@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.
- package/CHANGELOG.md +75 -0
- package/cli.mjs +128 -16
- package/integration.mjs +96 -3
- package/package.json +14 -12
- 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 +122 -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/plaintext.ts +22 -0
- package/src/lib/repo/migrations.ts +52 -0
- package/src/lib/repo/sql.ts +289 -30
- package/src/lib/repo/types.ts +40 -4
- package/src/lib/schema.ts +53 -0
- package/src/lib/search-config.ts +10 -0
- package/src/lib/search-i18n.ts +45 -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 +27 -10
- 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/api/search.ts +44 -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/pages/search.astro +83 -18
- 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 +88 -0
- package/src/lib/repo/search.ts +0 -14
package/src/actions/index.ts
CHANGED
|
@@ -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
|
|
75
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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.
|
|
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' : '
|
|
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
|
|
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
|
-
|
|
122
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
161
|
+
function add(data: Partial<Link>, openIt = true) {
|
|
160
162
|
setPickerOpen(false);
|
|
161
163
|
setSitesOpen(false);
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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={() =>
|
|
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) :
|
|
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>
|