@acms-subir/core 0.0.2 → 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.
Files changed (47) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/cli.mjs +128 -16
  3. package/integration.mjs +96 -3
  4. package/package.json +14 -12
  5. package/src/actions/index.ts +184 -6
  6. package/src/components/DesignManager.tsx +53 -17
  7. package/src/components/LinksManager.tsx +110 -48
  8. package/src/components/NavManager.tsx +83 -37
  9. package/src/components/editor/PostEditor.tsx +228 -55
  10. package/src/components/editor/extensions/cards.tsx +17 -1
  11. package/src/components/editor/extensions/codeBlock.tsx +1 -1
  12. package/src/components/editor/extensions/infoCards.tsx +16 -1
  13. package/src/components/editor/extensions/mediaCards.tsx +36 -2
  14. package/src/components/editor/extensions/slash.tsx +70 -8
  15. package/src/components/editor/extensions/toc.tsx +89 -0
  16. package/src/components/editor/extensions/toggle.tsx +70 -0
  17. package/src/layouts/Site.astro +122 -1
  18. package/src/lib/analytics.ts +121 -0
  19. package/src/lib/designPreview.ts +14 -0
  20. package/src/lib/drafts.ts +77 -0
  21. package/src/lib/dynamicBlocks.ts +43 -3
  22. package/src/lib/plaintext.ts +22 -0
  23. package/src/lib/repo/migrations.ts +52 -0
  24. package/src/lib/repo/sql.ts +289 -30
  25. package/src/lib/repo/types.ts +40 -4
  26. package/src/lib/schema.ts +53 -0
  27. package/src/lib/search-config.ts +10 -0
  28. package/src/lib/search-i18n.ts +45 -0
  29. package/src/pages/[...slug].astro +3 -1
  30. package/src/pages/admin/c/[collection]/[id].astro +10 -3
  31. package/src/pages/admin/c/[collection]/index.astro +1 -0
  32. package/src/pages/admin/design.astro +6 -1
  33. package/src/pages/admin/index.astro +27 -10
  34. package/src/pages/admin/pages/[id].astro +10 -2
  35. package/src/pages/admin/pages/index.astro +1 -0
  36. package/src/pages/admin/settings.astro +148 -0
  37. package/src/pages/api/search.ts +44 -0
  38. package/src/pages/blog/[slug].astro +4 -1
  39. package/src/pages/index.astro +4 -1
  40. package/src/pages/links.astro +10 -4
  41. package/src/pages/preview/[type]/[id].astro +9 -4
  42. package/src/pages/search.astro +83 -18
  43. package/src/styles/admin.css +4 -0
  44. package/src/styles/cards.css +23 -0
  45. package/src/styles/editor.css +53 -1
  46. package/src/styles/site.css +88 -0
  47. package/src/lib/repo/search.ts +0 -14
@@ -12,7 +12,7 @@ import { isPrivateHost } from '../lib/net';
12
12
  import { normalizeCalendarSrc } from '../lib/calendar/src';
13
13
  import { getCalendarEvents } from '../lib/calendar/ics';
14
14
  import { siteLocale } from '../lib/dates';
15
- import { normalizePageLocale } from '../lib/i18n';
15
+ import { normalizePageLocale, localeHomeUrl } from '../lib/i18n';
16
16
 
17
17
  // All admin mutations live here. The middleware only guards /admin pages,
18
18
  // so every action re-checks the session itself.
