@autono/open-pages 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/{build-CCoxDxL-.js → build-Cpod-K9R.js} +9 -4
  2. package/dist/cli/bin.js +5 -5
  3. package/dist/{config-D_U_aCQz.js → config-CYLBzMMb.js} +1077 -18
  4. package/dist/{config-p-TWNXbv.d.ts → config-IkwICvOe.d.ts} +2 -1
  5. package/dist/{dev-CTrB263u.js → dev-B7x-G-wk.js} +1 -1
  6. package/dist/{export-enKL9R7x.js → export-BxMksWEc.js} +74 -12
  7. package/dist/index.d.ts +23 -3
  8. package/dist/locale/index.d.ts +1 -1
  9. package/dist/locale/index.js +96 -0
  10. package/dist/{preview-Dmzyd1cf.js → preview-CMv1lGOC.js} +1 -1
  11. package/dist/{types-DDdUFUIS.d.ts → types-B-9WXL93.d.ts} +24 -0
  12. package/dist/vite/index.d.ts +1 -1
  13. package/dist/vite/index.js +1 -1
  14. package/package.json +1 -1
  15. package/skills/apply-comments/SKILL.md +6 -4
  16. package/skills/create-email/SKILL.md +166 -0
  17. package/skills/create-theme/SKILL.md +31 -7
  18. package/skills/create-theme/references/email-theme-from-css.mjs +185 -0
  19. package/skills/create-theme/references/email-theme.md +48 -0
  20. package/skills/current-page/SKILL.md +3 -3
  21. package/src/app/app.tsx +4 -0
  22. package/src/app/components/command/home-command-menu.tsx +19 -2
  23. package/src/app/components/selection-note.tsx +71 -0
  24. package/src/app/components/sidebar/folder-item.tsx +9 -2
  25. package/src/app/components/sidebar/sidebar.tsx +10 -0
  26. package/src/app/frame/email-inspect.ts +17 -0
  27. package/src/app/lib/emails.ts +17 -0
  28. package/src/app/lib/frame.ts +15 -4
  29. package/src/app/lib/page-thumb.tsx +11 -3
  30. package/src/app/lib/sdk.ts +22 -0
  31. package/src/app/routes/email.tsx +424 -0
  32. package/src/app/routes/emails.tsx +94 -0
  33. package/src/app/routes/home-shell.tsx +21 -2
  34. package/src/app/routes/page.tsx +9 -50
  35. package/src/app/virtual.d.ts +10 -0
  36. package/src/locale/en.ts +25 -0
  37. package/src/locale/ja.ts +25 -0
  38. package/src/locale/types.ts +24 -0
  39. package/src/locale/zh-cn.ts +25 -0
  40. package/src/locale/zh-tw.ts +25 -0
  41. package/workspace/components.json +3 -1
  42. package/workspace/styles/globals.css +8 -2
  43. package/dist/open-pages-plugin-C8cFhMB9.js +0 -662
  44. package/dist/version-DZrXLoAD.js +0 -42
@@ -8,7 +8,15 @@ const THUMB_VIEWPORT = 1280;
8
8
  * desktop width and scaled down to fit. Inert — pointer events go to the
9
9
  * card, and the frame never takes focus.
10
10
  */
