@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
|
@@ -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>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect, useMemo, 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';
|
|
@@ -10,6 +10,9 @@ import type { NavItem, NavArea } from '../lib/schema';
|
|
|
10
10
|
// auto-list of published pages (which stays as a fallback while the header
|
|
11
11
|
// area is empty); footer renders above the footer text; social feeds the
|
|
12
12
|
// footer social row and share UIs.
|
|
13
|
+
//
|
|
14
|
+
// Edits stay local until "Save changes" commits them all at once — the live
|
|
15
|
+
// nav never sees a half-rearranged state. Discard restores the saved state.
|
|
13
16
|
|
|
14
17
|
type SaveState = 'saved' | 'saving' | 'error';
|
|
15
18
|
|
|
@@ -19,6 +22,9 @@ const AREAS: { key: NavArea; title: string; hint: string }[] = [
|
|
|
19
22
|
{ key: 'social', title: 'Social', hint: 'Footer social row — also used for share links on posts.' },
|
|
20
23
|
];
|
|
21
24
|
|
|
25
|
+
// unsaved additions carry a temp id until nav.create returns the real one
|
|
26
|
+
const isTemp = (id: string) => id.startsWith('tmp-');
|
|
27
|
+
|
|
22
28
|
function Row({
|
|
23
29
|
item, onEdit, onArea, onDelete,
|
|
24
30
|
}: {
|
|
@@ -63,52 +69,45 @@ function Row({
|
|
|
63
69
|
|
|
64
70
|
export default function NavManager({ items: initial }: { items: NavItem[] }) {
|
|
65
71
|
const [items, setItems] = useState<NavItem[]>(initial);
|
|
72
|
+
// last state the server has — dirty compares against this
|
|
73
|
+
const [saved, setSaved] = useState<NavItem[]>(initial);
|
|
66
74
|
const [saveState, setSaveState] = useState<SaveState>('saved');
|
|
67
|
-
|
|
68
|
-
const
|
|
75
|
+
|
|
76
|
+
const dirty = useMemo(
|
|
77
|
+
() =>
|
|
78
|
+
JSON.stringify(items.map(({ id, label, url, area }) => ({ id, label, url, area }))) !==
|
|
79
|
+
JSON.stringify(saved.map(({ id, label, url, area }) => ({ id, label, url, area }))),
|
|
80
|
+
[items, saved]
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// unsaved edits live only in this tab — warn before losing them
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (!dirty) return;
|
|
86
|
+
const onLeave = (e: BeforeUnloadEvent) => e.preventDefault();
|
|
87
|
+
window.addEventListener('beforeunload', onLeave);
|
|
88
|
+
return () => window.removeEventListener('beforeunload', onLeave);
|
|
89
|
+
}, [dirty]);
|
|
69
90
|
|
|
70
91
|
const sensors = useSensors(
|
|
71
92
|
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
|
72
93
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
|
73
94
|
);
|
|
74
95
|
|
|
75
|
-
async function run(fn: () => Promise<{ error?: unknown }>) {
|
|
76
|
-
pending.current += 1;
|
|
77
|
-
setSaveState('saving');
|
|
78
|
-
const r = await fn();
|
|
79
|
-
pending.current -= 1;
|
|
80
|
-
if (r.error) setSaveState('error');
|
|
81
|
-
else if (pending.current === 0) setSaveState('saved');
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function debounced(key: string, fn: () => void, ms = 600) {
|
|
85
|
-
const prev = timers.current.get(key);
|
|
86
|
-
if (prev) clearTimeout(prev);
|
|
87
|
-
timers.current.set(key, setTimeout(() => { timers.current.delete(key); fn(); }, ms));
|
|
88
|
-
}
|
|
89
|
-
|
|
90
96
|
function edit(id: string, patch: Partial<Pick<NavItem, 'label' | 'url'>>) {
|
|
91
97
|
setItems((ls) => ls.map((n) => (n.id === id ? { ...n, ...patch } : n)));
|
|
92
|
-
debounced(id, () => void run(() => actions.nav.update({ id, ...patch })));
|
|
93
98
|
}
|
|
94
99
|
|
|
95
100
|
function setArea(id: string, area: NavArea) {
|
|
96
101
|
setItems((ls) => ls.map((n) => (n.id === id ? { ...n, area } : n)));
|
|
97
|
-
void run(() => actions.nav.update({ id, area }));
|
|
98
102
|
}
|
|
99
103
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const r = await actions.nav.create({ label: 'New link', url: '/', area });
|
|
103
|
-
if (!r.error) setItems((ls) => [...ls, r.data.item]);
|
|
104
|
-
return r;
|
|
105
|
-
});
|
|
104
|
+
function add(area: NavArea) {
|
|
105
|
+
setItems((ls) => [...ls, { id: `tmp-${crypto.randomUUID()}`, label: 'New link', url: '/', area, order: ls.length }]);
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
function remove(id: string) {
|
|
109
|
-
|
|
109
|
+
// local until Save — Discard brings it back
|
|
110
110
|
setItems((ls) => ls.filter((n) => n.id !== id));
|
|
111
|
-
void run(() => actions.nav.delete({ id }));
|
|
112
111
|
}
|
|
113
112
|
|
|
114
113
|
function onDragEnd(area: NavArea, e: DragEndEvent) {
|
|
@@ -119,21 +118,68 @@ export default function NavManager({ items: initial }: { items: NavItem[] }) {
|
|
|
119
118
|
const rest = ls.filter((n) => n.area !== area);
|
|
120
119
|
const from = group.findIndex((n) => n.id === active.id);
|
|
121
120
|
const to = group.findIndex((n) => n.id === over.id);
|
|
122
|
-
|
|
123
|
-
const next = [...rest, ...moved];
|
|
124
|
-
// persist the full display order: header, footer, social, each in order
|
|
125
|
-
const ordered = AREAS.flatMap((a) => next.filter((n) => n.area === a.key)).map((n) => n.id);
|
|
126
|
-
void run(() => actions.nav.reorder({ ids: ordered }));
|
|
127
|
-
return next;
|
|
121
|
+
return [...rest, ...arrayMove(group, from, to)];
|
|
128
122
|
});
|
|
129
123
|
}
|
|
130
124
|
|
|
125
|
+
// one commit for everything: deletes, creates (temp ids become real),
|
|
126
|
+
// updates, then the full display order
|
|
127
|
+
async function saveAll() {
|
|
128
|
+
setSaveState('saving');
|
|
129
|
+
try {
|
|
130
|
+
const savedById = new Map(saved.map((n) => [n.id, n]));
|
|
131
|
+
const keptIds = new Set(items.map((n) => n.id));
|
|
132
|
+
for (const n of saved) {
|
|
133
|
+
if (!keptIds.has(n.id)) {
|
|
134
|
+
const r = await actions.nav.delete({ id: n.id });
|
|
135
|
+
if (r.error) throw r.error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const next: NavItem[] = [];
|
|
139
|
+
for (const n of items) {
|
|
140
|
+
if (isTemp(n.id)) {
|
|
141
|
+
const r = await actions.nav.create({ label: n.label, url: n.url, area: n.area });
|
|
142
|
+
if (r.error) throw r.error;
|
|
143
|
+
next.push({ ...n, id: r.data.item.id });
|
|
144
|
+
} else {
|
|
145
|
+
const prev = savedById.get(n.id);
|
|
146
|
+
if (prev && (prev.label !== n.label || prev.url !== n.url || prev.area !== n.area)) {
|
|
147
|
+
const r = await actions.nav.update({ id: n.id, label: n.label, url: n.url, area: n.area });
|
|
148
|
+
if (r.error) throw r.error;
|
|
149
|
+
}
|
|
150
|
+
next.push(n);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const ordered = AREAS.flatMap((a) => next.filter((n) => n.area === a.key)).map((n) => n.id);
|
|
154
|
+
if (ordered.length) {
|
|
155
|
+
const r = await actions.nav.reorder({ ids: ordered });
|
|
156
|
+
if (r.error) throw r.error;
|
|
157
|
+
}
|
|
158
|
+
setItems(next);
|
|
159
|
+
setSaved(next);
|
|
160
|
+
setSaveState('saved');
|
|
161
|
+
} catch {
|
|
162
|
+
setSaveState('error');
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function discard() {
|
|
167
|
+
setItems(saved);
|
|
168
|
+
setSaveState('saved');
|
|
169
|
+
}
|
|
170
|
+
|
|
131
171
|
return (
|
|
132
172
|
<div className="nm">
|
|
133
173
|
<div className="nm-head">
|
|
134
|
-
<span className={`lm-savestate ${saveState}`}>
|
|
135
|
-
{saveState === 'saving' ? 'Saving…' : saveState === 'error' ? 'Save failed' : '
|
|
174
|
+
<span className={`lm-savestate ${saveState === 'saved' && dirty ? 'dirty' : saveState}`}>
|
|
175
|
+
{saveState === 'saving' ? 'Saving…' : saveState === 'error' ? 'Save failed' : dirty ? 'Unsaved changes — not live yet' : 'Live'}
|
|
136
176
|
</span>
|
|
177
|
+
{dirty && (
|
|
178
|
+
<>
|
|
179
|
+
<button className="btn sm ghost" type="button" onClick={discard} disabled={saveState === 'saving'}>Discard</button>
|
|
180
|
+
<button className="btn sm primary" type="button" onClick={() => void saveAll()} disabled={saveState === 'saving'}>Save changes</button>
|
|
181
|
+
</>
|
|
182
|
+
)}
|
|
137
183
|
</div>
|
|
138
184
|
{AREAS.map((a) => {
|
|
139
185
|
const group = items.filter((n) => n.area === a.key);
|
|
@@ -144,7 +190,7 @@ export default function NavManager({ items: initial }: { items: NavItem[] }) {
|
|
|
144
190
|
<p className="card-title">{a.title}</p>
|
|
145
191
|
<p className="hint">{a.hint}</p>
|
|
146
192
|
</div>
|
|
147
|
-
<button className="btn sm" type="button" onClick={() =>
|
|
193
|
+
<button className="btn sm" type="button" onClick={() => add(a.key)}>+ Add item</button>
|
|
148
194
|
</div>
|
|
149
195
|
{group.length === 0 && <p className="nm-empty">No items yet.</p>}
|
|
150
196
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={(e) => onDragEnd(a.key, e)}>
|