@@ -71,8 +71,17 @@ export const server = {
71
71
  if (data.collection !== undefined && !CONTENT.collections[data.collection]) {
72
72
  throw new ActionError({ code: 'BAD_REQUEST', message: `Unknown collection "${data.collection}".` });
73
73
  }
74
- const movesUrl = data.slug !== undefined || data.collection !== undefined;
75
- const before = movesUrl ? await repo.getPost(id) : null;
74
+ const before = await repo.getPost(id);
75
+ if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
76
+ // A live (published/scheduled) post never takes content edits
77
+ // directly: they stage into the pending draft until the user hits
78
+ // Update. Status transitions stay deliberate, immediate writes.
79
+ const transitions = data.status !== undefined && data.status !== before.status;
80
+ if ((before.status === 'published' || before.status === 'scheduled') && !transitions) {
81
+ const { status: _status, scheduledAt: _scheduledAt, authorId: _authorId, ...staged } = data;
82
+ await repo.savePostDraft(id, staged);
83
+ return { post: (await repo.getPost(id))! };
84
+ }
76
85
  await repo.updatePost(id, data);
77
86
  // any new tag names become registry entries with slugs for archives
78
87
  if (data.tags !== undefined) await repo.ensureTags(data.tags);
@@ -80,7 +89,32 @@ export const server = {
80
89
  if (!post) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
81
90
  // a published post whose slug or collection changed leaves a 301
82
91
  // behind so old links keep working
83
- if (before && before.status === 'published' && (before.slug !== post.slug || before.collection !== post.collection)) {
92
+ if (before.status === 'published' && (before.slug !== post.slug || before.collection !== post.collection)) {
93
+ await repo
94
+ .createRedirect({
95
+ fromPath: collectionUrl(before.collection, before.slug),
96
+ toPath: collectionUrl(post.collection, post.slug),
97
+ })
98
+ .catch(() => {});
99
+ invalidateRedirectCache();
100
+ }
101
+ return { post };
102
+ },
103
+ }),
104
+ // Update button: copy the pending draft onto the live columns. The
105
+ // deferred side-effects (tag registry, slug-change 301) happen here —
106
+ // the moment the change actually goes live.
107
+ applyUpdate: defineAction({
108
+ input: z.object({ id: z.string() }),
109
+ handler: async ({ id }, ctx) => {
110
+ requireAdmin(ctx);
111
+ const repo = await getRepo(ctx.locals);
112
+ const before = await repo.getPost(id);
113
+ if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
114
+ await repo.applyPostDraft(id);
115
+ const post = (await repo.getPost(id))!;
116
+ if (post.tags.length) await repo.ensureTags(post.tags);
117
+ if (before.status === 'published' && (before.slug !== post.slug || before.collection !== post.collection)) {
84
118
  await repo
85
119
  .createRedirect({
86
120
  fromPath: collectionUrl(before.collection, before.slug),
@@ -92,6 +126,17 @@ export const server = {
92
126
  return { post };
93
127
  },
94
128
  }),
129
+ discardChanges: defineAction({
130
+ input: z.object({ id: z.string() }),
131
+ handler: async ({ id }, ctx) => {
132
+ requireAdmin(ctx);
133
+ const repo = await getRepo(ctx.locals);
134
+ await repo.discardPostDraft(id);
135
+ const post = await repo.getPost(id);
136
+ if (!post) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
137
+ return { post };
138
+ },
139
+ }),
95
140
  delete: defineAction({
96
141
  input: z.object({ id: z.string() }),
97
142
  handler: async ({ id }, ctx) => {
@@ -125,24 +170,59 @@ export const server = {
125
170
  if (data.slug !== undefined) data.slug = slugify(data.slug);
126
171
  // default locale stores as '', unknown codes fall back to it
127
172
  if (data.locale !== undefined) data.locale = normalizePageLocale(data.locale);
128
- const before = data.slug !== undefined ? await repo.getPage(id) : null;
173
+ const before = await repo.getPage(id);
174
+ if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
129
175
  const cleanBlocks =
130
176
  blocks === undefined
131
177
  ? undefined
132
178
  : blocks
133
179
  .filter((b) => widgetTypes.includes(b.type as WidgetType))
134
180
  .map((b) => ({ ...b, type: b.type as WidgetType, config: parseConfig(b.type as WidgetType, b.config) as Record<string, unknown> }));
181
+ // live pages stage their edits until Update, same as posts
182
+ const transitions = data.status !== undefined && data.status !== before.status;
183
+ if ((before.status === 'published' || before.status === 'scheduled') && !transitions) {
184
+ const { status: _status, ...staged } = data;
185
+ await repo.savePageDraft(id, { ...staged, blocks: cleanBlocks });
186
+ return { page: (await repo.getPage(id))! };
187
+ }
135
188
  await repo.updatePage(id, { ...data, blocks: cleanBlocks });
136
189
  const page = await repo.getPage(id);
137
190
  if (!page) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
138
191
  // renaming a published slug leaves a 301 behind so old links keep working
139
- if (before && before.status === 'published' && before.slug !== page.slug && !page.isHome) {
192
+ if (before.status === 'published' && before.slug !== page.slug && !page.isHome) {
140
193
  await repo.createRedirect({ fromPath: `/${before.slug}`, toPath: `/${page.slug}` }).catch(() => {});
141
194
  invalidateRedirectCache();
142
195
  }
143
196
  return { page };
144
197
  },
145
198
  }),
199
+ applyUpdate: defineAction({
200
+ input: z.object({ id: z.string() }),
201
+ handler: async ({ id }, ctx) => {
202
+ requireAdmin(ctx);
203
+ const repo = await getRepo(ctx.locals);
204
+ const before = await repo.getPage(id);
205
+ if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
206
+ await repo.applyPageDraft(id);
207
+ const page = (await repo.getPage(id))!;
208
+ if (before.status === 'published' && before.slug !== page.slug && !page.isHome) {
209
+ await repo.createRedirect({ fromPath: `/${before.slug}`, toPath: `/${page.slug}` }).catch(() => {});
210
+ invalidateRedirectCache();
211
+ }
212
+ return { page };
213
+ },
214
+ }),
215
+ discardChanges: defineAction({
216
+ input: z.object({ id: z.string() }),
217
+ handler: async ({ id }, ctx) => {
218
+ requireAdmin(ctx);
219
+ const repo = await getRepo(ctx.locals);
220
+ await repo.discardPageDraft(id);
221
+ const page = await repo.getPage(id);
222
+ if (!page) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
223
+ return { page };
224
+ },
225
+ }),
146
226
  delete: defineAction({
147
227
  input: z.object({ id: z.string() }),
148
228
  handler: async ({ id }, ctx) => {
@@ -309,6 +389,41 @@ export const server = {
309
389
  return { ok: true };
310
390
  },
311
391
  }),
392
+ // Design-screen staging: edits stage in settings.draftDesign (previewed
393
+ // via ?designPreview=1) until "Publish design" applies them live.
394
+ saveDesignDraft: defineAction({
395
+ input: z.object({
396
+ activeTheme: z.string().optional(),
397
+ designOverrides: z.record(z.any()).optional(),
398
+ siteLogo: z.string().optional(),
399
+ siteFavicon: z.string().optional(),
400
+ siteCover: z.string().optional(),
401
+ }),
402
+ handler: async (data, ctx) => {
403
+ requireAdmin(ctx);
404
+ const repo = await getRepo(ctx.locals);
405
+ await repo.saveDesignDraft(data);
406
+ return { ok: true };
407
+ },
408
+ }),
409
+ applyDesign: defineAction({
410
+ input: z.object({}),
411
+ handler: async (_input, ctx) => {
412
+ requireAdmin(ctx);
413
+ const repo = await getRepo(ctx.locals);
414
+ await repo.applyDesignDraft();
415
+ return { ok: true };
416
+ },
417
+ }),
418
+ discardDesign: defineAction({
419
+ input: z.object({}),
420
+ handler: async (_input, ctx) => {
421
+ requireAdmin(ctx);
422
+ const repo = await getRepo(ctx.locals);
423
+ await repo.discardDesignDraft();
424
+ return { ok: true };
425
+ },
426
+ }),
312
427
  },
313
428
 
314
429
  tags: {
@@ -322,6 +437,41 @@ export const server = {
322
437
  }),
323
438
  },
324
439
 
440
+ snippets: {
441
+ list: defineAction({
442
+ input: z.object({}),
443
+ handler: async (_input, ctx) => {
444
+ requireAdmin(ctx);
445
+ const repo = await getRepo(ctx.locals);
446
+ return { snippets: await repo.listSnippets() };
447
+ },
448
+ }),
449
+ create: defineAction({
450
+ input: z.object({ name: z.string().min(1).max(80), contentJson: z.string().min(2) }),
451
+ handler: async ({ name, contentJson }, ctx) => {
452
+ requireAdmin(ctx);
453
+ // must be a JSON array of ProseMirror nodes — reject anything else
454
+ // before it can break the editor on re-insert
455
+ let parsed: unknown;
456
+ try { parsed = JSON.parse(contentJson); } catch { parsed = null; }
457
+ if (!Array.isArray(parsed) || !parsed.length) {
458
+ throw new ActionError({ code: 'BAD_REQUEST', message: 'Select some content first.' });
459
+ }
460
+ const repo = await getRepo(ctx.locals);
461
+ return { snippet: await repo.createSnippet({ name, contentJson }) };
462
+ },
463
+ }),
464
+ delete: defineAction({
465
+ input: z.object({ id: z.string() }),
466
+ handler: async ({ id }, ctx) => {
467
+ requireAdmin(ctx);
468
+ const repo = await getRepo(ctx.locals);
469
+ await repo.deleteSnippet(id);
470
+ return { ok: true };
471
+ },
472
+ }),
473
+ },
474
+
325
475
  users: {
326
476
  list: defineAction({
327
477
  input: z.object({}),
@@ -333,6 +483,34 @@ export const server = {
333
483
  }),
334
484
  },
335
485
 
486
+ linkTargets: {
487
+ // ⌘K internal link search: published posts + pages matched by title or
488
+ // URL. Drafts are excluded on purpose — linking to one would 404 until
489
+ // it's published.
490
+ search: defineAction({
491
+ input: z.object({ query: z.string().min(1) }),
492
+ handler: async ({ query }, ctx) => {
493
+ requireAdmin(ctx);
494
+ const repo = await getRepo(ctx.locals);
495
+ const q = query.toLowerCase();
496
+ const [posts, pages] = await Promise.all([repo.listPosts(), repo.listPages()]);
497
+ const targets = [
498
+ ...pages
499
+ .filter((p) => p.status === 'published')
500
+ .map((p) => ({ kind: 'Page', title: p.title, url: p.isHome ? localeHomeUrl(p.locale) : `/${p.slug}` })),
501
+ ...posts
502
+ .filter((p) => p.status === 'published')
503
+ .map((p) => ({
504
+ kind: (CONTENT.collections[p.collection] ?? CONTENT.posts).singular,
505
+ title: p.title,
506
+ url: collectionUrl(p.collection, p.slug),
507
+ })),
508
+ ].filter((t) => t.title.toLowerCase().includes(q) || t.url.toLowerCase().includes(q));
509
+ return { targets: targets.slice(0, 8) };
510
+ },
511
+ }),
512
+ },
513
+
336
514
  bookmark: {
337
515
  // Server-side link-metadata fetch for the bookmark card (no CORS in the
338
516
  // browser). Fetches the page, scrapes OG/meta tags, returns a compact
@@ -7,6 +7,10 @@ import { ImagePicker } from './editor/ImagePicker';
7
7
  // token overrides on top (saved to settings.designOverrides — the theme files
8
8
  // are never written to), set the site logo, and export the effective design
9
9
  // as a committable theme.json.
10
+ //
11
+ // Every change stages into settings.draftDesign — the preview iframe renders
12
+ // the staged design (?designPreview=1, admin-only) while visitors keep the
13
+ // live one until "Publish design" applies it.
10
14
 
11
15
  type SaveState = 'saved' | 'saving' | 'error';
12
16
 
@@ -33,6 +37,8 @@ interface Pairing { key: string; label: string; headingFont: string; bodyFont: s
33
37
 
34
38
  interface Props {
35
39
  settings: Settings;
40
+ // true when settings.draftDesign held staged changes at load time
41
+ pendingDesign: boolean;
36
42
  themes: ThemeInfo[];
37
43
  pairings: Pairing[];
38
44
  unsplashEnabled: boolean;
@@ -80,7 +86,8 @@ const DEVICES = [
80
86
  ] as const;
81
87
  type DeviceKey = (typeof DEVICES)[number]['key'];
82
88
 
83
- export default function DesignManager({ settings, themes, pairings, unsplashEnabled, postPreviewPath }: Props) {
89
+ export default function DesignManager({ settings, pendingDesign, themes, pairings, unsplashEnabled, postPreviewPath }: Props) {
90
+ const [pending, setPending] = useState(pendingDesign);
84
91
  const [activeTheme, setActiveTheme] = useState(settings.activeTheme || 'ink');
85
92
  const [overrides, setOverrides] = useState<Record<string, unknown>>(
86
93
  (settings.designOverrides as Record<string, unknown>) ?? {}
@@ -113,22 +120,45 @@ export default function DesignManager({ settings, themes, pairings, unsplashEnab
113
120
 
114
121
  const theme = themes.find((t) => t.slug === activeTheme) ?? themes[0];
115
122
 
123
+ // saves stage into settings.draftDesign — the live site is untouched
124
+ const doSave = async () => {
125
+ const { activeTheme, overrides, siteLogo, siteFavicon, siteCover } = latest.current;
126
+ const { error } = await actions.settings.saveDesignDraft({
127
+ activeTheme,
128
+ designOverrides: overrides,
129
+ siteLogo,
130
+ siteFavicon,
131
+ siteCover,
132
+ });
133
+ if (error) { setSaveState('error'); return false; }
134
+ setSaveState('saved');
135
+ setPending(true);
136
+ setTick((t) => t + 1);
137
+ return true;
138
+ };
139
+
116
140
  const scheduleSave = () => {
117
141
  setSaveState('saving');
118
142
  if (timer.current) clearTimeout(timer.current);
119
- timer.current = setTimeout(async () => {
120
- const { activeTheme, overrides, siteLogo, siteFavicon, siteCover } = latest.current;
121
- const { error } = await actions.settings.update({
122
- activeTheme,
123
- designOverrides: overrides,
124
- siteLogo,
125
- siteFavicon,
126
- siteCover,
127
- });
128
- if (error) { setSaveState('error'); return; }
129
- setSaveState('saved');
130
- setTick((t) => t + 1);
131
- }, 500);
143
+ timer.current = setTimeout(doSave, 500);
144
+ };
145
+
146
+ // Publish design: flush any queued save into the staged blob, then apply it
147
+ const publishDesign = async () => {
148
+ if (timer.current) { clearTimeout(timer.current); timer.current = null; }
149
+ if (!(await doSave())) return;
150
+ const { error } = await actions.settings.applyDesign({});
151
+ if (error) { setSaveState('error'); return; }
152
+ setPending(false);
153
+ setTick((t) => t + 1);
154
+ };
155
+
156
+ const discardDesign = async () => {
157
+ if (!window.confirm('Discard the staged design changes and go back to the live design? This cannot be undone.')) return;
158
+ if (timer.current) { clearTimeout(timer.current); timer.current = null; }
159
+ const { error } = await actions.settings.discardDesign({});
160
+ if (error) { setSaveState('error'); return; }
161
+ location.reload(); // rehydrate the controls from the live design
132
162
  };
133
163
 
134
164
  const setOv = (key: string, value: unknown) => {
@@ -186,7 +216,7 @@ export default function DesignManager({ settings, themes, pairings, unsplashEnab
186
216
  <div className="dm-cardhead">
187
217
  <p className="dm-sub">
188
218
  Active theme: <b>{theme.name}</b> · loaded from <code>src/themes/{theme.slug}/</code> —
189
- values below override it. Overrides apply instantly.
219
+ values below override it. Changes stage privately in the preview; press <b>Publish design</b> to go live.
190
220
  </p>
191
221
  <div className="dm-headactions">
192
222
  {overrideCount > 0 && (
@@ -195,8 +225,14 @@ export default function DesignManager({ settings, themes, pairings, unsplashEnab
195
225
  </button>
196
226
  )}
197
227
  <span className={`lm-savestate ${saveState}`}>
198
- {saveState === 'saving' ? 'Saving…' : saveState === 'error' ? 'Save failed' : 'Saved'}
228
+ {saveState === 'saving' ? 'Saving…' : saveState === 'error' ? 'Save failed' : pending ? 'Staged — not live yet' : 'Live design'}
199
229
  </span>
230
+ {pending && (
231
+ <>
232
+ <button className="btn sm ghost" type="button" onClick={discardDesign}>Discard</button>
233
+ <button className="btn sm primary" type="button" onClick={publishDesign}>Publish design</button>
234
+ </>
235
+ )}
200
236
  </div>
201
237
  </div>
202
238
 
@@ -494,7 +530,7 @@ export default function DesignManager({ settings, themes, pairings, unsplashEnab
494
530
  <div className="dm-frame" ref={frameRef}>
495
531
  <iframe
496
532
  key={`${previewPage}-${tick}`}
497
- src={`${previewPage === 'post' && postPreviewPath ? postPreviewPath : '/'}?design=${tick}`}
533
+ src={`${previewPage === 'post' && postPreviewPath ? postPreviewPath : '/'}?designPreview=1&design=${tick}`}
498
534
  title="Site preview"
499
535
  style={{
500
536
  width: device.width,
@@ -1,4 +1,4 @@
1
- import { useRef, useState } from 'react';
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { DndContext, PointerSensor, KeyboardSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core';
3
3
  import type { DragEndEvent } from '@dnd-kit/core';
4
4
  import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
@@ -17,6 +17,10 @@ import { ImagePicker } from './editor/ImagePicker';
17
17
  // (brand-preset links, custom links, email, phone, heading, spacer, text,
18
18
  // vcard, raw HTML), drag-to-reorder, inline editing with an icon picker
19
19
  // (emoji / image / favicon), and a phone-framed live preview of /links.
20
+ //
21
+ // Edits stay local until "Save changes" commits them all at once — the live
22
+ // /links page never sees a half-arranged state. Discard restores the saved
23
+ // state.
20
24
 
21
25
  type SaveState = 'saved' | 'saving' | 'error';
22
26
  type Kind = Link['kind'];
@@ -102,14 +106,20 @@ const NEW_ITEM: Record<Kind, Partial<Link>> = {
102
106
  html: { kind: 'html', label: 'Custom block', url: '', html: '' },
103
107
  };
104
108
 
109
+ // unsaved additions carry a temp id until links.create returns the real one
110
+ const isTemp = (id: string) => id.startsWith('tmp-');
111
+
105
112
  export default function LinksManager({ links: initial, settings, unsplashEnabled }: Props) {
106
113
  const [links, setLinks] = useState<Link[]>(initial);
107
114
  // links-page identity — its own fields, seeded from site title/tagline until set
108
- const [profile, setProfile] = useState({
115
+ const initialProfile = {
109
116
  linksName: settings.linksName || settings.siteTitle,
110
117
  linksBio: settings.linksBio || settings.tagline,
111
118
  linksAvatar: settings.linksAvatar,
112
- });
119
+ };
120
+ const [profile, setProfile] = useState(initialProfile);
121
+ // last state the server has — dirty compares against this
122
+ const [saved, setSaved] = useState({ links: initial, profile: initialProfile });
113
123
  const [avatarPickerOpen, setAvatarPickerOpen] = useState(false);
114
124
  const [pickerOpen, setPickerOpen] = useState(initial.length === 0);
115
125
  const [sitesOpen, setSitesOpen] = useState(false);
@@ -118,76 +128,123 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
118
128
  const [colorPickerFor, setColorPickerFor] = useState<string | null>(null);
119
129
  const [saveState, setSaveState] = useState<SaveState>('saved');
120
130
  const [tick, setTick] = useState(0);
121
- const pending = useRef(0);
122
- const timers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
131
+
132
+ const linkShape = (l: Link) => ({ id: l.id, kind: l.kind, label: l.label, url: l.url, icon: l.icon, html: l.html, meta: l.meta });
133
+ const dirty = useMemo(
134
+ () =>
135
+ JSON.stringify({ links: links.map(linkShape), profile }) !==
136
+ JSON.stringify({ links: saved.links.map(linkShape), profile: saved.profile }),
137
+ [links, profile, saved]
138
+ );
139
+
140
+ // unsaved edits live only in this tab — warn before losing them
141
+ useEffect(() => {
142
+ if (!dirty) return;
143
+ const onLeave = (e: BeforeUnloadEvent) => e.preventDefault();
144
+ window.addEventListener('beforeunload', onLeave);
145
+ return () => window.removeEventListener('beforeunload', onLeave);
146
+ }, [dirty]);
123
147
 
124
148
  const sensors = useSensors(
125
149
  useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
126
150
  useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
127
151
  );
128
152
 
129
- // every mutation runs through this so the save chip and preview stay honest
130
- async function run(fn: () => Promise<{ error?: unknown }>) {
131
- pending.current += 1;
132
- setSaveState('saving');
133
- const r = await fn();
134
- pending.current -= 1;
135
- if (r.error) setSaveState('error');
136
- else if (pending.current === 0) {
137
- setSaveState('saved');
138
- setTick((t) => t + 1);
139
- }
140
- }
141
-
142
- function debounced(key: string, fn: () => void, ms = 600) {
143
- const prev = timers.current.get(key);
144
- if (prev) clearTimeout(prev);
145
- timers.current.set(key, setTimeout(() => { timers.current.delete(key); fn(); }, ms));
146
- }
147
-
148
- function edit(id: string, patch: Partial<Pick<Link, 'label' | 'url' | 'icon' | 'html' | 'meta'>>, instant = false) {
153
+ function edit(id: string, patch: Partial<Pick<Link, 'label' | 'url' | 'icon' | 'html' | 'meta'>>) {
149
154
  setLinks((ls) => ls.map((l) => (l.id === id ? { ...l, ...patch } : l)));
150
- if (instant) void run(() => actions.links.update({ id, ...patch }));
151
- else debounced(id, () => void run(() => actions.links.update({ id, ...patch })));
152
155
  }
153
156
 
154
157
  function editProfile(patch: Partial<typeof profile>) {
155
158
  setProfile((p) => ({ ...p, ...patch }));
156
- debounced('profile', () => void run(() => actions.settings.update(patch)));
157
159
  }
158
160
 
159
- async function add(data: Partial<Link>, openIt = true) {
161
+ function add(data: Partial<Link>, openIt = true) {
160
162
  setPickerOpen(false);
161
163
  setSitesOpen(false);
162
- await run(async () => {
163
- const r = await actions.links.create(data as Parameters<typeof actions.links.create>[0]);
164
- if (!r.error && openIt) setOpenEditor((o) => ({ ...o, [r.data.link.id]: true }));
165
- if (!r.error) setLinks((ls) => [...ls, r.data.link]);
166
- return r;
167
- });
164
+ const id = `tmp-${crypto.randomUUID()}`;
165
+ setLinks((ls) => [
166
+ ...ls,
167
+ { id, kind: 'link', label: '', url: '', icon: '', html: '', meta: {}, clicks: 0, order: ls.length, ...data } as Link,
168
+ ]);
169
+ if (openIt) setOpenEditor((o) => ({ ...o, [id]: true }));
168
170
  }
169
171
 
170
172
  function addSite(site: (typeof SITES)[number]) {
171
- void add({ kind: 'link', label: site.name, url: site.url, icon: svgOf(site.si), meta: { color: `#${site.si.hex}` } });
173
+ add({ kind: 'link', label: site.name, url: site.url, icon: svgOf(site.si), meta: { color: `#${site.si.hex}` } }, false);
172
174
  }
173
175
 
174
176
  function remove(id: string) {
175
- if (!window.confirm('Delete this block?')) return;
177
+ // local until Save — Discard brings it back
176
178
  setLinks((ls) => ls.filter((l) => l.id !== id));
177
- void run(() => actions.links.delete({ id }));
178
179
  }
179
180
 
180
181
  function onDragEnd(e: DragEndEvent) {
181
182
  const { active, over } = e;
182
183
  if (!over || active.id === over.id) return;
183
- setLinks((ls) => {
184
- const next = arrayMove(ls, ls.findIndex((l) => l.id === active.id), ls.findIndex((l) => l.id === over.id));
185
- void run(() => actions.links.reorder({ ids: next.map((l) => l.id) }));
186
- return next;
187
- });
184
+ setLinks((ls) => arrayMove(ls, ls.findIndex((l) => l.id === active.id), ls.findIndex((l) => l.id === over.id)));
185
+ }
186
+
187
+ // one commit for everything: deletes, creates (temp ids become real),
188
+ // updates, the display order, then the profile fields
189
+ async function saveAll() {
190
+ setSaveState('saving');
191
+ try {
192
+ const savedById = new Map(saved.links.map((l) => [l.id, l]));
193
+ const keptIds = new Set(links.map((l) => l.id));
194
+ for (const l of saved.links) {
195
+ if (!keptIds.has(l.id)) {
196
+ const r = await actions.links.delete({ id: l.id });
197
+ if (r.error) throw r.error;
198
+ }
199
+ }
200
+ const next: Link[] = [];
201
+ for (const l of links) {
202
+ if (isTemp(l.id)) {
203
+ const r = await actions.links.create({ kind: l.kind, label: l.label, url: l.url, icon: l.icon, html: l.html, meta: l.meta });
204
+ if (r.error) throw r.error;
205
+ next.push({ ...l, id: r.data.link.id, order: r.data.link.order });
206
+ } else {
207
+ const prev = savedById.get(l.id);
208
+ if (prev && JSON.stringify(linkShape(prev)) !== JSON.stringify(linkShape(l))) {
209
+ const r = await actions.links.update({ id: l.id, label: l.label, url: l.url, icon: l.icon, html: l.html, meta: l.meta });
210
+ if (r.error) throw r.error;
211
+ }
212
+ next.push(l);
213
+ }
214
+ }
215
+ if (next.length) {
216
+ const r = await actions.links.reorder({ ids: next.map((l) => l.id) });
217
+ if (r.error) throw r.error;
218
+ }
219
+ if (JSON.stringify(profile) !== JSON.stringify(saved.profile)) {
220
+ const r = await actions.settings.update(profile);
221
+ if (r.error) throw r.error;
222
+ }
223
+ // reopen editors under the real ids so panes don't collapse mid-edit
224
+ setOpenEditor((o) => {
225
+ const remap: Record<string, boolean> = {};
226
+ links.forEach((l, i) => { if (o[l.id]) remap[next[i]?.id ?? l.id] = true; });
227
+ return remap;
228
+ });
229
+ setLinks(next);
230
+ setSaved({ links: next, profile });
231
+ setSaveState('saved');
232
+ setTick((t) => t + 1);
233
+ } catch {
234
+ setSaveState('error');
235
+ }
188
236
  }
189
237
 
190
- const saveLabel = saveState === 'saved' ? 'Saved' : saveState === 'saving' ? 'Saving…' : 'Save failed — edit to retry';
238
+ function discard() {
239
+ setLinks(saved.links);
240
+ setProfile(saved.profile);
241
+ setSaveState('saved');
242
+ }
243
+
244
+ const saveLabel =
245
+ saveState === 'saving' ? 'Saving…' :
246
+ saveState === 'error' ? 'Save failed' :
247
+ dirty ? 'Unsaved changes — not live yet' : 'Live';
191
248
 
192
249
  return (
193
250
  <div className="lm-layout">
@@ -212,7 +269,7 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
212
269
  type="button"
213
270
  className="lm-avatar-remove"
214
271
  title="Remove photo"
215
- onClick={() => { setProfile((p) => ({ ...p, linksAvatar: '' })); void run(() => actions.settings.update({ linksAvatar: '' })); }}
272
+ onClick={() => setProfile((p) => ({ ...p, linksAvatar: '' }))}
216
273
  >
217
274
  ✕
218
275
  </button>
@@ -261,7 +318,7 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
261
318
  key={t.key}
262
319
  type="button"
263
320
  className="lm-tile"
264
- onClick={() => (t.kind === 'site' ? setSitesOpen(true) : void add(NEW_ITEM[t.kind]))}
321
+ onClick={() => (t.kind === 'site' ? setSitesOpen(true) : add(NEW_ITEM[t.kind]))}
265
322
  >
266
323
  <span className="lm-tile-ico" dangerouslySetInnerHTML={{ __html: TILE_ICONS[t.key] }} />
267
324
  <span className="lm-tile-body">
@@ -315,15 +372,20 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
315
372
  onSelect={(img) => {
316
373
  setAvatarPickerOpen(false);
317
374
  setProfile((p) => ({ ...p, linksAvatar: img.src }));
318
- void run(() => actions.settings.update({ linksAvatar: img.src }));
319
375
  }}
320
376
  />
321
377
 
322
378
  <aside className="lm-preview">
323
379
  <div className="lm-preview-bar">
324
380
  <span className="lm-preview-label">Live preview</span>
325
- <span className={`lm-savestate ${saveState}`}>{saveLabel}</span>
381
+ <span className={`lm-savestate ${saveState === 'saved' && dirty ? 'dirty' : saveState}`}>{saveLabel}</span>
326
382
  <div style={{ display: 'flex', gap: 2 }}>
383
+ {dirty && (
384
+ <>
385
+ <button type="button" className="btn sm ghost" onClick={discard} disabled={saveState === 'saving'}>Discard</button>
386
+ <button type="button" className="btn sm primary" onClick={() => void saveAll()} disabled={saveState === 'saving'}>Save changes</button>
387
+ </>
388
+ )}
327
389
  <button type="button" className="btn sm ghost" title="Refresh" onClick={() => setTick((t) => t + 1)}>↻</button>
328
390
  <a className="btn sm ghost" href="/links" target="_blank" rel="noopener" title="Open in tab">↗</a>
329
391
  </div>