@acms-subir/core 0.0.1

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 (107) hide show
  1. package/cli.mjs +75 -0
  2. package/integration.mjs +286 -0
  3. package/package.json +89 -0
  4. package/src/actions/index.ts +502 -0
  5. package/src/components/DesignManager.tsx +542 -0
  6. package/src/components/LinksManager.tsx +694 -0
  7. package/src/components/Modal.astro +65 -0
  8. package/src/components/NavManager.tsx +168 -0
  9. package/src/components/WidgetRenderer.astro +84 -0
  10. package/src/components/builder/PageBuilder.tsx +575 -0
  11. package/src/components/editor/ImagePicker.tsx +215 -0
  12. package/src/components/editor/PostEditor.tsx +1011 -0
  13. package/src/components/editor/PreviewOverlay.tsx +77 -0
  14. package/src/components/editor/extensions/calendar.tsx +230 -0
  15. package/src/components/editor/extensions/cards.tsx +488 -0
  16. package/src/components/editor/extensions/codeBlock.tsx +70 -0
  17. package/src/components/editor/extensions/infoCards.tsx +270 -0
  18. package/src/components/editor/extensions/mediaCards.tsx +376 -0
  19. package/src/components/editor/extensions/postList.tsx +130 -0
  20. package/src/components/editor/extensions/slash.tsx +250 -0
  21. package/src/components/editor/extensions/tableTyping.ts +63 -0
  22. package/src/components/editor/imaging.ts +146 -0
  23. package/src/components/media/CropModal.tsx +229 -0
  24. package/src/components/media/MediaLibrary.tsx +295 -0
  25. package/src/layouts/Admin.astro +119 -0
  26. package/src/layouts/Site.astro +218 -0
  27. package/src/lib/auth.ts +94 -0
  28. package/src/lib/backup/unzip.ts +38 -0
  29. package/src/lib/backup/zip.ts +105 -0
  30. package/src/lib/blocksToHtml.ts +68 -0
  31. package/src/lib/brand.ts +26 -0
  32. package/src/lib/calendar/ics.ts +402 -0
  33. package/src/lib/calendar/render.ts +275 -0
  34. package/src/lib/calendar/src.ts +68 -0
  35. package/src/lib/content.ts +53 -0
  36. package/src/lib/dates.ts +34 -0
  37. package/src/lib/dynamicBlocks.ts +115 -0
  38. package/src/lib/env.ts +6 -0
  39. package/src/lib/highlightCode.ts +44 -0
  40. package/src/lib/i18n.ts +40 -0
  41. package/src/lib/markdown.ts +7 -0
  42. package/src/lib/media/store.ts +95 -0
  43. package/src/lib/net.ts +51 -0
  44. package/src/lib/preview.ts +28 -0
  45. package/src/lib/repo/d1.ts +44 -0
  46. package/src/lib/repo/index.ts +23 -0
  47. package/src/lib/repo/migrations.ts +231 -0
  48. package/src/lib/repo/search.ts +14 -0
  49. package/src/lib/repo/sql.ts +721 -0
  50. package/src/lib/repo/sqlite.ts +43 -0
  51. package/src/lib/repo/types.ts +132 -0
  52. package/src/lib/schema.ts +286 -0
  53. package/src/lib/seo.ts +37 -0
  54. package/src/lib/theme.ts +206 -0
  55. package/src/lib/widgets.ts +108 -0
  56. package/src/middleware.ts +109 -0
  57. package/src/pages/[...slug].astro +43 -0
  58. package/src/pages/admin/c/[collection]/[id].astro +56 -0
  59. package/src/pages/admin/c/[collection]/index.astro +163 -0
  60. package/src/pages/admin/design.astro +42 -0
  61. package/src/pages/admin/index.astro +145 -0
  62. package/src/pages/admin/links.astro +21 -0
  63. package/src/pages/admin/login.astro +83 -0
  64. package/src/pages/admin/logout.ts +7 -0
  65. package/src/pages/admin/media.astro +13 -0
  66. package/src/pages/admin/navigation.astro +17 -0
  67. package/src/pages/admin/pages/[id].astro +49 -0
  68. package/src/pages/admin/pages/index.astro +100 -0
  69. package/src/pages/admin/posts/[id].astro +6 -0
  70. package/src/pages/admin/posts/index.astro +7 -0
  71. package/src/pages/admin/settings.astro +512 -0
  72. package/src/pages/admin/tags.astro +101 -0
  73. package/src/pages/admin/users.astro +149 -0
  74. package/src/pages/api/backup.ts +63 -0
  75. package/src/pages/api/drafts.ts +106 -0
  76. package/src/pages/api/media/upload.ts +89 -0
  77. package/src/pages/api/restore.ts +102 -0
  78. package/src/pages/author/[slug].astro +66 -0
  79. package/src/pages/blog/[slug].astro +240 -0
  80. package/src/pages/blog/index.astro +51 -0
  81. package/src/pages/blog/tag/[slug].astro +83 -0
  82. package/src/pages/embed/[pageId]/[blockId].ts +45 -0
  83. package/src/pages/go/[id].ts +10 -0
  84. package/src/pages/index.astro +52 -0
  85. package/src/pages/links.astro +110 -0
  86. package/src/pages/llms.txt.ts +47 -0
  87. package/src/pages/media/[...key].ts +18 -0
  88. package/src/pages/preview/[type]/[id].astro +52 -0
  89. package/src/pages/robots.txt.ts +28 -0
  90. package/src/pages/rss.xml.ts +58 -0
  91. package/src/pages/search.astro +83 -0
  92. package/src/pages/sitemap.xml.ts +55 -0
  93. package/src/styles/admin.css +652 -0
  94. package/src/styles/builder.css +121 -0
  95. package/src/styles/cards.css +176 -0
  96. package/src/styles/editor.css +596 -0
  97. package/src/styles/site.css +427 -0
  98. package/src/themes/editorial/templates/home.astro +127 -0
  99. package/src/themes/editorial/templates/post-minimal.astro +50 -0
  100. package/src/themes/editorial/theme.css +108 -0
  101. package/src/themes/editorial/theme.json +20 -0
  102. package/src/themes/field/theme.json +17 -0
  103. package/src/themes/ink/theme.json +17 -0
  104. package/src/themes/night/theme.css +7 -0
  105. package/src/themes/night/theme.json +17 -0
  106. package/src/virtual.d.ts +25 -0
  107. package/src/worker.ts +28 -0
