@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/cli.mjs +63 -9
  3. package/integration.mjs +11 -0
  4. package/package.json +10 -9
  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 +10 -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/repo/migrations.ts +37 -0
  23. package/src/lib/repo/sql.ts +127 -10
  24. package/src/lib/repo/types.ts +21 -1
  25. package/src/lib/schema.ts +53 -0
  26. package/src/pages/[...slug].astro +3 -1
  27. package/src/pages/admin/c/[collection]/[id].astro +10 -3
  28. package/src/pages/admin/c/[collection]/index.astro +1 -0
  29. package/src/pages/admin/design.astro +6 -1
  30. package/src/pages/admin/index.astro +5 -2
  31. package/src/pages/admin/pages/[id].astro +10 -2
  32. package/src/pages/admin/pages/index.astro +1 -0
  33. package/src/pages/admin/settings.astro +148 -0
  34. package/src/pages/blog/[slug].astro +4 -1
  35. package/src/pages/index.astro +4 -1
  36. package/src/pages/links.astro +10 -4
  37. package/src/pages/preview/[type]/[id].astro +9 -4
  38. package/src/styles/admin.css +4 -0
  39. package/src/styles/cards.css +23 -0
  40. package/src/styles/editor.css +53 -1
  41. 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 [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>
@@ -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)}>