@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.
- package/dist/{build-CCoxDxL-.js → build-Cpod-K9R.js} +9 -4
- package/dist/cli/bin.js +5 -5
- package/dist/{config-D_U_aCQz.js → config-CYLBzMMb.js} +1077 -18
- package/dist/{config-p-TWNXbv.d.ts → config-IkwICvOe.d.ts} +2 -1
- package/dist/{dev-CTrB263u.js → dev-B7x-G-wk.js} +1 -1
- package/dist/{export-enKL9R7x.js → export-BxMksWEc.js} +74 -12
- package/dist/index.d.ts +23 -3
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +96 -0
- package/dist/{preview-Dmzyd1cf.js → preview-CMv1lGOC.js} +1 -1
- package/dist/{types-DDdUFUIS.d.ts → types-B-9WXL93.d.ts} +24 -0
- package/dist/vite/index.d.ts +1 -1
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/apply-comments/SKILL.md +6 -4
- package/skills/create-email/SKILL.md +166 -0
- package/skills/create-theme/SKILL.md +31 -7
- package/skills/create-theme/references/email-theme-from-css.mjs +185 -0
- package/skills/create-theme/references/email-theme.md +48 -0
- package/skills/current-page/SKILL.md +3 -3
- package/src/app/app.tsx +4 -0
- package/src/app/components/command/home-command-menu.tsx +19 -2
- package/src/app/components/selection-note.tsx +71 -0
- package/src/app/components/sidebar/folder-item.tsx +9 -2
- package/src/app/components/sidebar/sidebar.tsx +10 -0
- package/src/app/frame/email-inspect.ts +17 -0
- package/src/app/lib/emails.ts +17 -0
- package/src/app/lib/frame.ts +15 -4
- package/src/app/lib/page-thumb.tsx +11 -3
- package/src/app/lib/sdk.ts +22 -0
- package/src/app/routes/email.tsx +424 -0
- package/src/app/routes/emails.tsx +94 -0
- package/src/app/routes/home-shell.tsx +21 -2
- package/src/app/routes/page.tsx +9 -50
- package/src/app/virtual.d.ts +10 -0
- package/src/locale/en.ts +25 -0
- package/src/locale/ja.ts +25 -0
- package/src/locale/types.ts +24 -0
- package/src/locale/zh-cn.ts +25 -0
- package/src/locale/zh-tw.ts +25 -0
- package/workspace/components.json +3 -1
- package/workspace/styles/globals.css +8 -2
- package/dist/open-pages-plugin-C8cFhMB9.js +0 -662
- 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({
|
|
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 /
|
|
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:
|
|
46
|
+
width: viewport,
|
|
39
47
|
height: size.height / scale,
|
|
40
48
|
transform: `scale(${scale})`,
|
|
41
49
|
}}
|
package/src/app/lib/sdk.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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)}
|