11
- export function PageThumb({ source, title }: { source: FrameSource; title: string }) {
11
+ export function PageThumb({
12
+ source,
13
+ title,
14
+ viewport = THUMB_VIEWPORT,
15
+ }: {
16
+ source: FrameSource;
17
+ title: string;
18
+ viewport?: number;
19
+ }) {
12
20
  const ref = useRef<HTMLDivElement | null>(null);
13
21
  const [size, setSize] = useState({ width: 0, height: 0 });
14
22
 
@@ -22,7 +30,7 @@ export function PageThumb({ source, title }: { source: FrameSource; title: strin
22
30
  return () => ro.disconnect();
23
31
  }, []);
24
32
 
25
- const scale = size.width > 0 ? size.width / THUMB_VIEWPORT : 0;
33
+ const scale = size.width > 0 ? size.width / viewport : 0;
26
34
 
27
35
  return (
28
36
  <div ref={ref} className="h-full w-full overflow-hidden bg-white">
@@ -35,7 +43,7 @@ export function PageThumb({ source, title }: { source: FrameSource; title: strin
35
43
  aria-hidden
36
44
  className="pointer-events-none origin-top-left border-0"
37
45
  style={{
38
- width: THUMB_VIEWPORT,
46
+ width: viewport,
39
47
  height: size.height / scale,
40
48
  transform: `scale(${scale})`,
41
49
  }}
@@ -29,6 +29,28 @@ export type PageModule = {
29
29
  design?: DesignSystem;
30
30
  };
31
31
 
32
+ export type EmailMeta = {
33
+ /** Display name in the workspace. Default: the folder name. */
34
+ title?: string;
35
+ /** Subject line the email is meant to be sent with. */
36
+ subject?: string;
37
+ /** What the email is for; shown on the workspace card. */
38
+ description?: string;
39
+ /** ISO 8601 timestamp. Set once at scaffold time; used to sort the email list. */
40
+ createdAt?: string;
41
+ };
42
+
43
+ /**
44
+ * An email is one react-email component rendered on the server to a single
45
+ * HTML document with inlined styles, plus a plain-text alternative derived
46
+ * from it. It never runs in a browser: no hooks with effects, no state, no
47
+ * browser APIs.
48
+ */
49
+ export type EmailModule = {
50
+ default: ComponentType;
51
+ meta?: EmailMeta;
52
+ };
53
+
32
54
  export type FolderIcon = { type: 'emoji'; value: string } | { type: 'color'; value: string };
33
55
 
34
56
  export type Folder = {
@@ -0,0 +1,424 @@
1
+ import config from 'virtual:open-pages/config';
2
+ import {
3
+ ChevronLeft,
4
+ Code2,
5
+ Copy,
6
+ Crosshair,
7
+ ExternalLink,
8
+ FileText,
9
+ Loader2,
10
+ Monitor,
11
+ RotateCw,
12
+ Smartphone,
13
+ TriangleAlert,
14
+ } from 'lucide-react';
15
+ import { useCallback, useEffect, useMemo, useState } from 'react';
16
+ import { useNavigate, useParams } from 'react-router-dom';
17
+ import { toast } from 'sonner';
18
+ import { type Selection, SelectionNote } from '~/components/selection-note';
19
+ import { Button, buttonVariants } from '~/components/ui/button';
20
+ import { readLastHomeLocation } from '~/lib/last-home-location';
21
+ import { format, useLocale } from '~/lib/use-locale';
22
+ import { cn } from '~/lib/utils';
23
+ import { emailChangeIncludes, emailIds, emailMeta } from '../lib/emails';
24
+ import { emailUrl, type FrameMessage, isFrameMessage, type WorkspaceMessage } from '../lib/frame';
25
+
26
+ const { showPageUi, showPageBrowser } = config.build;
27
+
28
+ type View = 'html' | 'text';
29
+ type Viewport = { id: 'desktop' | 'mobile'; width: number | null };
30
+
31
+ const VIEWPORTS: Viewport[] = [
32
+ { id: 'desktop', width: null },
33
+ { id: 'mobile', width: 375 },
34
+ ];
35
+ const VIEWPORT_STORAGE_KEY = 'open-pages:email-viewport';
36
+ const VIEWPORT_ICONS = { desktop: Monitor, mobile: Smartphone } as const;
37
+
38
+ function readViewportPref(): Viewport {
39
+ try {
40
+ const raw = window.localStorage.getItem(VIEWPORT_STORAGE_KEY);
41
+ return VIEWPORTS.find((v) => v.id === raw) ?? VIEWPORTS[0];
42
+ } catch {
43
+ return VIEWPORTS[0];
44
+ }
45
+ }
46
+
47
+ export function EmailView() {
48
+ const { emailId = '' } = useParams();
49
+ const known = emailIds.includes(emailId);
50
+ const info = emailMeta[emailId];
51
+ const title = info?.title ?? emailId;
52
+ const t = useLocale();
53
+
54
+ useEffect(() => {
55
+ document.title = `${title} — Autono`;
56
+ return () => {
57
+ document.title = 'Autono';
58
+ };
59
+ }, [title]);
60
+
61
+ const navigate = useNavigate();
62
+ const goBack = useCallback(() => {
63
+ const idx = (window.history.state as { idx?: number } | null)?.idx ?? 0;
64
+ if (idx > 0) navigate(-1);
65
+ else navigate(readLastHomeLocation(), { replace: true });
66
+ }, [navigate]);
67
+
68
+ const [view, setView] = useState<View>('html');
69
+ const [viewport, setViewport] = useState<Viewport>(readViewportPref);
70
+ const pickViewport = (v: Viewport) => {
71
+ setViewport(v);
72
+ try {
73
+ window.localStorage.setItem(VIEWPORT_STORAGE_KEY, v.id);
74
+ } catch {}
75
+ };
76
+
77
+ // Bumped on every change to the email or its imports; the frame and the
78
+ // text alternative both re-fetch from the server.
79
+ const [version, setVersion] = useState(0);
80
+ const [frameEl, setFrameEl] = useState<HTMLIFrameElement | null>(null);
81
+ const [ready, setReady] = useState(false);
82
+ const [text, setText] = useState<string | null>(null);
83
+
84
+ useEffect(() => {
85
+ if (!import.meta.hot) return;
86
+ const handler = (data: unknown) => {
87
+ if (emailChangeIncludes(data, emailId)) setVersion((v) => v + 1);
88
+ };
89
+ import.meta.hot.on('open-pages:email-changed', handler);
90
+ return () => import.meta.hot?.off('open-pages:email-changed', handler);
91
+ }, [emailId]);
92
+
93
+ const [unresolved, setUnresolved] = useState<string[]>([]);
94
+ useEffect(() => {
95
+ if (!known) return;
96
+ let cancelled = false;
97
+ fetch(`${emailUrl(emailId, 'json')}?v=${version}`)
98
+ .then((res) => (res.ok ? res.json() : null))
99
+ .then((body: { unresolved?: unknown } | null) => {
100
+ if (cancelled) return;
101
+ setUnresolved(Array.isArray(body?.unresolved) ? (body.unresolved as string[]) : []);
102
+ })
103
+ .catch(() => {
104
+ if (!cancelled) setUnresolved([]);
105
+ });
106
+ return () => {
107
+ cancelled = true;
108
+ };
109
+ }, [known, emailId, version]);
110
+
111
+ useEffect(() => {
112
+ if (view !== 'text' || !known) return;
113
+ let cancelled = false;
114
+ setText(null);
115
+ fetch(`${emailUrl(emailId, 'text')}?v=${version}`)
116
+ .then((res) => res.text())
117
+ .then((body) => {
118
+ if (!cancelled) setText(body);
119
+ })
120
+ .catch((e) => {
121
+ if (!cancelled) setText(e instanceof Error ? e.message : String(e));
122
+ });
123
+ return () => {
124
+ cancelled = true;
125
+ };
126
+ }, [view, known, emailId, version]);
127
+
128
+ const [inspecting, setInspecting] = useState(false);
129
+ const [selection, setSelection] = useState<Selection | null>(null);
130
+ const [note, setNote] = useState('');
131
+ const [savingNote, setSavingNote] = useState(false);
132
+
133
+ const send = useCallback(
134
+ (msg: WorkspaceMessage) => {
135
+ frameEl?.contentWindow?.postMessage(msg, '*');
136
+ },
137
+ [frameEl],
138
+ );
139
+
140
+ useEffect(() => {
141
+ send({ type: 'op:inspect', on: inspecting });
142
+ if (!inspecting) {
143
+ setSelection(null);
144
+ send({ type: 'op:select', loc: null });
145
+ }
146
+ }, [inspecting, send]);
147
+
148
+ useEffect(() => {
149
+ const onMessage = (event: MessageEvent) => {
150
+ if (event.source !== frameEl?.contentWindow) return;
151
+ if (!isFrameMessage(event.data)) return;
152
+ const msg: FrameMessage = event.data;
153
+ if (msg.type === 'op:ready') {
154
+ setReady(true);
155
+ send({ type: 'op:inspect', on: inspecting });
156
+ } else if (msg.type === 'op:select') {
157
+ setSelection({ loc: msg.loc, tag: msg.tag, text: msg.text });
158
+ setNote('');
159
+ } else if (msg.type === 'op:key') {
160
+ if (msg.key === 'i') setInspecting((v) => !v);
161
+ if (msg.key === 'Escape') setSelection(null);
162
+ }
163
+ };
164
+ window.addEventListener('message', onMessage);
165
+ return () => window.removeEventListener('message', onMessage);
166
+ }, [frameEl, inspecting, send]);
167
+
168
+ useEffect(() => {
169
+ const onKey = (e: KeyboardEvent) => {
170
+ const tag = (e.target as HTMLElement | null)?.tagName;
171
+ if (tag === 'INPUT' || tag === 'TEXTAREA') return;
172
+ if (e.key === 'i') setInspecting((v) => !v);
173
+ if (e.key === 'Escape') setSelection(null);
174
+ };
175
+ window.addEventListener('keydown', onKey);
176
+ return () => window.removeEventListener('keydown', onKey);
177
+ }, []);
178
+
179
+ useEffect(() => {
180
+ if (!import.meta.hot || !emailId || !known) return;
181
+ import.meta.hot.send('open-pages:current', {
182
+ pageId: emailId,
183
+ pageTitle: title,
184
+ view: 'emails',
185
+ });
186
+ }, [emailId, known, title]);
187
+
188
+ useEffect(() => {
189
+ if (!import.meta.hot) return;
190
+ const [line, column] = selection
191
+ ? selection.loc.split(':').map(Number)
192
+ : [undefined, undefined];
193
+ import.meta.hot.send('open-pages:current', {
194
+ selection: selection ? { line, column, tagName: selection.tag, text: selection.text } : null,
195
+ });
196
+ send({ type: 'op:select', loc: selection?.loc ?? null });
197
+ }, [selection, send]);
198
+
199
+ const submitComment = useCallback(async () => {
200
+ if (!selection || !note.trim()) return;
201
+ const [line, column] = selection.loc.split(':').map(Number);
202
+ setSavingNote(true);
203
+ try {
204
+ const res = await fetch('/__comments/add', {
205
+ method: 'POST',
206
+ headers: { 'Content-Type': 'application/json' },
207
+ body: JSON.stringify({ pageId: emailId, target: 'email', line, column, text: note.trim() }),
208
+ });
209
+ if (!res.ok) {
210
+ const body = (await res.json().catch(() => ({}))) as { error?: string };
211
+ throw new Error(body.error ?? `POST /__comments/add → ${res.status}`);
212
+ }
213
+ toast.success('Comment saved — run /apply-comments to apply it');
214
+ setSelection(null);
215
+ setNote('');
216
+ } catch (e) {
217
+ toast.error(e instanceof Error ? e.message : String(e));
218
+ } finally {
219
+ setSavingNote(false);
220
+ }
221
+ }, [selection, note, emailId]);
222
+
223
+ const copyHtml = useCallback(async () => {
224
+ try {
225
+ const res = await fetch(`${emailUrl(emailId, 'html')}?raw`);
226
+ if (!res.ok) throw new Error(`${res.status}`);
227
+ await navigator.clipboard.writeText(await res.text());
228
+ toast.success(t.emails.copiedHtml);
229
+ } catch {
230
+ toast.error(t.emails.copyHtmlFailed);
231
+ }
232
+ }, [emailId, t]);
233
+
234
+ const frameSrc = useMemo(
235
+ () => (known ? `${emailUrl(emailId, 'html')}?v=${version}` : ''),
236
+ [known, emailId, version],
237
+ );
238
+ const rawSrc = known ? `${emailUrl(emailId, 'html')}?raw` : undefined;
239
+ const canInspect = view === 'html' && import.meta.env.DEV;
240
+
241
+ return (
242
+ <div className="flex h-screen flex-col bg-muted/40 text-foreground">
243
+ {showPageUi && (
244
+ <header className="flex h-12 shrink-0 items-center gap-2 border-b bg-background px-3">
245
+ {showPageBrowser && (
246
+ <Button variant="ghost" size="icon" aria-label={t.emails.backToEmails} onClick={goBack}>
247
+ <ChevronLeft className="size-4" />
248
+ </Button>
249
+ )}
250
+ <h1 className="min-w-0 truncate text-sm font-medium">{title}</h1>
251
+ <span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
252
+ email
253
+ </span>
254
+ {known && (
255
+ <span
256
+ className="hidden min-w-0 truncate text-xs text-muted-foreground md:inline"
257
+ title={t.emails.subjectLabel}
258
+ >
259
+ {info?.subject ?? t.emails.noSubject}
260
+ </span>
261
+ )}
262
+ <div className="ml-auto flex items-center gap-2">
263
+ {unresolved.length > 0 && (
264
+ <span
265
+ className="flex items-center gap-1.5 rounded-md border border-amber-500/40 bg-amber-500/10 px-2 py-1 text-xs text-amber-700 dark:text-amber-300"
266
+ title={`${t.emails.unresolvedTitle}\n${unresolved.join(' ')}`}
267
+ >
268
+ <TriangleAlert className="size-3.5" aria-hidden />
269
+ {format(t.emails.unresolvedCount, { count: String(unresolved.length) })}
270
+ </span>
271
+ )}
272
+ {!ready && known && view === 'html' && (
273
+ <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
274
+ <Loader2 className="size-3.5 animate-spin" />
275
+ {t.page.loadingEyebrow}
276
+ </span>
277
+ )}
278
+ <fieldset className="flex items-center rounded-md border p-0.5" aria-label="View">
279
+ <Button
280
+ variant={view === 'html' ? 'secondary' : 'ghost'}
281
+ size="sm"
282
+ aria-pressed={view === 'html'}
283
+ onClick={() => setView('html')}
284
+ >
285
+ <Code2 className="size-3.5" />
286
+ {t.emails.viewHtml}
287
+ </Button>
288
+ <Button
289
+ variant={view === 'text' ? 'secondary' : 'ghost'}
290
+ size="sm"
291
+ aria-pressed={view === 'text'}
292
+ onClick={() => setView('text')}
293
+ >
294
+ <FileText className="size-3.5" />
295
+ {t.emails.viewText}
296
+ </Button>
297
+ </fieldset>
298
+ {view === 'html' && (
299
+ <fieldset className="flex items-center rounded-md border p-0.5" aria-label="Viewport">
300
+ {VIEWPORTS.map((v) => {
301
+ const Icon = VIEWPORT_ICONS[v.id];
302
+ const label =
303
+ v.id === 'desktop' ? t.emails.viewportDesktop : t.emails.viewportMobile;
304
+ return (
305
+ <Button
306
+ key={v.id}
307
+ variant={viewport.id === v.id ? 'secondary' : 'ghost'}
308
+ size="icon-sm"
309
+ aria-label={label}
310
+ aria-pressed={viewport.id === v.id}
311
+ title={v.width ? `${label} · ${v.width}px` : label}
312
+ onClick={() => pickViewport(v)}
313
+ >
314
+ <Icon className="size-3.5" />
315
+ </Button>
316
+ );
317
+ })}
318
+ </fieldset>
319
+ )}
320
+ <Button
321
+ variant="outline"
322
+ size="icon-sm"
323
+ aria-label={t.emails.reload}
324
+ title={t.emails.reload}
325
+ onClick={() => {
326
+ setReady(false);
327
+ setVersion((v) => v + 1);
328
+ }}
329
+ >
330
+ <RotateCw className="size-3.5" />
331
+ </Button>
332
+ {canInspect && (
333
+ <Button
334
+ variant={inspecting ? 'default' : 'outline'}
335
+ size="sm"
336
+ onClick={() => setInspecting((v) => !v)}
337
+ disabled={!ready}
338
+ aria-pressed={inspecting}
339
+ title="Inspect elements (i) — click any element to select it or leave a comment"
340
+ >
341
+ <Crosshair className="size-4" />
342
+ Inspect
343
+ </Button>
344
+ )}
345
+ <Button
346
+ variant="outline"
347
+ size="sm"
348
+ onClick={copyHtml}
349
+ disabled={!known}
350
+ title={t.emails.copyHtml}
351
+ >
352
+ <Copy className="size-4" />
353
+ {t.emails.copyHtml}
354
+ </Button>
355
+ <a
356
+ href={rawSrc}
357
+ target="_blank"
358
+ rel="noreferrer"
359
+ aria-label={t.emails.openInTab}
360
+ aria-disabled={!known || undefined}
361
+ tabIndex={known ? undefined : -1}
362
+ title={t.emails.openInTab}
363
+ className={cn(
364
+ buttonVariants({ variant: 'outline', size: 'sm' }),
365
+ !known && 'pointer-events-none opacity-50',
366
+ )}
367
+ >
368
+ <ExternalLink className="size-4" />
369
+ Open
370
+ </a>
371
+ </div>
372
+ </header>
373
+ )}
374
+
375
+ {!known && (
376
+ <div className="border-b border-destructive/30 bg-destructive/10 px-4 py-2 font-mono text-xs text-destructive whitespace-pre-wrap">
377
+ {format(t.emails.notFound, { id: emailId })}
378
+ </div>
379
+ )}
380
+
381
+ <main className="relative flex min-h-0 flex-1 justify-center overflow-hidden">
382
+ {known && view === 'html' && (
383
+ <iframe
384
+ key={frameSrc}
385
+ ref={setFrameEl}
386
+ title={title}
387
+ src={frameSrc}
388
+ className={cn(
389
+ 'h-full border-0 bg-white',
390
+ viewport.width !== null && 'border-x shadow-md',
391
+ inspecting && 'cursor-crosshair',
392
+ )}
393
+ style={{ width: viewport.width ?? '100%', maxWidth: '100%' }}
394
+ />
395
+ )}
396
+ {known && view === 'text' && (
397
+ <div className="h-full w-full overflow-auto bg-background">
398
+ {text === null ? (
399
+ <div className="flex items-center gap-1.5 px-6 py-6 text-xs text-muted-foreground">
400
+ <Loader2 className="size-3.5 animate-spin" />
401
+ {t.page.loadingEyebrow}
402
+ </div>
403
+ ) : (
404
+ <pre className="mx-auto w-full max-w-[72ch] whitespace-pre-wrap px-6 py-8 font-mono text-[13px] leading-6 text-foreground">
405
+ {text}
406
+ </pre>
407
+ )}
408
+ </div>
409
+ )}
410
+
411
+ {selection && (
412
+ <SelectionNote
413
+ selection={selection}
414
+ note={note}
415
+ saving={savingNote}
416
+ onNoteChange={setNote}
417
+ onSubmit={submitComment}
418
+ onClear={() => setSelection(null)}
419
+ />
420
+ )}
421
+ </main>
422
+ </div>
423
+ );
424
+ }
@@ -0,0 +1,94 @@
1
+ import { Mail } from 'lucide-react';
2
+ import { Link } from 'react-router-dom';
3
+ import { format, useLocale } from '~/lib/use-locale';
4
+ import { SystemViewIcon } from '../components/sidebar/folder-item';
5
+ import { emailCreatedAt, emailIds, emailMeta } from '../lib/emails';
6
+ import { PageThumb } from '../lib/page-thumb';
7
+
8
+ // Emails lay out at 600px; a slightly wider thumb viewport keeps the body
9
+ // margins visible so the card reads as an email, not a cropped page.
10
+ const EMAIL_THUMB_VIEWPORT = 760;
11
+
12
+ export function EmailsGalleryPage() {
13
+ const t = useLocale();
14
+ const sorted = emailIds
15
+ .slice()
16
+ .sort((a, b) => (emailCreatedAt[b] ?? 0) - (emailCreatedAt[a] ?? 0));
17
+
18
+ return (
19
+ <>
20
+ <header className="mb-6 md:mb-8">
21
+ <div className="flex flex-wrap items-center gap-2.5">
22
+ <SystemViewIcon kind="emails" className="text-muted-foreground" />
23
+ <h1 className="font-heading text-[19px] font-semibold leading-none tracking-[-0.015em] md:text-[21px]">
24
+ {t.emails.title}
25
+ </h1>
26
+ <span className="folio ml-0.5">{emailIds.length.toString().padStart(2, '0')}</span>
27
+ </div>
28
+ </header>
29
+
30
+ {sorted.length === 0 ? (
31
+ <EmptyState />
32
+ ) : (
33
+ <ul className="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-x-6 gap-y-9 md:grid-cols-[repeat(auto-fill,minmax(300px,1fr))]">
34
+ {sorted.map((id, i) => {
35
+ const info = emailMeta[id];
36
+ const title = info?.title ?? id;
37
+ return (
38
+ <li
39
+ key={id}
40
+ className="rise-in"
41
+ style={{ animationDelay: `${Math.min(i, 11) * 30}ms` }}
42
+ >
43
+ <Link
44
+ to={`/e/${id}`}
45
+ aria-label={format(t.emails.openEmailAria, { name: title })}
46
+ className="group block rounded-[6px] outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
47
+ >
48
+ <div className="relative aspect-[4/3] overflow-hidden rounded-[6px] border border-hairline bg-card shadow-edge ring-1 ring-foreground/[0.04] group-hover:shadow-floating group-hover:ring-foreground/20 motion-safe:transition-[box-shadow,--tw-ring-color,scale] motion-safe:duration-200 group-active:scale-[0.99]">
49
+ <div className="h-full w-full motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-[1.03]">
50
+ <PageThumb
51
+ source={{ emailId: id }}
52
+ title={title}
53
+ viewport={EMAIL_THUMB_VIEWPORT}
54
+ />
55
+ </div>
56
+ </div>
57
+ <div className="mt-3 min-w-0">
58
+ <h3 className="truncate font-heading text-[14px] font-medium tracking-tight">
59
+ {title}
60
+ </h3>
61
+ <p className="mt-0.5 truncate text-[12.5px] text-muted-foreground">
62
+ {info?.subject ?? t.emails.noSubject}
63
+ </p>
64
+ </div>
65
+ </Link>
66
+ </li>
67
+ );
68
+ })}
69
+ </ul>
70
+ )}
71
+ </>
72
+ );
73
+ }
74
+
75
+ function EmptyState() {
76
+ const t = useLocale();
77
+ return (
78
+ <div className="rounded-[8px] border border-dashed border-border px-8 py-20">
79
+ <div className="mx-auto flex max-w-md flex-col items-center text-center">
80
+ <Mail className="size-5 text-muted-foreground/60" aria-hidden />
81
+ <p className="mt-4 font-heading text-[14px] font-semibold tracking-tight">
82
+ {t.emails.noEmailsTitle}
83
+ </p>
84
+ <p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
85
+ {t.emails.noEmailsHintPrefix}
86
+ <code className="rounded-[4px] bg-muted px-1.5 py-0.5 font-mono text-[11.5px] text-foreground">
87
+ /create-email
88
+ </code>
89
+ {t.emails.noEmailsHintSuffix}
90
+ </p>
91
+ </div>
92
+ </div>
93
+ );
94
+ }
@@ -18,7 +18,14 @@ import { cn } from '~/lib/utils';
18
18
  import { CommandMenuTrigger } from '../components/command/command-menu';
19
19
  import { HomeCommandMenu } from '../components/command/home-command-menu';
20
20
  import { SystemViewIcon } from '../components/sidebar/folder-item';
21
- import { ALL_DOCS_ID, ASSETS_ID, Sidebar, THEMES_ID } from '../components/sidebar/sidebar';
21
+ import {
22
+ ALL_DOCS_ID,
23
+ ASSETS_ID,
24
+ EMAILS_ID,
25
+ Sidebar,
26
+ THEMES_ID,
27
+ } from '../components/sidebar/sidebar';
28
+ import { emailIds } from '../lib/emails';
22
29
  import { pageIds } from '../lib/pages';
23
30
  import type { FoldersManifest } from '../lib/sdk';
24
31
  import { themes as themeRegistry } from '../lib/themes';
@@ -28,7 +35,7 @@ export type HomeOutletContext = {
28
35
  loading: boolean;
29
36
  draftDocs: string[];
30
37
  docsByFolder: Record<string, string[]>;
31
- /** Selected view id: ALL_DOCS_ID, DRAFT_ID, a folder id, THEMES_ID, or ASSETS_ID. */
38
+ /** Selected view id: ALL_DOCS_ID, DRAFT_ID, a folder id, THEMES_ID, ASSETS_ID, or EMAILS_ID. */
32
39
  selectedId: string;
33
40
  selectFolder: (id: string) => void;
34
41
  reportTitle: (pageId: string, title: string) => void;
@@ -42,6 +49,7 @@ export type HomeOutletContext = {
42
49
  function pathToSelectedId(pathname: string, search: URLSearchParams): string {
43
50
  if (pathname === '/themes' || pathname.startsWith('/themes/')) return THEMES_ID;
44
51
  if (pathname === '/assets') return ASSETS_ID;
52
+ if (pathname === '/emails') return EMAILS_ID;
45
53
  return search.get('f') ?? ALL_DOCS_ID;
46
54
  }
47
55
 
@@ -81,6 +89,7 @@ export function HomeShell() {
81
89
  (id: string) => {
82
90
  if (id === THEMES_ID) navigate('/themes', { replace: true });
83
91
  else if (id === ASSETS_ID) navigate('/assets', { replace: true });
92
+ else if (id === EMAILS_ID) navigate('/emails', { replace: true });
84
93
  else if (id === ALL_DOCS_ID) navigate('/', { replace: true });
85
94
  else navigate(`/?f=${encodeURIComponent(id)}`, { replace: true });
86
95
  },
@@ -151,6 +160,7 @@ export function HomeShell() {
151
160
  allCount={pageIds.length}
152
161
  themesCount={themeRegistry.length}
153
162
  assetsCount={globalAssets.length}
163
+ emailsCount={emailIds.length}
154
164
  selectedId={selectedId}
155
165
  onSelect={selectFolder}
156
166
  onCreate={(name, icon) => create(name, icon)}
@@ -215,6 +225,7 @@ export function HomeShell() {
215
225
  className={cn(
216
226
  selectedId !== THEMES_ID &&
217
227
  selectedId !== ASSETS_ID &&
228
+ selectedId !== EMAILS_ID &&
218
229
  'bg-muted text-foreground',
219
230
  )}
220
231
  >
@@ -232,6 +243,14 @@ export function HomeShell() {
232
243
  {themeRegistry.length.toString().padStart(2, '0')}
233
244
  </span>
234
245
  </DropdownMenuItem>
246
+ <DropdownMenuItem
247
+ onClick={() => selectFolder(EMAILS_ID)}
248
+ className={cn(selectedId === EMAILS_ID && 'bg-muted text-foreground')}
249
+ >
250
+ <SystemViewIcon kind="emails" className="text-muted-foreground" />
251
+ <span className="flex-1 truncate">{t.home.emails}</span>
252
+ <span className="folio">{emailIds.length.toString().padStart(2, '0')}</span>
253
+ </DropdownMenuItem>
235
254
  {import.meta.env.DEV && (
236
255
  <DropdownMenuItem
237
256
  onClick={() => selectFolder(ASSETS_ID)}