@@ -0,0 +1,1011 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { useEditor, EditorContent } from '@tiptap/react';
3
+ import { BubbleMenu, FloatingMenu } from '@tiptap/react/menus';
4
+ import type { Range } from '@tiptap/react';
5
+ import StarterKit from '@tiptap/starter-kit';
6
+ import { TableKit } from '@tiptap/extension-table';
7
+ import { Placeholder, CharacterCount } from '@tiptap/extensions';
8
+ import { actions } from 'astro:actions';
9
+ import type { Page, Post, Status } from '../../lib/schema';
10
+ import { ImageCard, EmbedCard, ButtonCard, Callout, HtmlCard, detectEmbed } from './extensions/cards';
11
+ import { FileCard, GalleryCard, BookmarkCard } from './extensions/mediaCards';
12
+ import { CodeBlock } from './extensions/codeBlock';
13
+ import { PostListCard } from './extensions/postList';
14
+ import { CalendarCard } from './extensions/calendar';
15
+ import { InfoCards } from './extensions/infoCards';
16
+ import { SlashMenu } from './extensions/slash';
17
+ import { TableTyping } from './extensions/tableTyping';
18
+ import { ImagePicker, type PickedImage } from './ImagePicker';
19
+ import { PreviewOverlay } from './PreviewOverlay';
20
+ import { renderMarkdown } from '../../lib/markdown';
21
+ import { CONTENT, collectionUrl } from '../../lib/content';
22
+ import { I18N, localeHomeUrl } from '../../lib/i18n';
23
+
24
+ // The Ghost-style writing surface, shared by posts AND pages: distraction-free
25
+ // column, cards via "/", autosave, a collapsible settings sidebar, and an
26
+ // optional side-by-side live preview. `kind` switches the save action and
27
+ // which settings fields exist — everything else is identical.
28
+
29
+ type SaveState = 'saved' | 'dirty' | 'saving' | 'error';
30
+
31
+ interface Props {
32
+ kind?: 'post' | 'page';
33
+ post?: Post;
34
+ page?: Page;
35
+ initialHTML: string;
36
+ unsplashEnabled: boolean;
37
+ previewPath: string;
38
+ sharePath: string;
39
+ // theme-provided template names for this document kind; empty = no picker
40
+ templates?: string[];
41
+ }
42
+
43
+ interface Meta {
44
+ title: string;
45
+ slug: string;
46
+ collection: string;
47
+ tags: string[];
48
+ excerpt: string;
49
+ cover: string;
50
+ coverAlt: string;
51
+ seoTitle: string;
52
+ seoDesc: string;
53
+ status: Status;
54
+ featured: boolean;
55
+ template: string;
56
+ isHome: boolean;
57
+ // pages only; '' = the site's default locale
58
+ locale: string;
59
+ authorId: string | null;
60
+ authorIds: string[];
61
+ publishedAt: number | null;
62
+ scheduledAt: number | null;
63
+ }
64
+
65
+ interface AuthorOption {
66
+ id: string;
67
+ name: string;
68
+ }
69
+
70
+ const tsToLocal = (ts: number | null) => {
71
+ if (!ts) return '';
72
+ const d = new Date(ts);
73
+ const pad = (n: number) => String(n).padStart(2, '0');
74
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
75
+ };
76
+ const localToTs = (v: string) => (v ? new Date(v).getTime() : null);
77
+
78
+ const STATUS_LABEL: Record<Status, string> = {
79
+ draft: 'Draft', scheduled: 'Scheduled', published: 'Published', archived: 'Archived',
80
+ };
81
+
82
+ // Pasted plain text that reads as markdown gets converted to rich content.
83
+ // Only structural/inline syntax counts — ordinary prose must pass through.
84
+ function looksLikeMarkdown(text: string): boolean {
85
+ return (
86
+ /^#{1,6}\s+\S/m.test(text) || // headings
87
+ /^\s{0,3}(?:[-*+]|\d+\.)\s+\S/m.test(text) || // lists
88
+ /^\s{0,3}>\s?\S/m.test(text) || // blockquotes
89
+ /^```/m.test(text) || // fenced code
90
+ /^\s{0,3}(?:-{3,}|\*{3,})\s*$/m.test(text) || // hr
91
+ /^\s*\|.+\|\s*$/m.test(text) || // tables
92
+ /\*\*[^*\n]+\*\*|__[^_\n]+__/.test(text) || // bold
93
+ /!?\[[^\]\n]+\]\([^)\n]+\)/.test(text) // links / images
94
+ );
95
+ }
96
+
97
+ // Table rows pasted back out of a rendered doc arrive one-per-paragraph with
98
+ // blank lines between them — not a valid markdown table. Rejoin consecutive
99
+ // pipe rows so marked sees a single table block.
100
+ function joinBrokenTableRows(text: string): string {
101
+ const isRow = (l: string) => /^\s*\|.*\|\s*$/.test(l);
102
+ const lines = text.split('\n');
103
+ const out: string[] = [];
104
+ for (let i = 0; i < lines.length; i++) {
105
+ if (lines[i].trim() === '' && out.length && isRow(out[out.length - 1])) {
106
+ let j = i;
107
+ while (j < lines.length && lines[j].trim() === '') j++;
108
+ if (j < lines.length && isRow(lines[j])) { i = j - 1; continue; }
109
+ }
110
+ out.push(lines[i]);
111
+ }
112
+ return out.join('\n');
113
+ }
114
+
115
+ export default function PostEditor({ kind = 'post', post, page, initialHTML, unsplashEnabled, previewPath, sharePath, templates = [] }: Props) {
116
+ const doc = (kind === 'page' ? page : post)!;
117
+ const docId = doc.id;
118
+
119
+ const [meta, setMetaState] = useState<Meta>(() =>
120
+ kind === 'page'
121
+ ? {
122
+ title: page!.title, slug: page!.slug, collection: 'posts', tags: [], excerpt: '', cover: '', coverAlt: '',
123
+ seoTitle: page!.seoTitle, seoDesc: page!.seoDesc, status: page!.status,
124
+ featured: false, isHome: page!.isHome, locale: page!.locale, authorId: null, authorIds: [], template: page!.template, publishedAt: null, scheduledAt: null,
125
+ }
126
+ : {
127
+ title: post!.title, slug: post!.slug, collection: post!.collection, tags: post!.tags, excerpt: post!.excerpt,
128
+ cover: post!.cover, coverAlt: post!.coverAlt, seoTitle: post!.seoTitle, seoDesc: post!.seoDesc,
129
+ status: post!.status, featured: post!.featured, isHome: false, locale: '', authorId: post!.authorId,
130
+ authorIds: post!.authorIds.length ? post!.authorIds : post!.authorId ? [post!.authorId] : [],
131
+ template: post!.template,
132
+ publishedAt: post!.publishedAt, scheduledAt: post!.scheduledAt,
133
+ }
134
+ );
135
+ // labels + URL base follow the post's collection (config key on the row)
136
+ const col = kind === 'page' ? CONTENT.posts : (CONTENT.collections[meta.collection] ?? CONTENT.posts);
137
+ const listHref = kind === 'page' ? '/admin/pages' : `/admin/c/${meta.collection}`;
138
+ const noun = kind === 'page' ? 'page' : col.singular.toLowerCase();
139
+
140
+ const [authors, setAuthors] = useState<AuthorOption[]>([]);
141
+ const [tagSuggestions, setTagSuggestions] = useState<string[]>([]);
142
+ const metaRef = useRef(meta);
143
+ const [saveState, setSaveState] = useState<SaveState>('saved');
144
+ const saveStateRef = useRef<SaveState>('saved');
145
+ const [sidebarOpen, setSidebarOpen] = useState(false);
146
+ const [publishOpen, setPublishOpen] = useState(false);
147
+ const [previewOpen, setPreviewOpen] = useState(false);
148
+ const [split, setSplit] = useState(() => {
149
+ try { return localStorage.getItem('pe-split') === '1'; } catch { return false; }
150
+ });
151
+ const [previewTick, setPreviewTick] = useState(0);
152
+ const [picker, setPicker] = useState<'closed' | 'editor' | 'feature'>('closed');
153
+ const pickerRangeRef = useRef<Range | null>(null);
154
+ const [scheduleDraft, setScheduleDraft] = useState('');
155
+ const [linkEdit, setLinkEdit] = useState(false);
156
+ const [linkDraft, setLinkDraft] = useState('');
157
+ const [shortcutsOpen, setShortcutsOpen] = useState(false);
158
+ const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
159
+
160
+ const setSave = (s: SaveState) => { saveStateRef.current = s; setSaveState(s); };
161
+ const toggleSplit = () => {
162
+ setSplit((v) => {
163
+ try { localStorage.setItem('pe-split', v ? '0' : '1'); } catch { /* private mode */ }
164
+ return !v;
165
+ });
166
+ };
167
+
168
+ const editor = useEditor({
169
+ extensions: [
170
+ StarterKit.configure({
171
+ heading: { levels: [2, 3] },
172
+ link: { openOnClick: false },
173
+ codeBlock: false,
174
+ }),
175
+ CodeBlock,
176
+ Placeholder.configure({
177
+ // Only the empty document gets the invitation; empty paragraphs in an
178
+ // existing doc get the floating quick-insert menu instead (the two
179
+ // rendered on top of each other otherwise).
180
+ placeholder: ({ editor: ed, node }) =>
181
+ ed.isEmpty && node.type.name === 'paragraph' ? `Begin writing your ${noun} — type “/” for cards, paste markdown…` : '',
182
+ }),
183
+ CharacterCount,
184
+ TableKit.configure({ table: { resizable: false } }),
185
+ TableTyping,
186
+ ImageCard,
187
+ EmbedCard,
188
+ ButtonCard,
189
+ Callout,
190
+ HtmlCard,
191
+ PostListCard,
192
+ CalendarCard,
193
+ InfoCards,
194
+ FileCard,
195
+ BookmarkCard,
196
+ GalleryCard.configure({ unsplashEnabled }),
197
+ SlashMenu.configure({
198
+ context: {
199
+ openImagePicker: (range) => {
200
+ pickerRangeRef.current = range;
201
+ setPicker('editor');
202
+ },
203
+ },
204
+ }),
205
+ ],
206
+ content: initialHTML,
207
+ editorProps: {
208
+ attributes: { class: 'pe-prose' },
209
+ handlePaste: (view, event) => {
210
+ // bare embeddable URL on an empty paragraph → embed card
211
+ const text = event.clipboardData?.getData('text/plain')?.trim() ?? '';
212
+ const { $from, empty } = view.state.selection;
213
+ if (text && empty && $from.parent.type.name === 'paragraph' && $from.parent.content.size === 0 && !text.includes(' ')) {
214
+ const info = detectEmbed(text);
215
+ if (info && info.provider !== 'iframe') {
216
+ editorRef.current
217
+ ?.chain()
218
+ .insertContent({ type: 'embedCard', attrs: { url: text, embedSrc: info.embedSrc, provider: info.provider } })
219
+ .run();
220
+ return true;
221
+ }
222
+ }
223
+ // plain-text markdown → rich content; headings clamp to the editor's
224
+ // h2/h3 schema. Rich clipboards keep ProseMirror's own HTML handling,
225
+ // EXCEPT when the plain text carries a markdown table — copied
226
+ // markdown source usually has an HTML flavor alongside it (code
227
+ // editors, chat apps, this editor's own output) that would otherwise
228
+ // keep the pipe rows literal.
229
+ const html = event.clipboardData?.getData('text/html') ?? '';
230
+ const joined = joinBrokenTableRows(text);
231
+ const hasMarkdownTable = /^\s*\|.+\|\s*\n\s*\|(\s*:?-+:?\s*\|)+\s*$/m.test(joined);
232
+ if (text && looksLikeMarkdown(text) && (!html || hasMarkdownTable)) {
233
+ const rich = renderMarkdown(joined)
234
+ .replace(/<(\/?)h1\b[^>]*>/g, '<$1h2>')
235
+ .replace(/<(\/?)h[4-6]\b[^>]*>/g, '<$1h3>');
236
+ editorRef.current?.chain().insertContent(rich).run();
237
+ return true;
238
+ }
239
+ return false;
240
+ },
241
+ },
242
+ onUpdate: () => queueSave(),
243
+ immediatelyRender: false,
244
+ });
245
+ const editorRef = useRef(editor);
246
+ editorRef.current = editor;
247
+
248
+ const save = useCallback(async (overrides: Partial<Meta> = {}) => {
249
+ const ed = editorRef.current;
250
+ if (!ed) return;
251
+ if (saveTimer.current) { clearTimeout(saveTimer.current); saveTimer.current = null; }
252
+ const m = { ...metaRef.current, ...overrides };
253
+ setSave('saving');
254
+ const bodyJson = JSON.stringify(ed.getJSON());
255
+ const bodyHtml = ed.getHTML();
256
+ if (kind === 'page') {
257
+ const r = await actions.pages.save({
258
+ id: docId,
259
+ title: m.title,
260
+ slug: m.slug,
261
+ bodyJson,
262
+ bodyHtml,
263
+ seoTitle: m.seoTitle,
264
+ seoDesc: m.seoDesc,
265
+ template: m.template,
266
+ status: m.status,
267
+ isHome: m.isHome,
268
+ locale: m.locale,
269
+ });
270
+ if (r.error) {
271
+ setSave('error');
272
+ return;
273
+ }
274
+ // server normalizes the locale (default stores as '')
275
+ const p = r.data.page;
276
+ setMetaState((prev) => {
277
+ const next = { ...prev, slug: p.slug, isHome: p.isHome, locale: p.locale };
278
+ metaRef.current = next;
279
+ return next;
280
+ });
281
+ } else {
282
+ const r = await actions.posts.save({
283
+ id: docId,
284
+ title: m.title,
285
+ slug: m.slug,
286
+ collection: m.collection,
287
+ bodyJson,
288
+ bodyHtml,
289
+ excerpt: m.excerpt,
290
+ tags: m.tags,
291
+ cover: m.cover,
292
+ coverAlt: m.coverAlt,
293
+ seoTitle: m.seoTitle,
294
+ seoDesc: m.seoDesc,
295
+ status: m.status,
296
+ featured: m.featured,
297
+ authorIds: m.authorIds,
298
+ template: m.template,
299
+ publishedAt: m.publishedAt,
300
+ scheduledAt: m.scheduledAt,
301
+ });
302
+ if (r.error) {
303
+ setSave('error');
304
+ return;
305
+ }
306
+ // server may have re-slugified / auto-set publish date
307
+ const p = r.data.post;
308
+ setMetaState((prev) => {
309
+ const next = { ...prev, slug: p.slug, publishedAt: p.publishedAt, collection: p.collection };
310
+ metaRef.current = next;
311
+ return next;
312
+ });
313
+ }
314
+ setPreviewTick((t) => t + 1);
315
+ setSave(saveStateRef.current === 'saving' ? 'saved' : saveStateRef.current);
316
+ }, [docId, kind]);
317
+
318
+ const queueSave = useCallback(() => {
319
+ setSave('dirty');
320
+ if (saveTimer.current) clearTimeout(saveTimer.current);
321
+ saveTimer.current = setTimeout(() => save(), 1200);
322
+ }, [save]);
323
+
324
+ const setMeta = useCallback((patch: Partial<Meta>) => {
325
+ setMetaState((prev) => {
326
+ const next = { ...prev, ...patch };
327
+ metaRef.current = next;
328
+ return next;
329
+ });
330
+ queueSave();
331
+ }, [queueSave]);
332
+
333
+ // Author + tag-registry options for the settings sidebar, loaded on first open.
334
+ const sidebarLoaded = useRef(false);
335
+ useEffect(() => {
336
+ if (kind !== 'post' || !sidebarOpen || sidebarLoaded.current) return;
337
+ sidebarLoaded.current = true;
338
+ actions.users.list({}).then((r) => {
339
+ if (!r.error) setAuthors(r.data.users.map((u) => ({ id: u.id, name: u.name })));
340
+ });
341
+ actions.tags.list({}).then((r) => {
342
+ if (!r.error) setTagSuggestions(r.data.tags.map((t) => t.name));
343
+ });
344
+ }, [sidebarOpen, kind]);
345
+
346
+ // opens the link editor seeded from the current selection/link
347
+ const openLinkEdit = useCallback(() => {
348
+ const ed = editorRef.current;
349
+ if (!ed) return;
350
+ const { empty } = ed.state.selection;
351
+ if (empty && !ed.isActive('link')) return; // nothing to link
352
+ setLinkDraft((ed.getAttributes('link').href as string | undefined) ?? 'https://');
353
+ setLinkEdit(true);
354
+ }, []);
355
+
356
+ // Cmd+S flush, Cmd+K link, Cmd+/ shortcut sheet + unsaved-changes guard
357
+ useEffect(() => {
358
+ const onKey = (e: KeyboardEvent) => {
359
+ if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); save(); }
360
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); openLinkEdit(); }
361
+ if ((e.metaKey || e.ctrlKey) && e.key === '/') { e.preventDefault(); setShortcutsOpen((v) => !v); }
362
+ };
363
+ const onLeave = (e: BeforeUnloadEvent) => {
364
+ if (saveStateRef.current === 'dirty' || saveStateRef.current === 'saving') e.preventDefault();
365
+ };
366
+ window.addEventListener('keydown', onKey);
367
+ window.addEventListener('beforeunload', onLeave);
368
+ return () => { window.removeEventListener('keydown', onKey); window.removeEventListener('beforeunload', onLeave); };
369
+ }, [save, openLinkEdit]);
370
+
371
+ function insertPicked(img: PickedImage) {
372
+ const ed = editorRef.current;
373
+ if (picker === 'editor' && ed) {
374
+ const chain = pickerRangeRef.current
375
+ ? ed.chain().focus().insertContentAt(pickerRangeRef.current.from, { type: 'imageCard', attrs: img })
376
+ : ed.chain().focus().insertContent({ type: 'imageCard', attrs: img });
377
+ chain.run();
378
+ queueSave();
379
+ } else if (picker === 'feature') {
380
+ setMeta({ cover: img.src, coverAlt: img.alt });
381
+ }
382
+ setPicker('closed');
383
+ }
384
+
385
+ const [toast, setToast] = useState<{ text: string; href?: string } | null>(null);
386
+ const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
387
+
388
+ function publicPath(m: Meta) {
389
+ return kind === 'post' ? collectionUrl(m.collection, m.slug) : m.isHome ? localeHomeUrl(m.locale) : `/${m.slug}`;
390
+ }
391
+
392
+ async function transition(patch: Partial<Meta>) {
393
+ setPublishOpen(false);
394
+ const next = { ...metaRef.current, ...patch };
395
+ metaRef.current = next;
396
+ setMetaState(next);
397
+ await save(patch);
398
+ if (saveStateRef.current === 'error') return;
399
+ const messages: Partial<Record<Status, { text: string; href?: string }>> = {
400
+ published: { text: `${kind === 'page' ? 'Page' : (CONTENT.collections[next.collection] ?? CONTENT.posts).singular} published`, href: publicPath(next) },
401
+ scheduled: { text: `Scheduled for ${next.scheduledAt ? new Date(next.scheduledAt).toLocaleString() : 'later'}` },
402
+ draft: { text: 'Back to private draft' },
403
+ archived: { text: 'Archived — hidden from the site' },
404
+ };
405
+ const msg = patch.status ? messages[patch.status] : undefined;
406
+ if (msg) {
407
+ setToast(msg);
408
+ if (toastTimer.current) clearTimeout(toastTimer.current);
409
+ toastTimer.current = setTimeout(() => setToast(null), 4500);
410
+ }
411
+ }
412
+
413
+ const words = editor?.storage.characterCount?.words() ?? 0;
414
+ const saveLabel =
415
+ saveState === 'saved' ? 'Saved' :
416
+ saveState === 'saving' ? 'Saving…' :
417
+ saveState === 'dirty' ? 'Unsaved changes' : 'Save failed — retrying on next change';
418
+
419
+ const publishLabel = meta.status === 'published' ? 'Update' : 'Publish';
420
+
421
+ return (
422
+ <div className={`pe-shell ${sidebarOpen ? 'has-sidebar' : ''} ${split ? 'has-split' : ''}`}>
423
+ {toast && (
424
+ <div className="pe-toast" role="status">
425
+ <span className="pe-toast-check">✓</span>
426
+ {toast.text}
427
+ {toast.href && <a href={toast.href} target="_blank" rel="noopener">View ↗</a>}
428
+ </div>
429
+ )}
430
+ <header className="pe-topbar">
431
+ <div className="pe-topbar-left">
432
+ <a className="pe-btn ghost sm" href={listHref}>{kind === 'page' ? '‹ Pages' : '‹ Posts'}</a>
433
+ <span className={`pe-savestate ${saveState}`}>
434
+ {STATUS_LABEL[meta.status]} — {saveLabel}
435
+ </span>
436
+ </div>
437
+ <div className="pe-topbar-right">
438
+ <button
439
+ type="button"
440
+ className={`pe-btn ghost sm ${split ? 'is-active' : ''}`}
441
+ title="Side-by-side preview"
442
+ onClick={toggleSplit}
443
+ >
444
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
445
+ <rect x="3" y="4" width="18" height="16" rx="2" /><path d="M12 4v16" />
446
+ </svg>
447
+ </button>
448
+ <button type="button" className="pe-btn ghost sm" onClick={() => setPreviewOpen(true)}>Preview</button>
449
+ <div className="pe-publish-wrap">
450
+ <button type="button" className="pe-btn primary sm" onClick={() => setPublishOpen((v) => !v)}>
451
+ {publishLabel} ▾
452
+ </button>
453
+ {publishOpen && (
454
+ <div className="pe-popover">
455
+ {meta.status === 'draft' && (
456
+ <>
457
+ <div className="pe-pop-head">
458
+ <b>Ready to publish?</b>
459
+ <span>This {noun} is a private draft — publishing makes it visible to everyone.</span>
460
+ </div>
461
+ <button
462
+ type="button"
463
+ className="pe-btn primary pe-pop-cta"
464
+ onClick={() => transition({ status: 'published', scheduledAt: null })}
465
+ >
466
+ Publish now
467
+ </button>
468
+ {kind === 'post' && (
469
+ <div className="pe-pop-schedule">
470
+ <span className="pe-pop-label">Or schedule for later</span>
471
+ <div className="pe-pop-schedrow">
472
+ <input
473
+ type="datetime-local"
474
+ value={scheduleDraft}
475
+ min={tsToLocal(Date.now())}
476
+ onChange={(e) => setScheduleDraft(e.target.value)}
477
+ />
478
+ <button
479
+ type="button"
480
+ className="pe-btn sm"
481
+ disabled={!scheduleDraft || (localToTs(scheduleDraft) ?? 0) <= Date.now()}
482
+ onClick={() => transition({ status: 'scheduled', scheduledAt: localToTs(scheduleDraft) })}
483
+ >
484
+ Schedule
485
+ </button>
486
+ </div>
487
+ {!scheduleDraft && <span className="pe-pop-hint">Pick a date and time first</span>}
488
+ </div>
489
+ )}
490
+ </>
491
+ )}
492
+ {meta.status === 'scheduled' && (
493
+ <>
494
+ <div className="pe-pop-head">
495
+ <b>Scheduled</b>
496
+ <span>Goes live {meta.scheduledAt ? new Date(meta.scheduledAt).toLocaleString() : ''}.</span>
497
+ </div>
498
+ <button
499
+ type="button"
500
+ className="pe-btn primary pe-pop-cta"
501
+ onClick={() => transition({ status: 'published', scheduledAt: null })}
502
+ >
503
+ Publish now instead
504
+ </button>
505
+ <button type="button" className="pe-pop-item" onClick={() => transition({ status: 'draft', scheduledAt: null })}>
506
+ <b>Unschedule</b><span>Back to a private draft</span>
507
+ </button>
508
+ </>
509
+ )}
510
+ {meta.status === 'published' && (
511
+ <>
512
+ <div className="pe-pop-head">
513
+ <b>Published</b>
514
+ <span>Live on the site — edits save and go live automatically.</span>
515
+ </div>
516
+ <a className="pe-btn pe-pop-cta" href={publicPath(meta)} target="_blank" rel="noopener">
517
+ View on site ↗
518
+ </a>
519
+ <div className="pe-pop-sep" />
520
+ <button type="button" className="pe-pop-item" onClick={() => transition({ status: 'draft' })}>
521
+ <b>Unpublish</b><span>Revert to a private draft</span>
522
+ </button>
523
+ <button type="button" className="pe-pop-item" onClick={() => transition({ status: 'archived' })}>
524
+ <b>Archive</b><span>Hide from the site, keep forever</span>
525
+ </button>
526
+ </>
527
+ )}
528
+ {meta.status === 'archived' && (
529
+ <>
530
+ <div className="pe-pop-head">
531
+ <b>Archived</b>
532
+ <span>Hidden from the site, kept forever.</span>
533
+ </div>
534
+ <button
535
+ type="button"
536
+ className="pe-btn primary pe-pop-cta"
537
+ onClick={() => transition({ status: 'published' })}
538
+ >
539
+ Publish again
540
+ </button>
541
+ <button type="button" className="pe-pop-item" onClick={() => transition({ status: 'draft' })}>
542
+ <b>Restore as draft</b><span>Back into the workflow</span>
543
+ </button>
544
+ </>
545
+ )}
546
+ </div>
547
+ )}
548
+ </div>
549
+ <button
550
+ type="button"
551
+ className={`pe-btn ghost sm pe-sidebar-toggle ${sidebarOpen ? 'is-active' : ''}`}
552
+ title={kind === 'page' ? 'Page settings' : `${col.singular} settings`}
553
+ onClick={() => setSidebarOpen((v) => !v)}
554
+ >
555
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
556
+ <rect x="3" y="4" width="18" height="16" rx="2" /><path d="M15 4v16" />
557
+ </svg>
558
+ </button>
559
+ </div>
560
+ </header>
561
+
562
+ <div className="pe-body">
563
+ <main className="pe-main">
564
+ <div className="pe-column">
565
+ {kind === 'post' && (meta.cover ? (
566
+ <figure className="pe-feature">
567
+ <img src={meta.cover} alt={meta.coverAlt} />
568
+ <div className="pe-feature-tools">
569
+ <button type="button" className="pe-btn sm" onClick={() => setPicker('feature')}>Replace</button>
570
+ <button type="button" className="pe-btn sm" onClick={() => setMeta({ cover: '', coverAlt: '' })}>Remove</button>
571
+ </div>
572
+ <figcaption className="pe-feature-label">Feature image</figcaption>
573
+ </figure>
574
+ ) : (
575
+ <button type="button" className="pe-add-feature" onClick={() => setPicker('feature')}>
576
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
577
+ <rect x="3" y="5" width="18" height="14" rx="2" />
578
+ <circle cx="8.5" cy="10" r="1.5" />
579
+ <path d="M21 15.5 16 11l-6.5 6.5M3 17l4-4 3 3" />
580
+ </svg>
581
+ <span>Add feature image</span>
582
+ <em>Shown at the top of the {noun} and in link previews</em>
583
+ </button>
584
+ ))}
585
+
586
+ <textarea
587
+ className="pe-title"
588
+ rows={1}
589
+ placeholder={kind === 'page' ? 'Page title' : `${col.singular} title`}
590
+ value={meta.title}
591
+ onChange={(e) => {
592
+ e.target.style.height = 'auto';
593
+ e.target.style.height = `${e.target.scrollHeight}px`;
594
+ setMeta({ title: e.target.value });
595
+ }}
596
+ onKeyDown={(e) => {
597
+ if (e.key === 'Enter') { e.preventDefault(); editor?.commands.focus('start'); }
598
+ }}
599
+ />
600
+
601
+ {editor && (
602
+ <BubbleMenu editor={editor} className="pe-bubble" options={{ placement: 'top' }}>
603
+ <button type="button" className={editor.isActive('bold') ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleBold().run()}><b>B</b></button>
604
+ <button type="button" className={editor.isActive('italic') ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleItalic().run()}><i>I</i></button>
605
+ <button type="button" className={editor.isActive('strike') ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleStrike().run()}><s>S</s></button>
606
+ <button type="button" className={editor.isActive('code') ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleCode().run()}>{'<>'}</button>
607
+ <span className="pe-bubble-sep" />
608
+ <button type="button" className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2</button>
609
+ <button type="button" className={editor.isActive('heading', { level: 3 }) ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>H3</button>
610
+ <button type="button" className={editor.isActive('blockquote') ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleBlockquote().run()}>❝</button>
611
+ <span className="pe-bubble-sep" />
612
+ <button
613
+ type="button"
614
+ className={editor.isActive('link') ? 'is-active' : ''}
615
+ title="Link (⌘K)"
616
+ onClick={openLinkEdit}
617
+ >
618
+ Link
619
+ </button>
620
+ </BubbleMenu>
621
+ )}
622
+
623
+ {editor && (
624
+ <BubbleMenu
625
+ editor={editor}
626
+ className="pe-bubble pe-tablebar"
627
+ options={{ placement: 'bottom' }}
628
+ shouldShow={({ editor: ed }) => ed.isActive('table')}
629
+ >
630
+ <button type="button" title="Add column" onClick={() => editor.chain().focus().addColumnAfter().run()}>+ Col</button>
631
+ <button type="button" title="Add row" onClick={() => editor.chain().focus().addRowAfter().run()}>+ Row</button>
632
+ <span className="pe-bubble-sep" />
633
+ <button type="button" title="Delete column" onClick={() => editor.chain().focus().deleteColumn().run()}>− Col</button>
634
+ <button type="button" title="Delete row" onClick={() => editor.chain().focus().deleteRow().run()}>− Row</button>
635
+ <span className="pe-bubble-sep" />
636
+ <button type="button" title="Toggle header row" onClick={() => editor.chain().focus().toggleHeaderRow().run()}>Header</button>
637
+ <button type="button" title="Delete table" onClick={() => editor.chain().focus().deleteTable().run()}>✕</button>
638
+ </BubbleMenu>
639
+ )}
640
+
641
+ {editor && (
642
+ <FloatingMenu
643
+ editor={editor}
644
+ className="pe-floating"
645
+ options={{ placement: 'left' }}
646
+ shouldShow={({ editor: ed, view, state }) => {
647
+ // empty paragraph in a non-empty doc — the empty doc shows the
648
+ // placeholder instead, so the two never overlap
649
+ const { $anchor, empty } = state.selection;
650
+ return (
651
+ view.hasFocus() && empty && !ed.isEmpty && $anchor.depth === 1 &&
652
+ $anchor.parent.type.name === 'paragraph' && $anchor.parent.content.size === 0
653
+ );
654
+ }}
655
+ >
656
+ {([
657
+ ['Image', () => { pickerRangeRef.current = null; setPicker('editor'); }],
658
+ ['Gallery', () => editor.chain().focus().insertContent({ type: 'galleryCard' }).run()],
659
+ ['Embed', () => editor.chain().focus().insertContent({ type: 'embedCard' }).run()],
660
+ ['Bookmark', () => editor.chain().focus().insertContent({ type: 'bookmarkCard' }).run()],
661
+ ['File', () => editor.chain().focus().insertContent({ type: 'fileCard' }).run()],
662
+ ['Button', () => editor.chain().focus().insertContent({ type: 'buttonCard' }).run()],
663
+ ['Callout', () => editor.chain().focus().insertContent({ type: 'callout' }).run()],
664
+ ['Calendar', () => editor.chain().focus().insertContent({ type: 'calendarCard' }).run()],
665
+ ['Info cards', () => editor.chain().focus().insertContent({ type: 'infoCards' }).run()],
666
+ ['Table', () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()],
667
+ ['HTML', () => editor.chain().focus().insertContent({ type: 'htmlCard' }).run()],
668
+ ['— Divider', () => editor.chain().focus().setHorizontalRule().run()],
669
+ ] as [string, () => void][]).map(([label, run]) => (
670
+ <button key={label} type="button" onMouseDown={(e) => { e.preventDefault(); run(); }}>{label}</button>
671
+ ))}
672
+ <span className="pe-floating-hint">or type /</span>
673
+ </FloatingMenu>
674
+ )}
675
+
676
+ {linkEdit && editor && (
677
+ <div className="pe-linkedit" role="dialog" aria-label="Edit link">
678
+ <input
679
+ autoFocus
680
+ type="url"
681
+ placeholder="https://… (empty removes the link)"
682
+ value={linkDraft}
683
+ onChange={(e) => setLinkDraft(e.target.value)}
684
+ onKeyDown={(e) => {
685
+ if (e.key === 'Enter') {
686
+ e.preventDefault();
687
+ const url = linkDraft.trim();
688
+ if (url === '' || url === 'https://') editor.chain().focus().extendMarkRange('link').unsetLink().run();
689
+ else editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
690
+ setLinkEdit(false);
691
+ }
692
+ if (e.key === 'Escape') { e.preventDefault(); setLinkEdit(false); editor.commands.focus(); }
693
+ }}
694
+ />
695
+ <button
696
+ type="button"
697
+ className="pe-btn primary sm"
698
+ onClick={() => {
699
+ const url = linkDraft.trim();
700
+ if (url === '' || url === 'https://') editor.chain().focus().extendMarkRange('link').unsetLink().run();
701
+ else editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
702
+ setLinkEdit(false);
703
+ }}
704
+ >
705
+ Apply
706
+ </button>
707
+ <button type="button" className="pe-btn ghost sm" onClick={() => { setLinkEdit(false); editor.commands.focus(); }}>✕</button>
708
+ </div>
709
+ )}
710
+
711
+ <EditorContent editor={editor} />
712
+ <div className="pe-wordcount">
713
+ {words.toLocaleString()} words
714
+ <button type="button" className="pe-shortcuts-btn" title="Keyboard shortcuts (⌘/)" onClick={() => setShortcutsOpen(true)}>⌘/</button>
715
+ </div>
716
+
717
+ {shortcutsOpen && (
718
+ <div className="pe-shortcuts-overlay" onClick={() => setShortcutsOpen(false)}>
719
+ <div className="pe-shortcuts" role="dialog" aria-label="Keyboard shortcuts" onClick={(e) => e.stopPropagation()}>
720
+ <div className="pe-shortcuts-head">
721
+ <span>Keyboard shortcuts</span>
722
+ <button type="button" className="pe-btn ghost sm" onClick={() => setShortcutsOpen(false)}>✕</button>
723
+ </div>
724
+ {([
725
+ ['Writing', [
726
+ ['⌘B / ⌘I', 'Bold / italic'],
727
+ ['⌘⇧X', 'Strikethrough'],
728
+ ['⌘E', 'Inline code'],
729
+ ['⌘K', 'Add or edit a link'],
730
+ ['/', 'Insert a card (image, gallery, embed…)'],
731
+ ]],
732
+ ['Markdown as you type', [
733
+ ['# · ## · ###', 'Headings'],
734
+ ['- or 1.', 'Bullet / numbered list'],
735
+ ['> text', 'Quote'],
736
+ ['``` ', 'Code block'],
737
+ ['| a | b |', 'Table (press Enter after the divider row)'],
738
+ ['Paste markdown', 'Converts to rich content automatically'],
739
+ ]],
740
+ ['Document', [
741
+ ['⌘S', 'Save now'],
742
+ ['⌘/', 'This cheat-sheet'],
743
+ ]],
744
+ ] as [string, [string, string][]][]).map(([group, rows]) => (
745
+ <div key={group} className="pe-shortcuts-group">
746
+ <div className="pe-shortcuts-title">{group}</div>
747
+ {rows.map(([keys, what]) => (
748
+ <div key={keys} className="pe-shortcuts-row">
749
+ <kbd>{keys}</kbd>
750
+ <span>{what}</span>
751
+ </div>
752
+ ))}
753
+ </div>
754
+ ))}
755
+ </div>
756
+ </div>
757
+ )}
758
+ </div>
759
+ </main>
760
+
761
+ {split && (
762
+ <aside className="pe-splitpane">
763
+ <div className="pe-split-bar">
764
+ <span className="pe-split-label">Live preview</span>
765
+ <div className="pe-split-actions">
766
+ <button type="button" className="pe-btn ghost sm" title="Refresh" onClick={() => setPreviewTick((t) => t + 1)}>↻</button>
767
+ <a className="pe-btn ghost sm" href={previewPath} target="_blank" rel="noopener" title="Open in tab">↗</a>
768
+ <button type="button" className="pe-btn ghost sm" title="Close preview" onClick={toggleSplit}>✕</button>
769
+ </div>
770
+ </div>
771
+ <iframe key={previewTick} src={previewPath} title="Live preview" />
772
+ </aside>
773
+ )}
774
+ </div>
775
+
776
+ {sidebarOpen && (
777
+ <aside className="pe-sidebar">
778
+ <div className="pe-sidebar-head">
779
+ <span>{kind === 'page' ? 'Page settings' : `${col.singular} settings`}</span>
780
+ <button type="button" className="pe-btn ghost sm" onClick={() => setSidebarOpen(false)}>✕</button>
781
+ </div>
782
+
783
+ <label className="pe-field">
784
+ <span>{kind === 'page' ? 'Page URL' : `${col.singular} URL`}</span>
785
+ <div className="pe-slug">
786
+ <span className="pe-slug-prefix">{kind === 'page' ? '/' : `${col.base}/`}</span>
787
+ <input type="text" value={meta.slug} onChange={(e) => setMeta({ slug: e.target.value })} />
788
+ </div>
789
+ </label>
790
+
791
+ {kind === 'post' && Object.keys(CONTENT.collections).length > 1 && (
792
+ <label className="pe-field">
793
+ <span>Collection</span>
794
+ <select value={meta.collection} onChange={(e) => setMeta({ collection: e.target.value })}>
795
+ {Object.entries(CONTENT.collections).map(([k, c]) => (
796
+ <option key={k} value={k}>{c.plural}</option>
797
+ ))}
798
+ </select>
799
+ {meta.status === 'published' && (
800
+ <em className="hint">Moving a published {noun} leaves a redirect at its old URL.</em>
801
+ )}
802
+ </label>
803
+ )}
804
+
805
+ {kind === 'page' && I18N.enabled && (
806
+ <label className="pe-field">
807
+ <span>Language</span>
808
+ <select
809
+ value={meta.locale || I18N.defaultLocale}
810
+ onChange={(e) => setMeta({ locale: e.target.value })}
811
+ >
812
+ {I18N.locales.map((l) => (
813
+ <option key={l} value={l}>{l.toUpperCase()}{l === I18N.defaultLocale ? ' — default' : ''}</option>
814
+ ))}
815
+ </select>
816
+ </label>
817
+ )}
818
+
819
+ {kind === 'page' && (
820
+ <label className="pe-field pe-check">
821
+ <input
822
+ type="checkbox"
823
+ checked={meta.isHome}
824
+ onChange={(e) => setMeta({ isHome: e.target.checked })}
825
+ />
826
+ {I18N.enabled ? (
827
+ <span>Serve this page at <code>{localeHomeUrl(meta.locale)}</code> ({(meta.locale || I18N.defaultLocale).toUpperCase()} home page)</span>
828
+ ) : (
829
+ <span>Serve this page at <code>/</code> (home page)</span>
830
+ )}
831
+ </label>
832
+ )}
833
+
834
+ {templates.length > 0 && (
835
+ <label className="pe-field">
836
+ <span>Template <em className="pe-check-hint">from the active theme</em></span>
837
+ <select value={meta.template} onChange={(e) => setMeta({ template: e.target.value })}>
838
+ <option value="">Default</option>
839
+ {templates.map((t) => (
840
+ <option key={t} value={t}>{t}</option>
841
+ ))}
842
+ </select>
843
+ </label>
844
+ )}
845
+
846
+ {kind === 'post' && (
847
+ <>
848
+ <label className="pe-field">
849
+ <span>Publish date</span>
850
+ <input
851
+ type="datetime-local"
852
+ value={tsToLocal(meta.publishedAt)}
853
+ onChange={(e) => setMeta({ publishedAt: localToTs(e.target.value) })}
854
+ />
855
+ </label>
856
+
857
+ <label className="pe-field">
858
+ <span>Tags</span>
859
+ <TagInput tags={meta.tags} suggestions={tagSuggestions} onChange={(tags) => setMeta({ tags })} />
860
+ </label>
861
+
862
+ <label className="pe-field pe-check">
863
+ <input
864
+ type="checkbox"
865
+ checked={meta.featured}
866
+ onChange={(e) => setMeta({ featured: e.target.checked })}
867
+ />
868
+ <span>Feature this post <em className="pe-check-hint">highlighted on the homepage and in featured lists</em></span>
869
+ </label>
870
+
871
+ {authors.length > 0 && (
872
+ <div className="pe-field">
873
+ <span>Authors <em className="pe-check-hint">first is the primary byline</em></span>
874
+ <div className="pe-authors">
875
+ {authors.map((a) => {
876
+ const checked = meta.authorIds.includes(a.id);
877
+ return (
878
+ <label key={a.id} className="pe-check pe-author-check">
879
+ <input
880
+ type="checkbox"
881
+ checked={checked}
882
+ onChange={(e) =>
883
+ setMeta({
884
+ authorIds: e.target.checked
885
+ ? [...meta.authorIds, a.id]
886
+ : meta.authorIds.filter((id) => id !== a.id),
887
+ })
888
+ }
889
+ />
890
+ <span>{a.name}{checked && meta.authorIds[0] === a.id && meta.authorIds.length > 1 ? ' · primary' : ''}</span>
891
+ </label>
892
+ );
893
+ })}
894
+ </div>
895
+ </div>
896
+ )}
897
+
898
+ <label className="pe-field">
899
+ <span>Excerpt <em>{meta.excerpt.length}/300</em></span>
900
+ <textarea
901
+ rows={3}
902
+ maxLength={300}
903
+ placeholder="A short teaser shown on the blog index and in link previews"
904
+ value={meta.excerpt}
905
+ onChange={(e) => setMeta({ excerpt: e.target.value })}
906
+ />
907
+ </label>
908
+
909
+ <div className="pe-field">
910
+ <span>Feature image alt</span>
911
+ <input
912
+ type="text"
913
+ placeholder="Describe the feature image"
914
+ value={meta.coverAlt}
915
+ onChange={(e) => setMeta({ coverAlt: e.target.value })}
916
+ />
917
+ </div>
918
+ </>
919
+ )}
920
+
921
+ <details className="pe-seo">
922
+ <summary>Search engine optimization</summary>
923
+ <label className="pe-field">
924
+ <span>Meta title <em>{(meta.seoTitle || meta.title).length}/60</em></span>
925
+ <input type="text" placeholder={meta.title} value={meta.seoTitle} onChange={(e) => setMeta({ seoTitle: e.target.value })} />
926
+ </label>
927
+ <label className="pe-field">
928
+ <span>Meta description <em>{(meta.seoDesc || meta.excerpt).length}/160</em></span>
929
+ <textarea rows={3} placeholder={meta.excerpt} value={meta.seoDesc} onChange={(e) => setMeta({ seoDesc: e.target.value })} />
930
+ </label>
931
+ </details>
932
+
933
+ <div className="pe-danger">
934
+ <button
935
+ type="button"
936
+ className="pe-btn danger sm"
937
+ onClick={async () => {
938
+ if (!window.confirm(`Delete this ${noun} permanently? This cannot be undone.`)) return;
939
+ if (kind === 'page') await actions.pages.delete({ id: docId });
940
+ else await actions.posts.delete({ id: docId });
941
+ location.href = listHref;
942
+ }}
943
+ >
944
+ Delete {noun}
945
+ </button>
946
+ </div>
947
+ </aside>
948
+ )}
949
+
950
+ <ImagePicker
951
+ open={picker !== 'closed'}
952
+ unsplashEnabled={unsplashEnabled}
953
+ onClose={() => setPicker('closed')}
954
+ onSelect={insertPicked}
955
+ />
956
+ <PreviewOverlay
957
+ open={previewOpen}
958
+ url={previewPath}
959
+ shareUrl={sharePath}
960
+ onClose={() => setPreviewOpen(false)}
961
+ />
962
+ </div>
963
+ );
964
+ }
965
+
966
+ // ---- tag chips ------------------------------------------------------------
967
+
968
+ function TagInput({ tags, suggestions = [], onChange }: { tags: string[]; suggestions?: string[]; onChange: (tags: string[]) => void }) {
969
+ const [draft, setDraft] = useState('');
970
+ const commit = (value?: string) => {
971
+ const t = (value ?? draft).trim().replace(/,+$/, '');
972
+ if (t && !tags.some((x) => x.toLowerCase() === t.toLowerCase())) onChange([...tags, t]);
973
+ setDraft('');
974
+ };
975
+ const matches = draft
976
+ ? suggestions.filter(
977
+ (s) => s.toLowerCase().includes(draft.toLowerCase()) && !tags.some((t) => t.toLowerCase() === s.toLowerCase())
978
+ ).slice(0, 6)
979
+ : [];
980
+ return (
981
+ <div className="pe-tags-wrap">
982
+ <div className="pe-tags">
983
+ {tags.map((t) => (
984
+ <span key={t} className="pe-tag">
985
+ {t}
986
+ <button type="button" aria-label={`Remove ${t}`} onClick={() => onChange(tags.filter((x) => x !== t))}>✕</button>
987
+ </span>
988
+ ))}
989
+ <input
990
+ type="text"
991
+ placeholder={tags.length ? '' : 'Add a tag…'}
992
+ value={draft}
993
+ onChange={(e) => setDraft(e.target.value)}
994
+ onBlur={() => setTimeout(() => commit(), 150)}
995
+ onKeyDown={(e) => {
996
+ if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); commit(matches[0] && matches[0].toLowerCase() === draft.toLowerCase() ? matches[0] : undefined); }
997
+ if (e.key === 'Backspace' && !draft && tags.length) onChange(tags.slice(0, -1));
998
+ if (e.key === 'Escape') setDraft('');
999
+ }}
1000
+ />
1001
+ </div>
1002
+ {matches.length > 0 && (
1003
+ <div className="pe-tag-suggest">
1004
+ {matches.map((s) => (
1005
+ <button key={s} type="button" onMouseDown={(e) => { e.preventDefault(); commit(s); }}>{s}</button>
1006
+ ))}
1007
+ </div>
1008
+ )}
1009
+ </div>
1010
+ );
1011
+ }