@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
|
@@ -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)}>
|