@acms-subir/core 0.1.0 → 0.2.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/CHANGELOG.md +28 -0
- package/cli.mjs +63 -9
- package/integration.mjs +11 -0
- package/package.json +10 -9
- package/src/actions/index.ts +184 -6
- package/src/components/DesignManager.tsx +53 -17
- package/src/components/LinksManager.tsx +110 -48
- package/src/components/NavManager.tsx +83 -37
- package/src/components/editor/PostEditor.tsx +228 -55
- package/src/components/editor/extensions/cards.tsx +17 -1
- package/src/components/editor/extensions/codeBlock.tsx +1 -1
- package/src/components/editor/extensions/infoCards.tsx +16 -1
- package/src/components/editor/extensions/mediaCards.tsx +36 -2
- package/src/components/editor/extensions/slash.tsx +70 -8
- package/src/components/editor/extensions/toc.tsx +89 -0
- package/src/components/editor/extensions/toggle.tsx +70 -0
- package/src/layouts/Site.astro +10 -1
- package/src/lib/analytics.ts +121 -0
- package/src/lib/designPreview.ts +14 -0
- package/src/lib/drafts.ts +77 -0
- package/src/lib/dynamicBlocks.ts +43 -3
- package/src/lib/repo/migrations.ts +37 -0
- package/src/lib/repo/sql.ts +127 -10
- package/src/lib/repo/types.ts +21 -1
- package/src/lib/schema.ts +53 -0
- package/src/pages/[...slug].astro +3 -1
- package/src/pages/admin/c/[collection]/[id].astro +10 -3
- package/src/pages/admin/c/[collection]/index.astro +1 -0
- package/src/pages/admin/design.astro +6 -1
- package/src/pages/admin/index.astro +5 -2
- package/src/pages/admin/pages/[id].astro +10 -2
- package/src/pages/admin/pages/index.astro +1 -0
- package/src/pages/admin/settings.astro +148 -0
- package/src/pages/blog/[slug].astro +4 -1
- package/src/pages/index.astro +4 -1
- package/src/pages/links.astro +10 -4
- package/src/pages/preview/[type]/[id].astro +9 -4
- package/src/styles/admin.css +4 -0
- package/src/styles/cards.css +23 -0
- package/src/styles/editor.css +53 -1
- package/src/styles/site.css +3 -0
|
@@ -3,6 +3,7 @@ import { useEditor, EditorContent } from '@tiptap/react';
|
|
|
3
3
|
import { BubbleMenu, FloatingMenu } from '@tiptap/react/menus';
|
|
4
4
|
import type { Range } from '@tiptap/react';
|
|
5
5
|
import StarterKit from '@tiptap/starter-kit';
|
|
6
|
+
import Highlight from '@tiptap/extension-highlight';
|
|
6
7
|
import { TableKit } from '@tiptap/extension-table';
|
|
7
8
|
import { Placeholder, CharacterCount } from '@tiptap/extensions';
|
|
8
9
|
import { actions } from 'astro:actions';
|
|
@@ -11,20 +12,26 @@ import { ImageCard, EmbedCard, ButtonCard, Callout, HtmlCard, detectEmbed } from
|
|
|
11
12
|
import { FileCard, GalleryCard, BookmarkCard } from './extensions/mediaCards';
|
|
12
13
|
import { CodeBlock } from './extensions/codeBlock';
|
|
13
14
|
import { PostListCard } from './extensions/postList';
|
|
15
|
+
import { ToggleCard } from './extensions/toggle';
|
|
16
|
+
import { TocCard } from './extensions/toc';
|
|
14
17
|
import { CalendarCard } from './extensions/calendar';
|
|
15
18
|
import { InfoCards } from './extensions/infoCards';
|
|
16
|
-
import { SlashMenu } from './extensions/slash';
|
|
19
|
+
import { SlashMenu, type SlashSnippet } from './extensions/slash';
|
|
17
20
|
import { TableTyping } from './extensions/tableTyping';
|
|
18
21
|
import { ImagePicker, type PickedImage } from './ImagePicker';
|
|
19
22
|
import { PreviewOverlay } from './PreviewOverlay';
|
|
20
23
|
import { renderMarkdown } from '../../lib/markdown';
|
|
21
24
|
import { CONTENT, collectionUrl } from '../../lib/content';
|
|
22
25
|
import { I18N, localeHomeUrl } from '../../lib/i18n';
|
|
26
|
+
import { mergePageDraft, mergePostDraft } from '../../lib/drafts';
|
|
23
27
|
|
|
24
28
|
// The Ghost-style writing surface, shared by posts AND pages: distraction-free
|
|
25
29
|
// column, cards via "/", autosave, a collapsible settings sidebar, and an
|
|
26
30
|
// optional side-by-side live preview. `kind` switches the save action and
|
|
27
31
|
// which settings fields exist — everything else is identical.
|
|
32
|
+
//
|
|
33
|
+
// Live (published/scheduled) documents autosave into a private pending draft;
|
|
34
|
+
// the live version only changes when the user hits Update (applyUpdate).
|
|
28
35
|
|
|
29
36
|
type SaveState = 'saved' | 'dirty' | 'saving' | 'error';
|
|
30
37
|
|
|
@@ -36,6 +43,9 @@ interface Props {
|
|
|
36
43
|
unsplashEnabled: boolean;
|
|
37
44
|
previewPath: string;
|
|
38
45
|
sharePath: string;
|
|
46
|
+
// public URL of the LIVE version — may differ from the staged slug while
|
|
47
|
+
// edits are pending; falls back to the editor state's URL when omitted
|
|
48
|
+
livePath?: string;
|
|
39
49
|
// theme-provided template names for this document kind; empty = no picker
|
|
40
50
|
templates?: string[];
|
|
41
51
|
}
|
|
@@ -112,9 +122,12 @@ function joinBrokenTableRows(text: string): string {
|
|
|
112
122
|
return out.join('\n');
|
|
113
123
|
}
|
|
114
124
|
|
|
115
|
-
export default function PostEditor({ kind = 'post', post, page, initialHTML, unsplashEnabled, previewPath, sharePath, templates = [] }: Props) {
|
|
125
|
+
export default function PostEditor({ kind = 'post', post, page, initialHTML, unsplashEnabled, previewPath, sharePath, livePath: livePathProp, templates = [] }: Props) {
|
|
116
126
|
const doc = (kind === 'page' ? page : post)!;
|
|
117
127
|
const docId = doc.id;
|
|
128
|
+
// pending = staged edits exist that the live site doesn't show yet
|
|
129
|
+
const [pending, setPending] = useState(() => Boolean(doc.draft));
|
|
130
|
+
const [livePath, setLivePath] = useState(livePathProp ?? '');
|
|
118
131
|
|
|
119
132
|
const [meta, setMetaState] = useState<Meta>(() =>
|
|
120
133
|
kind === 'page'
|
|
@@ -154,8 +167,13 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
|
|
|
154
167
|
const [scheduleDraft, setScheduleDraft] = useState('');
|
|
155
168
|
const [linkEdit, setLinkEdit] = useState(false);
|
|
156
169
|
const [linkDraft, setLinkDraft] = useState('');
|
|
170
|
+
const [linkResults, setLinkResults] = useState<{ kind: string; title: string; url: string }[]>([]);
|
|
171
|
+
const [linkSel, setLinkSel] = useState(-1);
|
|
157
172
|
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
|
158
173
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
174
|
+
// saved snippets for the "/" menu; a ref so the (non-React) suggestion
|
|
175
|
+
// plugin always reads the current list
|
|
176
|
+
const snippetsRef = useRef<SlashSnippet[]>([]);
|
|
159
177
|
|
|
160
178
|
const setSave = (s: SaveState) => { saveStateRef.current = s; setSaveState(s); };
|
|
161
179
|
const toggleSplit = () => {
|
|
@@ -168,11 +186,12 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
|
|
|
168
186
|
const editor = useEditor({
|
|
169
187
|
extensions: [
|
|
170
188
|
StarterKit.configure({
|
|
171
|
-
heading: { levels: [2, 3] },
|
|
189
|
+
heading: { levels: [1, 2, 3] },
|
|
172
190
|
link: { openOnClick: false },
|
|
173
191
|
codeBlock: false,
|
|
174
192
|
}),
|
|
175
193
|
CodeBlock,
|
|
194
|
+
Highlight,
|
|
176
195
|
Placeholder.configure({
|
|
177
196
|
// Only the empty document gets the invitation; empty paragraphs in an
|
|
178
197
|
// existing doc get the floating quick-insert menu instead (the two
|
|
@@ -189,6 +208,8 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
|
|
|
189
208
|
Callout,
|
|
190
209
|
HtmlCard,
|
|
191
210
|
PostListCard,
|
|
211
|
+
ToggleCard,
|
|
212
|
+
TocCard,
|
|
192
213
|
CalendarCard,
|
|
193
214
|
InfoCards,
|
|
194
215
|
FileCard,
|
|
@@ -200,6 +221,11 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
|
|
|
200
221
|
pickerRangeRef.current = range;
|
|
201
222
|
setPicker('editor');
|
|
202
223
|
},
|
|
224
|
+
getSnippets: () => snippetsRef.current,
|
|
225
|
+
deleteSnippet: (id) => {
|
|
226
|
+
snippetsRef.current = snippetsRef.current.filter((s) => s.id !== id);
|
|
227
|
+
actions.snippets.delete({ id });
|
|
228
|
+
},
|
|
203
229
|
},
|
|
204
230
|
}),
|
|
205
231
|
],
|
|
@@ -220,8 +246,8 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
|
|
|
220
246
|
return true;
|
|
221
247
|
}
|
|
222
248
|
}
|
|
223
|
-
// plain-text markdown → rich content;
|
|
224
|
-
//
|
|
249
|
+
// plain-text markdown → rich content; h4–h6 clamp to h3, the editor's
|
|
250
|
+
// deepest level. Rich clipboards keep ProseMirror's own HTML handling,
|
|
225
251
|
// EXCEPT when the plain text carries a markdown table — copied
|
|
226
252
|
// markdown source usually has an HTML flavor alongside it (code
|
|
227
253
|
// editors, chat apps, this editor's own output) that would otherwise
|
|
@@ -231,7 +257,6 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
|
|
|
231
257
|
const hasMarkdownTable = /^\s*\|.+\|\s*\n\s*\|(\s*:?-+:?\s*\|)+\s*$/m.test(joined);
|
|
232
258
|
if (text && looksLikeMarkdown(text) && (!html || hasMarkdownTable)) {
|
|
233
259
|
const rich = renderMarkdown(joined)
|
|
234
|
-
.replace(/<(\/?)h1\b[^>]*>/g, '<$1h2>')
|
|
235
260
|
.replace(/<(\/?)h[4-6]\b[^>]*>/g, '<$1h3>');
|
|
236
261
|
editorRef.current?.chain().insertContent(rich).run();
|
|
237
262
|
return true;
|
|
@@ -271,8 +296,10 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
|
|
|
271
296
|
setSave('error');
|
|
272
297
|
return;
|
|
273
298
|
}
|
|
274
|
-
// server normalizes the locale (default stores as '')
|
|
275
|
-
|
|
299
|
+
// server normalizes the locale (default stores as ''); a staged save
|
|
300
|
+
// returns the live row + pending draft, so sync from the merged view
|
|
301
|
+
const p = mergePageDraft(r.data.page);
|
|
302
|
+
setPending(Boolean(r.data.page.draft));
|
|
276
303
|
setMetaState((prev) => {
|
|
277
304
|
const next = { ...prev, slug: p.slug, isHome: p.isHome, locale: p.locale };
|
|
278
305
|
metaRef.current = next;
|
|
@@ -303,8 +330,10 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
|
|
|
303
330
|
setSave('error');
|
|
304
331
|
return;
|
|
305
332
|
}
|
|
306
|
-
// server may have re-slugified / auto-set publish date
|
|
307
|
-
|
|
333
|
+
// server may have re-slugified / auto-set publish date; a staged save
|
|
334
|
+
// returns the live row + pending draft, so sync from the merged view
|
|
335
|
+
const p = mergePostDraft(r.data.post);
|
|
336
|
+
setPending(Boolean(r.data.post.draft));
|
|
308
337
|
setMetaState((prev) => {
|
|
309
338
|
const next = { ...prev, slug: p.slug, publishedAt: p.publishedAt, collection: p.collection };
|
|
310
339
|
metaRef.current = next;
|
|
@@ -330,6 +359,26 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
|
|
|
330
359
|
queueSave();
|
|
331
360
|
}, [queueSave]);
|
|
332
361
|
|
|
362
|
+
// Saved snippets for the "/" menu, loaded once per mount.
|
|
363
|
+
useEffect(() => {
|
|
364
|
+
actions.snippets.list({}).then((r) => {
|
|
365
|
+
if (!r.error) snippetsRef.current = r.data.snippets;
|
|
366
|
+
});
|
|
367
|
+
}, []);
|
|
368
|
+
|
|
369
|
+
const saveSnippet = useCallback(async () => {
|
|
370
|
+
const ed = editorRef.current;
|
|
371
|
+
if (!ed || ed.state.selection.empty) return;
|
|
372
|
+
const name = window.prompt('Snippet name — it will appear in the “/” menu:');
|
|
373
|
+
if (!name?.trim()) return;
|
|
374
|
+
const nodes = ed.state.selection.content().content.toJSON() as unknown[] | null;
|
|
375
|
+
if (!nodes?.length) return;
|
|
376
|
+
const r = await actions.snippets.create({ name: name.trim(), contentJson: JSON.stringify(nodes) });
|
|
377
|
+
if (r.error) return;
|
|
378
|
+
snippetsRef.current = [...snippetsRef.current, r.data.snippet].sort((a, b) => a.name.localeCompare(b.name));
|
|
379
|
+
showToast({ text: `Snippet “${r.data.snippet.name}” saved — insert it with /` });
|
|
380
|
+
}, []);
|
|
381
|
+
|
|
333
382
|
// Author + tag-registry options for the settings sidebar, loaded on first open.
|
|
334
383
|
const sidebarLoaded = useRef(false);
|
|
335
384
|
useEffect(() => {
|
|
@@ -353,6 +402,29 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
|
|
|
353
402
|
setLinkEdit(true);
|
|
354
403
|
}, []);
|
|
355
404
|
|
|
405
|
+
const applyLink = useCallback((raw: string) => {
|
|
406
|
+
const ed = editorRef.current;
|
|
407
|
+
if (!ed) return;
|
|
408
|
+
const url = raw.trim();
|
|
409
|
+
if (url === '' || url === 'https://') ed.chain().focus().extendMarkRange('link').unsetLink().run();
|
|
410
|
+
else ed.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
|
411
|
+
setLinkEdit(false);
|
|
412
|
+
}, []);
|
|
413
|
+
|
|
414
|
+
// Internal link search: typing a title into the ⌘K box surfaces published
|
|
415
|
+
// posts/pages to link to; URL-ish input (or the seeded https://) skips it.
|
|
416
|
+
useEffect(() => {
|
|
417
|
+
if (!linkEdit) { setLinkResults([]); setLinkSel(-1); return; }
|
|
418
|
+
const q = linkDraft.trim();
|
|
419
|
+
if (q.length < 2 || /^(https?:\/\/|\/|#|mailto:|tel:)/i.test(q)) { setLinkResults([]); setLinkSel(-1); return; }
|
|
420
|
+
const t = setTimeout(() => {
|
|
421
|
+
actions.linkTargets.search({ query: q }).then((r) => {
|
|
422
|
+
if (!r.error) { setLinkResults(r.data.targets); setLinkSel(r.data.targets.length ? 0 : -1); }
|
|
423
|
+
});
|
|
424
|
+
}, 200);
|
|
425
|
+
return () => clearTimeout(t);
|
|
426
|
+
}, [linkDraft, linkEdit]);
|
|
427
|
+
|
|
356
428
|
// Cmd+S flush, Cmd+K link, Cmd+/ shortcut sheet + unsaved-changes guard
|
|
357
429
|
useEffect(() => {
|
|
358
430
|
const onKey = (e: KeyboardEvent) => {
|
|
@@ -389,13 +461,22 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
|
|
|
389
461
|
return kind === 'post' ? collectionUrl(m.collection, m.slug) : m.isHome ? localeHomeUrl(m.locale) : `/${m.slug}`;
|
|
390
462
|
}
|
|
391
463
|
|
|
464
|
+
function showToast(msg: { text: string; href?: string }) {
|
|
465
|
+
setToast(msg);
|
|
466
|
+
if (toastTimer.current) clearTimeout(toastTimer.current);
|
|
467
|
+
toastTimer.current = setTimeout(() => setToast(null), 4500);
|
|
468
|
+
}
|
|
469
|
+
|
|
392
470
|
async function transition(patch: Partial<Meta>) {
|
|
393
471
|
setPublishOpen(false);
|
|
394
472
|
const next = { ...metaRef.current, ...patch };
|
|
395
473
|
metaRef.current = next;
|
|
396
474
|
setMetaState(next);
|
|
475
|
+
// a status change writes the full editor state live (any staged edits are
|
|
476
|
+
// folded in — the payload IS the staged content) and clears the draft
|
|
397
477
|
await save(patch);
|
|
398
478
|
if (saveStateRef.current === 'error') return;
|
|
479
|
+
if (patch.status === 'published') setLivePath(publicPath(next));
|
|
399
480
|
const messages: Partial<Record<Status, { text: string; href?: string }>> = {
|
|
400
481
|
published: { text: `${kind === 'page' ? 'Page' : (CONTENT.collections[next.collection] ?? CONTENT.posts).singular} published`, href: publicPath(next) },
|
|
401
482
|
scheduled: { text: `Scheduled for ${next.scheduledAt ? new Date(next.scheduledAt).toLocaleString() : 'later'}` },
|
|
@@ -403,20 +484,54 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
|
|
|
403
484
|
archived: { text: 'Archived — hidden from the site' },
|
|
404
485
|
};
|
|
405
486
|
const msg = patch.status ? messages[patch.status] : undefined;
|
|
406
|
-
if (msg)
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
487
|
+
if (msg) showToast(msg);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Update button: flush the current edits into the pending draft, then ask
|
|
491
|
+
// the server to copy the draft onto the live version.
|
|
492
|
+
async function applyUpdate() {
|
|
493
|
+
setPublishOpen(false);
|
|
494
|
+
await save();
|
|
495
|
+
if (saveStateRef.current === 'error') return;
|
|
496
|
+
const r = kind === 'page'
|
|
497
|
+
? await actions.pages.applyUpdate({ id: docId })
|
|
498
|
+
: await actions.posts.applyUpdate({ id: docId });
|
|
499
|
+
if (r.error) {
|
|
500
|
+
setSave('error');
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
setPending(false);
|
|
504
|
+
const href = publicPath(metaRef.current);
|
|
505
|
+
setLivePath(href);
|
|
506
|
+
setPreviewTick((t) => t + 1);
|
|
507
|
+
showToast({ text: 'Changes are live', href });
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function discardChanges() {
|
|
511
|
+
if (!window.confirm(`Discard the pending changes and go back to the live ${noun}? This cannot be undone.`)) return;
|
|
512
|
+
setPublishOpen(false);
|
|
513
|
+
// cancel any queued autosave so it can't re-stage the edits after the
|
|
514
|
+
// discard, and clear the dirty flag so the reload isn't blocked
|
|
515
|
+
if (saveTimer.current) { clearTimeout(saveTimer.current); saveTimer.current = null; }
|
|
516
|
+
setSave('saved');
|
|
517
|
+
const r = kind === 'page'
|
|
518
|
+
? await actions.pages.discardChanges({ id: docId })
|
|
519
|
+
: await actions.posts.discardChanges({ id: docId });
|
|
520
|
+
if (r.error) {
|
|
521
|
+
setSave('error');
|
|
522
|
+
return;
|
|
410
523
|
}
|
|
524
|
+
location.reload(); // rehydrate the editor from the live version
|
|
411
525
|
}
|
|
412
526
|
|
|
413
527
|
const words = editor?.storage.characterCount?.words() ?? 0;
|
|
414
528
|
const saveLabel =
|
|
415
|
-
saveState === 'saved' ? 'Saved' :
|
|
529
|
+
saveState === 'saved' ? (pending ? 'Saved — not live yet' : 'Saved') :
|
|
416
530
|
saveState === 'saving' ? 'Saving…' :
|
|
417
531
|
saveState === 'dirty' ? 'Unsaved changes' : 'Save failed — retrying on next change';
|
|
418
532
|
|
|
419
533
|
const publishLabel = meta.status === 'published' ? 'Update' : 'Publish';
|
|
534
|
+
const liveHref = livePath || publicPath(meta);
|
|
420
535
|
|
|
421
536
|
return (
|
|
422
537
|
<div className={`pe-shell ${sidebarOpen ? 'has-sidebar' : ''} ${split ? 'has-split' : ''}`}>
|
|
@@ -431,7 +546,7 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
|
|
|
431
546
|
<div className="pe-topbar-left">
|
|
432
547
|
<a className="pe-btn ghost sm" href={listHref}>{kind === 'page' ? '‹ Pages' : '‹ Posts'}</a>
|
|
433
548
|
<span className={`pe-savestate ${saveState}`}>
|
|
434
|
-
{STATUS_LABEL[meta.status]} — {saveLabel}
|
|
549
|
+
{STATUS_LABEL[meta.status]}{pending && <em className="pe-edited-dot"> · edited</em>} — {saveLabel}
|
|
435
550
|
</span>
|
|
436
551
|
</div>
|
|
437
552
|
<div className="pe-topbar-right">
|
|
@@ -492,16 +607,29 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
|
|
|
492
607
|
{meta.status === 'scheduled' && (
|
|
493
608
|
<>
|
|
494
609
|
<div className="pe-pop-head">
|
|
495
|
-
<b>Scheduled</b>
|
|
496
|
-
<span>
|
|
610
|
+
<b>Scheduled{pending ? ' · unpublished edits' : ''}</b>
|
|
611
|
+
<span>
|
|
612
|
+
Goes live {meta.scheduledAt ? new Date(meta.scheduledAt).toLocaleString() : ''}.
|
|
613
|
+
{pending && ' Edits are saved privately — press Update to include them.'}
|
|
614
|
+
</span>
|
|
497
615
|
</div>
|
|
616
|
+
{pending && (
|
|
617
|
+
<button type="button" className="pe-btn primary pe-pop-cta" onClick={applyUpdate}>
|
|
618
|
+
Update scheduled version
|
|
619
|
+
</button>
|
|
620
|
+
)}
|
|
498
621
|
<button
|
|
499
622
|
type="button"
|
|
500
|
-
className=
|
|
623
|
+
className={`pe-btn ${pending ? '' : 'primary '}pe-pop-cta`}
|
|
501
624
|
onClick={() => transition({ status: 'published', scheduledAt: null })}
|
|
502
625
|
>
|
|
503
626
|
Publish now instead
|
|
504
627
|
</button>
|
|
628
|
+
{pending && (
|
|
629
|
+
<button type="button" className="pe-pop-item" onClick={discardChanges}>
|
|
630
|
+
<b>Discard pending changes</b><span>Back to the scheduled version</span>
|
|
631
|
+
</button>
|
|
632
|
+
)}
|
|
505
633
|
<button type="button" className="pe-pop-item" onClick={() => transition({ status: 'draft', scheduledAt: null })}>
|
|
506
634
|
<b>Unschedule</b><span>Back to a private draft</span>
|
|
507
635
|
</button>
|
|
@@ -510,12 +638,26 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
|
|
|
510
638
|
{meta.status === 'published' && (
|
|
511
639
|
<>
|
|
512
640
|
<div className="pe-pop-head">
|
|
513
|
-
<b>Published</b>
|
|
514
|
-
<span>
|
|
641
|
+
<b>{pending ? 'Published · unpublished edits' : 'Published'}</b>
|
|
642
|
+
<span>
|
|
643
|
+
{pending
|
|
644
|
+
? `Changes are saved privately — the live ${noun} hasn't changed yet.`
|
|
645
|
+
: 'Live on the site — edits stay private until you press Update.'}
|
|
646
|
+
</span>
|
|
515
647
|
</div>
|
|
516
|
-
|
|
517
|
-
|
|
648
|
+
{pending && (
|
|
649
|
+
<button type="button" className="pe-btn primary pe-pop-cta" onClick={applyUpdate}>
|
|
650
|
+
Update — push changes live
|
|
651
|
+
</button>
|
|
652
|
+
)}
|
|
653
|
+
<a className="pe-btn pe-pop-cta" href={liveHref} target="_blank" rel="noopener">
|
|
654
|
+
{pending ? 'View live version ↗' : 'View on site ↗'}
|
|
518
655
|
</a>
|
|
656
|
+
{pending && (
|
|
657
|
+
<button type="button" className="pe-pop-item" onClick={discardChanges}>
|
|
658
|
+
<b>Discard pending changes</b><span>Back to the live version</span>
|
|
659
|
+
</button>
|
|
660
|
+
)}
|
|
519
661
|
<div className="pe-pop-sep" />
|
|
520
662
|
<button type="button" className="pe-pop-item" onClick={() => transition({ status: 'draft' })}>
|
|
521
663
|
<b>Unpublish</b><span>Revert to a private draft</span>
|
|
@@ -604,10 +746,22 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
|
|
|
604
746
|
<button type="button" className={editor.isActive('italic') ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleItalic().run()}><i>I</i></button>
|
|
605
747
|
<button type="button" className={editor.isActive('strike') ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleStrike().run()}><s>S</s></button>
|
|
606
748
|
<button type="button" className={editor.isActive('code') ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleCode().run()}>{'<>'}</button>
|
|
749
|
+
<button type="button" className={editor.isActive('highlight') ? 'is-active' : ''} title="Highlight (==text==)" onClick={() => editor.chain().focus().toggleHighlight().run()}><mark>H</mark></button>
|
|
607
750
|
<span className="pe-bubble-sep" />
|
|
751
|
+
<button type="button" className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}>H1</button>
|
|
608
752
|
<button type="button" className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2</button>
|
|
609
753
|
<button type="button" className={editor.isActive('heading', { level: 3 }) ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>H3</button>
|
|
610
754
|
<button type="button" className={editor.isActive('blockquote') ? 'is-active' : ''} onClick={() => editor.chain().focus().toggleBlockquote().run()}>❝</button>
|
|
755
|
+
<button
|
|
756
|
+
type="button"
|
|
757
|
+
title="Regular text"
|
|
758
|
+
onClick={() => {
|
|
759
|
+
const c = editor.chain().focus().setParagraph();
|
|
760
|
+
(editor.isActive('blockquote') ? c.toggleBlockquote() : c).run();
|
|
761
|
+
}}
|
|
762
|
+
>
|
|
763
|
+
¶
|
|
764
|
+
</button>
|
|
611
765
|
<span className="pe-bubble-sep" />
|
|
612
766
|
<button
|
|
613
767
|
type="button"
|
|
@@ -617,6 +771,10 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
|
|
|
617
771
|
>
|
|
618
772
|
Link
|
|
619
773
|
</button>
|
|
774
|
+
<span className="pe-bubble-sep" />
|
|
775
|
+
<button type="button" title="Save selection as a snippet — reusable from the “/” menu" onClick={saveSnippet}>
|
|
776
|
+
+ Snippet
|
|
777
|
+
</button>
|
|
620
778
|
</BubbleMenu>
|
|
621
779
|
)}
|
|
622
780
|
|
|
@@ -661,6 +819,7 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
|
|
|
661
819
|
['File', () => editor.chain().focus().insertContent({ type: 'fileCard' }).run()],
|
|
662
820
|
['Button', () => editor.chain().focus().insertContent({ type: 'buttonCard' }).run()],
|
|
663
821
|
['Callout', () => editor.chain().focus().insertContent({ type: 'callout' }).run()],
|
|
822
|
+
['Toggle', () => editor.chain().focus().insertContent({ type: 'toggleCard', content: [{ type: 'paragraph' }] }).run()],
|
|
664
823
|
['Calendar', () => editor.chain().focus().insertContent({ type: 'calendarCard' }).run()],
|
|
665
824
|
['Info cards', () => editor.chain().focus().insertContent({ type: 'infoCards' }).run()],
|
|
666
825
|
['Table', () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()],
|
|
@@ -675,42 +834,55 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
|
|
|
675
834
|
|
|
676
835
|
{linkEdit && editor && (
|
|
677
836
|
<div className="pe-linkedit" role="dialog" aria-label="Edit link">
|
|
678
|
-
<
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
e.preventDefault();
|
|
687
|
-
|
|
688
|
-
if (
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
>
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
837
|
+
<div className="pe-linkedit-row">
|
|
838
|
+
<input
|
|
839
|
+
autoFocus
|
|
840
|
+
type="text"
|
|
841
|
+
placeholder="Paste a URL, or type to search your content… (empty removes the link)"
|
|
842
|
+
value={linkDraft}
|
|
843
|
+
onChange={(e) => setLinkDraft(e.target.value)}
|
|
844
|
+
onKeyDown={(e) => {
|
|
845
|
+
if (e.key === 'ArrowDown' && linkResults.length) { e.preventDefault(); setLinkSel((s) => (s + 1) % linkResults.length); }
|
|
846
|
+
if (e.key === 'ArrowUp' && linkResults.length) { e.preventDefault(); setLinkSel((s) => (s - 1 + linkResults.length) % linkResults.length); }
|
|
847
|
+
if (e.key === 'Enter') {
|
|
848
|
+
e.preventDefault();
|
|
849
|
+
applyLink(linkSel >= 0 && linkResults[linkSel] ? linkResults[linkSel].url : linkDraft);
|
|
850
|
+
}
|
|
851
|
+
if (e.key === 'Escape') { e.preventDefault(); setLinkEdit(false); editor.commands.focus(); }
|
|
852
|
+
}}
|
|
853
|
+
/>
|
|
854
|
+
<button
|
|
855
|
+
type="button"
|
|
856
|
+
className="pe-btn primary sm"
|
|
857
|
+
onClick={() => applyLink(linkSel >= 0 && linkResults[linkSel] ? linkResults[linkSel].url : linkDraft)}
|
|
858
|
+
>
|
|
859
|
+
Apply
|
|
860
|
+
</button>
|
|
861
|
+
<button type="button" className="pe-btn ghost sm" onClick={() => { setLinkEdit(false); editor.commands.focus(); }}>✕</button>
|
|
862
|
+
</div>
|
|
863
|
+
{linkResults.length > 0 && (
|
|
864
|
+
<div className="pe-link-results">
|
|
865
|
+
{linkResults.map((t, i) => (
|
|
866
|
+
<button
|
|
867
|
+
key={t.url}
|
|
868
|
+
type="button"
|
|
869
|
+
className={i === linkSel ? 'is-selected' : ''}
|
|
870
|
+
onMouseEnter={() => setLinkSel(i)}
|
|
871
|
+
onMouseDown={(e) => { e.preventDefault(); applyLink(t.url); }}
|
|
872
|
+
>
|
|
873
|
+
<span className="pe-link-title">{t.title}</span>
|
|
874
|
+
<span className="pe-link-url">{t.url}</span>
|
|
875
|
+
<span className="pe-link-kind">{t.kind}</span>
|
|
876
|
+
</button>
|
|
877
|
+
))}
|
|
878
|
+
</div>
|
|
879
|
+
)}
|
|
708
880
|
</div>
|
|
709
881
|
)}
|
|
710
882
|
|
|
711
883
|
<EditorContent editor={editor} />
|
|
712
884
|
<div className="pe-wordcount">
|
|
713
|
-
{words.toLocaleString()} words
|
|
885
|
+
{words.toLocaleString()} words{words > 0 && ` · ${Math.max(1, Math.round(words / 220))} min read`}
|
|
714
886
|
<button type="button" className="pe-shortcuts-btn" title="Keyboard shortcuts (⌘/)" onClick={() => setShortcutsOpen(true)}>⌘/</button>
|
|
715
887
|
</div>
|
|
716
888
|
|
|
@@ -726,7 +898,8 @@ export default function PostEditor({ kind = 'post', post, page, initialHTML, uns
|
|
|
726
898
|
['⌘B / ⌘I', 'Bold / italic'],
|
|
727
899
|
['⌘⇧X', 'Strikethrough'],
|
|
728
900
|
['⌘E', 'Inline code'],
|
|
729
|
-
['
|
|
901
|
+
['==text==', 'Highlight'],
|
|
902
|
+
['⌘K', 'Link — paste a URL or search your own content'],
|
|
730
903
|
['/', 'Insert a card (image, gallery, embed…)'],
|
|
731
904
|
]],
|
|
732
905
|
['Markdown as you type', [
|
|
@@ -2,6 +2,7 @@ import { Node } from '@tiptap/react';
|
|
|
2
2
|
import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react';
|
|
3
3
|
import type { NodeViewProps } from '@tiptap/react';
|
|
4
4
|
import { useState } from 'react';
|
|
5
|
+
import { cleanEventName } from '../../../lib/analytics';
|
|
5
6
|
|
|
6
7
|
// Card nodes shared by the post editor. renderHTML emits the public markup
|
|
7
8
|
// (`.pc-*` classes, styled in src/styles/cards.css); parseHTML round-trips it
|
|
@@ -292,6 +293,8 @@ export const ButtonCard = Node.create({
|
|
|
292
293
|
label: { default: '' },
|
|
293
294
|
href: { default: '' },
|
|
294
295
|
align: { default: 'center' },
|
|
296
|
+
// optional analytics event name → data-umami-event on the public <a>
|
|
297
|
+
event: { default: '' },
|
|
295
298
|
};
|
|
296
299
|
},
|
|
297
300
|
|
|
@@ -306,6 +309,7 @@ export const ButtonCard = Node.create({
|
|
|
306
309
|
label: link?.textContent ?? '',
|
|
307
310
|
href: link?.getAttribute('href') ?? '',
|
|
308
311
|
align: e.getAttribute('data-align') ?? 'center',
|
|
312
|
+
event: link?.getAttribute('data-umami-event') ?? '',
|
|
309
313
|
};
|
|
310
314
|
},
|
|
311
315
|
},
|
|
@@ -314,10 +318,15 @@ export const ButtonCard = Node.create({
|
|
|
314
318
|
|
|
315
319
|
renderHTML({ node }) {
|
|
316
320
|
const a = node.attrs;
|
|
321
|
+
const event = cleanEventName(a.event ?? '');
|
|
317
322
|
return [
|
|
318
323
|
'div',
|
|
319
324
|
{ class: 'pc-buttoncard', 'data-align': a.align, style: `text-align:${a.align}` },
|
|
320
|
-
[
|
|
325
|
+
[
|
|
326
|
+
'a',
|
|
327
|
+
{ class: 'pc-btncard', href: a.href, ...(event ? { 'data-umami-event': event } : {}) },
|
|
328
|
+
a.label || 'Button',
|
|
329
|
+
],
|
|
321
330
|
];
|
|
322
331
|
},
|
|
323
332
|
|
|
@@ -353,6 +362,13 @@ function ButtonCardView({ node, updateAttributes, deleteNode, selected }: NodeVi
|
|
|
353
362
|
value={a.href}
|
|
354
363
|
onChange={(e) => updateAttributes({ href: e.target.value })}
|
|
355
364
|
/>
|
|
365
|
+
<input
|
|
366
|
+
type="text"
|
|
367
|
+
placeholder="Analytics event (optional)"
|
|
368
|
+
title="Clicks are counted under this name in Umami (and forwarded to enabled ad pixels)"
|
|
369
|
+
value={a.event}
|
|
370
|
+
onChange={(e) => updateAttributes({ event: e.target.value })}
|
|
371
|
+
/>
|
|
356
372
|
</div>
|
|
357
373
|
<div className="pc-buttoncard" style={{ textAlign: a.align as 'center' | 'left' }}>
|
|
358
374
|
<span className="pc-btncard">{a.label || 'Button'}</span>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Node } from '@tiptap/core';
|
|
2
2
|
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
|
|
3
3
|
import type { NodeViewProps } from '@tiptap/react';
|
|
4
|
+
import { cleanEventName } from '../../../lib/analytics';
|
|
4
5
|
|
|
5
6
|
// Info cards: a *static* grid of title / badge / description / detail-line
|
|
6
7
|
// cards — services, opening hours, team members, feature grids. Unlike the
|
|
@@ -25,6 +26,7 @@ export interface InfoItem {
|
|
|
25
26
|
badgeStyle: 'filled' | 'outline';
|
|
26
27
|
description: string;
|
|
27
28
|
href: string; // empty = plain card; set = whole card is a link
|
|
29
|
+
event: string; // optional analytics event name (linked cards only)
|
|
28
30
|
lines: InfoLine[];
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -67,6 +69,7 @@ const sanitizeItem = (raw: unknown): InfoItem | null => {
|
|
|
67
69
|
badgeStyle: it.badgeStyle === 'outline' ? 'outline' : 'filled',
|
|
68
70
|
description: str(it.description),
|
|
69
71
|
href: str(it.href),
|
|
72
|
+
event: str(it.event),
|
|
70
73
|
lines,
|
|
71
74
|
};
|
|
72
75
|
};
|
|
@@ -90,6 +93,7 @@ const newItem = (): InfoItem => ({
|
|
|
90
93
|
badgeStyle: 'filled',
|
|
91
94
|
description: '',
|
|
92
95
|
href: '',
|
|
96
|
+
event: '',
|
|
93
97
|
lines: [
|
|
94
98
|
{ icon: 'calendar', text: '' },
|
|
95
99
|
{ icon: 'clock', text: '' },
|
|
@@ -140,8 +144,9 @@ export const InfoCards = Node.create({
|
|
|
140
144
|
]);
|
|
141
145
|
}
|
|
142
146
|
const href = it.href.trim();
|
|
147
|
+
const event = cleanEventName(it.event ?? '');
|
|
143
148
|
return href
|
|
144
|
-
? ['a', { class: 'pc-info-card', href }, ...children]
|
|
149
|
+
? ['a', { class: 'pc-info-card', href, ...(event ? { 'data-umami-event': event } : {}) }, ...children]
|
|
145
150
|
: ['article', { class: 'pc-info-card' }, ...children];
|
|
146
151
|
});
|
|
147
152
|
return ['div', { class: 'pc-infocards', 'data-items': JSON.stringify(items) }, ...cards];
|
|
@@ -256,6 +261,16 @@ function InfoCardsView({ node, updateAttributes, deleteNode, selected }: NodeVie
|
|
|
256
261
|
value={it.href}
|
|
257
262
|
onChange={(e) => patch(i, { href: e.target.value })}
|
|
258
263
|
/>
|
|
264
|
+
{it.href.trim() !== '' && (
|
|
265
|
+
<input
|
|
266
|
+
type="text"
|
|
267
|
+
className="pe-infocard-href"
|
|
268
|
+
placeholder="Analytics event (optional)"
|
|
269
|
+
title="Clicks are counted under this name in Umami (and forwarded to enabled ad pixels)"
|
|
270
|
+
value={it.event}
|
|
271
|
+
onChange={(e) => patch(i, { event: e.target.value })}
|
|
272
|
+
/>
|
|
273
|
+
)}
|
|
259
274
|
</div>
|
|
260
275
|
</div>
|
|
261
276
|
))}
|