@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
@@ -1,4 +1,4 @@
1
- import { useRef, useState } from 'react';
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
- const pending = useRef(0);
68
- const timers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
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
- async function add(area: NavArea) {
101
- await run(async () => {
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
- if (!window.confirm('Delete this navigation item?')) return;
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
- const moved = arrayMove(group, from, to);
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' : 'Saved'}
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={() => void add(a.key)}>+ Add item</button>
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)}>