Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ Open [http://localhost:3000](http://localhost:3000) in your browser.

1. **Iframe-based preview** - Uses `srcdoc` with strict sandboxing for security. This shows how the email renders in a modern browser, not specific email clients.

2. **Regex-based CSS extraction** - Simple and fast for the MVP. Could be upgraded to PostCSS for more robust parsing.
2. **PostCSS-based CSS extraction** - Uses PostCSS with safe-parser for robust CSS parsing, handling nested rules, @-rules, and malformed CSS gracefully.

3. **Client-side analysis** - All processing happens in the browser. The caniemail data is fetched once and cached.

Expand Down Expand Up @@ -116,14 +116,17 @@ For true cross-client preview screenshots, commercial tools like [Litmus](https:
- [Next.js 16](https://nextjs.org) - React framework
- [TypeScript](https://typescriptlang.org) - Type safety
- [Tailwind CSS](https://tailwindcss.com) - Styling
- [PostCSS](https://postcss.org) - CSS parsing
- [caniemail.com](https://caniemail.com) - Compatibility data

## Author

**Dominik Hryshaiev** - [LinkedIn](https://www.linkedin.com/in/domhhv) · [GitHub](https://github.com/domhhv)

## License

MIT

## Acknowledgments

- [caniemail.com](https://www.caniemail.com) for their comprehensive email client support data
- [Resend](https://resend.com) for the interesting challenge
- [Email favicon](https://www.flaticon.com/free-icon/email_9840614?term=mail&page=1&position=53&origin=search&related_id=9840614) is created by [lakonicon](https://www.flaticon.com/authors/lakonicon) on Flaticon
Binary file modified src/app/favicon.ico
Binary file not shown.
97 changes: 50 additions & 47 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -44,30 +44,31 @@
}

:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.5rem;
--background: oklch(0.982 0 161.565);
--foreground: oklch(0.244 0 169.695);
--muted: oklch(0.952 0 165.964);
--muted-foreground: oklch(0.503 0 180);
--popover: oklch(0.991 0 180);
--popover-foreground: oklch(0.244 0 169.695);
--card: oklch(0.991 0 180);
--card-foreground: oklch(0.244 0 169.695);
--border: oklch(0.882 0 180);
--input: oklch(0.955 0 180);
--primary: oklch(0.689 0.19 45.166);
--primary-foreground: oklch(1 0 180);
--secondary: oklch(0.92 0.065 74.362);
--secondary-foreground: oklch(0.35 0.069 40.827);
--accent: oklch(0.931 0 171.254);
--accent-foreground: oklch(0.244 0 169.695);
--destructive: oklch(0.627 0.194 33.336);
--destructive-foreground: oklch(1 0 180);
--ring: oklch(0.745 0.132 54.33);
--chart-1: oklch(0.689 0.19 45.166);
--chart-2: oklch(0.92 0.065 74.362);
--chart-3: oklch(0.931 0 171.254);
--chart-4: oklch(0.936 0.052 74.572);
--chart-5: oklch(0.693 0.195 44.608);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
Expand All @@ -79,29 +80,31 @@
}

.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--radius: 0.5rem;
--background: oklch(0.178 0 180);
--foreground: oklch(0.949 0 180);
--muted: oklch(0.252 0 180);
--muted-foreground: oklch(0.77 0 141.34);
--popover: oklch(0.213 0 168.69);
--popover-foreground: oklch(0.949 0 180);
--card: oklch(0.213 0 168.69);
--card-foreground: oklch(0.949 0 180);
--border: oklch(0.235 0.011 90.394);
--input: oklch(0.402 0 195.945);
--primary: oklch(0.689 0.19 45.166);
--primary-foreground: oklch(1 0 180);
--secondary: oklch(0.258 0.045 60.789);
--secondary-foreground: oklch(0.925 0.052 66.177);
--accent: oklch(0.285 0 168.69);
--accent-foreground: oklch(0.949 0 180);
--destructive: oklch(0.627 0.194 33.336);
--destructive-foreground: oklch(1 0 180);
--ring: oklch(0.541 0.116 50.039);
--chart-1: oklch(0.689 0.19 45.166);
--chart-2: oklch(0.258 0.045 60.789);
--chart-3: oklch(0.285 0 168.69);
--chart-4: oklch(0.294 0.053 60.297);
--chart-5: oklch(0.693 0.195 44.608);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
Expand Down
17 changes: 11 additions & 6 deletions src/components/custom/email-content-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { useTheme } from 'next-themes';
import * as React from 'react';

import { TooltipButton } from '@/components/custom/tooltip-button';
import { Button } from '@/components/ui/button';
import { Kbd, KbdGroup } from '@/components/ui/kbd';
import { useHasKeyboard } from '@/hooks/use-has-keyboard';
import { useModifierKeys } from '@/hooks/use-modifier-keys';
Expand Down Expand Up @@ -82,16 +81,22 @@ export function EmailContentInput({ onChange, placeholder = 'Paste your HTML ema
<BrushCleaningIcon className="size-4" />
</TooltipButton>
) : (
<Button size="sm" variant="secondary" onClick={fillSample}>
<TooltipButton
size="sm"
variant="default"
onClick={fillSample}
tooltip="Fill input with sample email HTML for quick compatibility preview"
aria-label="Fill input with sample email HTML for quick compatibility preview"
>
{isFocused && hasKeyboard && (
<KbdGroup className="mr-2">
<Kbd className="bg-background">{mod}</Kbd>
<Kbd className="bg-background">{shift}</Kbd>
<Kbd className="bg-background">E</Kbd>
<Kbd>{mod}</Kbd>
<Kbd>{shift}</Kbd>
<Kbd>E</Kbd>
</KbdGroup>
)}
<span>Try Sample</span>
</Button>
</TooltipButton>
)}
</div>
<div className="min-h-0 flex-1 overflow-auto">
Expand Down
41 changes: 27 additions & 14 deletions src/components/layout/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,33 @@ type FooterProps = {

export function Footer({ canIEmailData }: FooterProps) {
return (
<footer className="border-border flex flex-col border-t px-6 py-3 text-center text-xs text-slate-400 md:flex-row md:justify-center md:text-center dark:text-slate-500">
<span>
Compatibility data powered by{' '}
<Link target="_blank" rel="noopener noreferrer" href="https://www.caniemail.com">
caniemail.com
</Link>
{canIEmailData && ` (${canIEmailData.data.length} features)`}
</span>
<span>
<span className="hidden md:inline-block">&nbsp;⋅&nbsp;</span>Email favicon created by lakonicon on{' '}
<Link target="_blank" rel="noopener noreferrer" href="https://www.flaticon.com/free-icons/email">
Flaticon
</Link>
</span>
<footer className="border-border flex flex-col gap-1 border-t px-6 py-3 text-center text-xs text-slate-400 dark:text-slate-500">
<div className="flex items-center justify-center gap-1.5">
<span>
Built by{' '}
<Link
target="_blank"
rel="noopener noreferrer"
href="https://www.linkedin.com/in/domhhv"
className="font-medium text-slate-600 hover:text-slate-800 dark:text-slate-300 dark:hover:text-slate-100"
>
Dominik Hryshaiev
</Link>
</span>
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-medium text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400">
<span className="mr-1 inline-block size-1.5 animate-pulse rounded-full bg-emerald-500 align-middle" />
Available for hire
</span>
</div>
<div className="flex flex-col md:flex-row md:justify-center">
<span>
Compatibility data powered by{' '}
<Link target="_blank" rel="noopener noreferrer" href="https://www.caniemail.com">
caniemail.com
</Link>
{canIEmailData && ` (${canIEmailData.data.length} features)`}
</span>
</div>
</footer>
);
}