caracms 0.3.4 → 0.5.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 (66) hide show
  1. package/README.md +73 -3
  2. package/bin/templates/catch-all.astro.txt +10 -9
  3. package/package.json +1 -1
  4. package/skills/caracms-adopt/SKILL.md +21 -4
  5. package/skills/caracms-blocks/SKILL.md +36 -13
  6. package/skills/caracms-content/SKILL.md +27 -6
  7. package/skills/caracms-tina/SKILL.md +53 -11
  8. package/skills/caracms-tina/field-kinds.md +2 -0
  9. package/skills/caracms-tina/tina-to-caracms.mjs +71 -3
  10. package/src/cli/validate.ts +103 -12
  11. package/src/code-routes.ts +26 -0
  12. package/src/components/Head.astro +5 -5
  13. package/src/components/RenderDocument.astro +3 -3
  14. package/src/components/sitemap.xml.ts +5 -4
  15. package/src/config.ts +57 -11
  16. package/src/content-schema.ts +6 -2
  17. package/src/content.ts +188 -27
  18. package/src/document.ts +13 -2
  19. package/src/editor/README.md +3 -0
  20. package/src/editor/addresses.ts +6 -1
  21. package/src/editor/client/arrange/ArrangeStage.tsx +629 -0
  22. package/src/editor/client/arrange/model.ts +659 -0
  23. package/src/editor/client/arrange/store.ts +335 -0
  24. package/src/editor/client/collections/CollectionTable.tsx +9 -1
  25. package/src/editor/client/documents/actions.ts +75 -19
  26. package/src/editor/client/documents/dialogs.tsx +137 -18
  27. package/src/editor/client/editor.ts +78 -6
  28. package/src/editor/client/fields/HiddenToggle.tsx +18 -2
  29. package/src/editor/client/nav/DocRow.tsx +8 -1
  30. package/src/editor/client/nav/tree.ts +26 -1
  31. package/src/editor/client/pane/Pane.tsx +3 -0
  32. package/src/editor/client/pane/levels/ArrangeLevel.tsx +342 -0
  33. package/src/editor/client/pane/levels/DocumentsLevel.tsx +68 -13
  34. package/src/editor/client/pane/parts.tsx +14 -1
  35. package/src/editor/client/shell/Stage.tsx +8 -4
  36. package/src/editor/routes/admin.astro +1 -1
  37. package/src/editor/routes/arrange.ts +17 -0
  38. package/src/editor/routes/move.ts +1 -1
  39. package/src/editor/server/admin-props.ts +24 -4
  40. package/src/editor/server/arrange.ts +245 -0
  41. package/src/editor/server/create.ts +13 -2
  42. package/src/editor/server/drafts-store.ts +55 -10
  43. package/src/editor/server/move.ts +300 -81
  44. package/src/editor/styles/editor.css +5 -0
  45. package/src/editor/styles/levels/arrange.css +152 -0
  46. package/src/editor/styles/levels/collections.css +2 -0
  47. package/src/editor/styles/levels/m5a-documents.css +15 -0
  48. package/src/editor/types.ts +83 -7
  49. package/src/held.ts +116 -0
  50. package/src/index.ts +6 -2
  51. package/src/live/README.md +3 -2
  52. package/src/live/auth/middleware.ts +11 -0
  53. package/src/live/autosaves.ts +88 -1
  54. package/src/live/cloud.ts +131 -0
  55. package/src/live/drafts-store.ts +170 -77
  56. package/src/live/editor.ts +26 -4
  57. package/src/live/git.ts +63 -12
  58. package/src/live/github.ts +69 -7
  59. package/src/live/mode.ts +42 -6
  60. package/src/live/runtime.ts +18 -2
  61. package/src/page-order.ts +253 -0
  62. package/src/redirects.ts +64 -1
  63. package/src/relink.ts +40 -9
  64. package/src/reserved.ts +3 -2
  65. package/src/serialize.ts +18 -1
  66. package/src/slug.ts +15 -4
@@ -0,0 +1,629 @@
1
+ /**
2
+ * Arrange pages (notes/2026-10-25-arrange-pages.md; the mockup's notes/mockups/arrange-pages.html):
3
+ * the screen in the preview's place (Decision 16). Every page and group at every depth, Home
4
+ * first and fixed, held collections as rows under their pages that nothing drags or drops on,
5
+ * hidden pages marked; a row's grip, its name at its depth, its address, its end (Rename…,
6
+ * Remove, the twisty).
7
+ *
8
+ * Mouse only (Decision 4): press a row and move 4px to drag it. Over a row's top or bottom
9
+ * quarter it goes beside that row (a line at that level's indent, tagged with the level), over
10
+ * its middle inside it (a full ring and "Inside …"); a drop that can't land says why in the
11
+ * row (model.ts → checkDrop). A card follows the pointer with where it would go. Esc or
12
+ * leaving the window drops nothing. While reviewing, the tree is read-only.
13
+ */
14
+ import { useEffect, useLayoutEffect, useRef, type CSSProperties, type KeyboardEvent, type ReactElement } from 'react';
15
+ import { createPortal } from 'react-dom';
16
+ import { useEditor, useStore } from '../context.tsx';
17
+ import { Icon } from '../shell/Icons.tsx';
18
+ import { addressAt, checkDrop, countGroups, countPages, indexTree, pageless, locate, nameCheck, plural, type ArrangeNode, type DropTarget, type NameCheck, type Placed, type Plan } from './model.ts';
19
+ import { andList } from '../documents/actions.ts';
20
+ import { arrangeFor, type Arrange, type ArrangeState } from './store.ts';
21
+
22
+ const depthStyle = (d: number) => ({ ['--depth' as string]: d }) as CSSProperties;
23
+
24
+ export function ArrangeStage() {
25
+ const editor = useEditor();
26
+ const arrange = arrangeFor(editor);
27
+ const s = useStore(arrange.state);
28
+ const plan = arrange.plan();
29
+ const arranging = s.mode === 'arrange';
30
+ const tree = useRef<HTMLDivElement>(null);
31
+ const scroll = useRef<HTMLDivElement>(null);
32
+ useDrag(arrange, tree);
33
+ useMarker(arrange, s, tree);
34
+ // New group scrolls to its row at the end and puts the caret in it.
35
+ const naming = !!s.naming;
36
+ useEffect(() => {
37
+ if (!naming) return;
38
+ scroll.current?.scrollTo({ top: 1e5 });
39
+ document.getElementById('ng-in')?.focus();
40
+ }, [naming]);
41
+ const renamingId = s.renaming?.id ?? null;
42
+ useEffect(() => {
43
+ if (!renamingId) return;
44
+ const input = document.getElementById('rn-in') as HTMLInputElement | null;
45
+ input?.focus();
46
+ input?.select();
47
+ }, [renamingId]);
48
+
49
+ const rows: ReactElement[] = [];
50
+ const walk = (nodes: readonly ArrangeNode[], d: number) => {
51
+ for (const node of nodes) {
52
+ if (s.drag && node.id === s.drag.id) {
53
+ rows.push(<Row key={node.id} node={node} depth={d} plan={plan} src />);
54
+ continue;
55
+ }
56
+ rows.push(<Row key={node.id} node={node} depth={d} plan={plan} />);
57
+ if (s.collapsed.includes(node.id)) continue;
58
+ if (node.holds) rows.push(<HeldRowView key={`held:${node.id}`} node={node} depth={d + 1} plan={plan} />);
59
+ walk(node.kids, d + 1);
60
+ }
61
+ };
62
+ walk(s.tree, 0);
63
+
64
+ return (
65
+ <section className="arr" aria-label="Arrange pages" data-arrange-mode={s.mode}>
66
+ <div className="arr-head">
67
+ <div className="arr-head-in">
68
+ <div className="arr-title">
69
+ <h2>Pages</h2>
70
+ <span className="n-chip">
71
+ {plural(countPages(s.tree), 'page', 'pages')} · {plural(countGroups(s.tree), 'group', 'groups')}
72
+ </span>
73
+ </div>
74
+ <div>
75
+ {arranging ? (
76
+ <button type="button" className="btn sm" data-act="new-group" disabled={!!s.naming || !!s.renaming} onClick={() => arrange.newGroup()}>
77
+ <Icon name="plus" size="sm" />
78
+ New group
79
+ </button>
80
+ ) : (
81
+ <span className="arr-ro">
82
+ <Icon name="lock" size="sm" />
83
+ Reviewing: go back to change anything
84
+ </span>
85
+ )}
86
+ </div>
87
+ <div className="arr-hint">
88
+ {arranging ? (
89
+ <>
90
+ <span>Drag a row to order it or put it inside another. Everything under a page moves with it.</span>
91
+ <span className="key">
92
+ <span className="key-line" aria-hidden="true" />
93
+ Beside a row, at that level
94
+ </span>
95
+ <span className="key">
96
+ <span className="key-ring" aria-hidden="true" />
97
+ Inside that page or group
98
+ </span>
99
+ </>
100
+ ) : (
101
+ <span>This is how the Pages tree will read once it’s applied.</span>
102
+ )}
103
+ </div>
104
+ </div>
105
+ </div>
106
+ <div className="arr-scroll" id="ascroll" ref={scroll}>
107
+ <div className="arr-cols" aria-hidden="true">
108
+ <span />
109
+ <span>Name</span>
110
+ <span>Address</span>
111
+ <span />
112
+ </div>
113
+ <div className="arr-tree" id="atree" role="tree" aria-label="Pages" ref={tree}>
114
+ {rows}
115
+ {s.naming ? <NamingRow /> : null}
116
+ <div className="amark" id="amark" hidden>
117
+ <span className="tag" />
118
+ </div>
119
+ </div>
120
+ </div>
121
+ {s.drag ? createPortal(<Ghost />, document.body) : null}
122
+ </section>
123
+ );
124
+ }
125
+
126
+ function Row({ node, depth: d, plan, src = false }: { node: ArrangeNode; depth: number; plan: Plan; src?: boolean }) {
127
+ const editor = useEditor();
128
+ const arrange = arrangeFor(editor);
129
+ const s = useStore(arrange.state);
130
+ const { site } = arrange;
131
+ const id = node.id;
132
+ const x = plan.now.get(id)!;
133
+ const y = node.fresh ? undefined : plan.orig.get(id);
134
+ const home = x.path === site.home && !x.parent;
135
+ const group = node.kind === 'group';
136
+ const t = s.drag?.target ?? null;
137
+ const arranging = s.mode === 'arrange';
138
+ const renaming = s.renaming?.id === id;
139
+ const draggable = arranging && !home && !s.naming && !s.renaming;
140
+ const isNew = group && !!node.fresh;
141
+ const empty = pageless(node);
142
+ const gone = group && !!y && !node.fresh && empty;
143
+ const root = !!y && y.parent !== x.parent;
144
+ const moved = !!y && y.path !== x.path;
145
+ const into = !!t && t.kind === 'inside' && t.rowId === id;
146
+ const refused = !!t && t.kind === 'refused' && t.rowId === id;
147
+ const cls = ['arow', group ? 'is-group' : 'is-page'];
148
+ if (home) cls.push('is-home');
149
+ if (src) cls.push('is-src');
150
+ if (isNew && empty && !renaming) cls.push('is-empty');
151
+ if (gone) cls.push('is-gone');
152
+ if (renaming) cls.push('is-naming');
153
+ if (into) cls.push('is-into');
154
+ if (refused) cls.push('is-refused');
155
+ if (s.linked === id) cls.push('is-linked');
156
+
157
+ const marks: ReactElement[] = [];
158
+ if (home) marks.push(<span key="home">Always first</span>);
159
+ if (!renaming) {
160
+ if (isNew) marks.push(<span key="m" className="chip select">New group</span>);
161
+ else if (group && y && y.node.slug !== node.slug) marks.push(<span key="m" className="chip select">Renamed</span>);
162
+ else if (root) marks.push(<span key="m" className="chip select">Moved</span>);
163
+ if (gone) marks.push(<span key="g" className="chip">Goes</span>);
164
+ }
165
+ if (!group && y) {
166
+ if (x.hidden && !y.hidden) marks.push(<span key="v" className="chip warn">Will be hidden</span>);
167
+ else if (!x.hidden && y.hidden) marks.push(<span key="v" className="chip live">Will be shown</span>);
168
+ else if (x.hidden) marks.push(<span key="v" className="hmark" data-mark="hidden" title={node.hidden ? 'Hidden from the site' : 'Hidden from the site with the page it’s under'}>Hidden</span>);
169
+ }
170
+ if (node.holds && s.collapsed.includes(id)) marks.push(<span key="c" className="cnt">{node.holds.label} · {node.holds.docs.length}</span>);
171
+
172
+ const check = renaming ? nameCheck(site, s.tree, s.renaming!.value, x.parent, id) : null;
173
+ let addr: ReactElement | string;
174
+ if (renaming) {
175
+ const pp = x.parent ? `${plan.now.get(x.parent)!.path}/` : '';
176
+ addr = (
177
+ <>
178
+ <span className="new" id="rn-addr">{`${site.base}${pp}${check!.slug || '…'}/`}</span>
179
+ <span className="was">
180
+ was <span className="mono">{addressAt(site, node.kind, (y ?? x).path)}</span>
181
+ </span>
182
+ </>
183
+ );
184
+ } else if (gone) addr = 'No pages left in it';
185
+ else if (moved || isNew) {
186
+ addr = (
187
+ <>
188
+ <span className="new">{addressAt(site, node.kind, x.path)}</span>
189
+ {y ? (
190
+ <span className="was">
191
+ was <span className="mono">{addressAt(site, y.node.kind, y.path)}</span>
192
+ </span>
193
+ ) : null}
194
+ </>
195
+ );
196
+ } else addr = addressAt(site, node.kind, x.path);
197
+
198
+ const ends: ReactElement[] = [];
199
+ if (into) {
200
+ ends.push(
201
+ <span key="tag" className="tag">
202
+ <Icon name="arrow-r" size="xs" />
203
+ Inside {node.label}
204
+ </span>,
205
+ );
206
+ } else if (renaming) {
207
+ ends.push(
208
+ <button key="c" type="button" className="btn sm ghost" data-act="rn-cancel" onClick={() => arrange.setRenaming(null)}>
209
+ Cancel
210
+ </button>,
211
+ <button key="ok" type="button" className="btn sm ink" data-act="rn-ok" id="rn-ok" disabled={check!.bad} onClick={() => arrange.confirmRename()}>
212
+ Rename
213
+ </button>,
214
+ );
215
+ } else if (arranging && group && !s.naming && !s.renaming) {
216
+ // A group that goes has no name to change: nothing is left in it.
217
+ if (!gone) {
218
+ ends.push(
219
+ <button key="rn" type="button" className="btn sm ghost act" data-act="rename" onClick={() => arrange.startRename(id)}>
220
+ Rename…
221
+ </button>,
222
+ );
223
+ }
224
+ if (isNew && empty) {
225
+ ends.push(
226
+ <button key="rm" type="button" className="btn sm ghost act" data-act="remove-group" onClick={() => arrange.remove(id)}>
227
+ Remove
228
+ </button>,
229
+ );
230
+ }
231
+ }
232
+ const hasKids = node.kids.length > 0 || !!node.holds;
233
+ const closed = s.collapsed.includes(id);
234
+ ends.push(
235
+ hasKids && !renaming ? (
236
+ <button key="tw" type="button" className="atw" aria-expanded={!closed} aria-label={`${closed ? 'Show' : 'Hide'} what’s under ${node.label}`} onClick={() => arrange.toggleCollapsed(id)}>
237
+ <Icon name="chev-r" />
238
+ </button>
239
+ ) : (
240
+ <span key="tw" className="atw-gap" />
241
+ ),
242
+ );
243
+
244
+ const onNameKey = (e: KeyboardEvent<HTMLInputElement>) => {
245
+ if (e.key === 'Enter') {
246
+ e.preventDefault();
247
+ arrange.confirmRename();
248
+ } else if (e.key === 'Escape') {
249
+ e.preventDefault();
250
+ arrange.setRenaming(null);
251
+ }
252
+ };
253
+ return (
254
+ <>
255
+ <div
256
+ className={cls.join(' ')}
257
+ style={depthStyle(d)}
258
+ data-id={id}
259
+ data-depth={d}
260
+ role="treeitem"
261
+ aria-level={d + 1}
262
+ data-drag={draggable ? '' : undefined}
263
+ title={home ? 'Home stays first and takes no pages' : undefined}
264
+ >
265
+ <span className="ag" aria-hidden="true">
266
+ {draggable ? <Icon name="grip" /> : null}
267
+ </span>
268
+ <span className="an">
269
+ <Icon name={group ? 'folder' : 'page'} />
270
+ {renaming ? (
271
+ <input
272
+ className="input"
273
+ id="rn-in"
274
+ value={s.renaming!.value}
275
+ aria-label={`New name for ${node.label}`}
276
+ aria-invalid={check!.err ? true : undefined}
277
+ autoComplete="off"
278
+ spellCheck={false}
279
+ onChange={(e) => arrange.setRenaming(e.target.value)}
280
+ onKeyDown={onNameKey}
281
+ />
282
+ ) : (
283
+ <span className="nm">{node.label}</span>
284
+ )}
285
+ <span className="meta">{marks}</span>
286
+ </span>
287
+ {refused && t?.kind === 'refused' ? (
288
+ <span className="arow-why" role="status">
289
+ <Icon name="x" size="sm" />
290
+ {t.reason}
291
+ </span>
292
+ ) : (
293
+ <>
294
+ <span className="aa">{addr}</span>
295
+ <span className="ae">{ends}</span>
296
+ </>
297
+ )}
298
+ </div>
299
+ {renaming ? <SubLine check={check!} depth={d} id="rn" help={renameHelp(node)} /> : null}
300
+ {!renaming && isNew && empty && !src ? (
301
+ <div className="arow-sub" style={depthStyle(d)} data-sub="empty">
302
+ Empty: add a page or remove it
303
+ {arranging ? (
304
+ <>
305
+ {' '}
306
+ <button type="button" className="linkbtn" data-act="remove-group" onClick={() => arrange.remove(id)}>
307
+ Remove
308
+ </button>
309
+ </>
310
+ ) : null}
311
+ </div>
312
+ ) : null}
313
+ </>
314
+ );
315
+ }
316
+
317
+ function HeldRowView({ node, depth: d, plan }: { node: ArrangeNode; depth: number; plan: Plan }) {
318
+ const editor = useEditor();
319
+ const arrange = arrangeFor(editor);
320
+ const target = useStore(arrange.state, (st) => st.drag?.target ?? null);
321
+ const linked = useStore(arrange.state, (st) => st.linked);
322
+ const { site } = arrange;
323
+ const x = plan.now.get(node.id)!;
324
+ const y = plan.orig.get(node.id);
325
+ const moved = !!y && y.path !== x.path;
326
+ const h = node.holds!;
327
+ const rid = `held:${node.id}`;
328
+ const refused = target?.kind === 'refused' && target.rowId === rid;
329
+ const base = addressAt(site, 'page', x.path).replace(/\/$/, '');
330
+ return (
331
+ <div
332
+ className={`arow is-held${refused ? ' is-refused' : ''}${linked === rid ? ' is-linked' : ''}`}
333
+ style={depthStyle(d)}
334
+ data-id={rid}
335
+ data-holder={node.id}
336
+ data-depth={d}
337
+ role="treeitem"
338
+ aria-level={d + 1}
339
+ title={`${h.label} goes wherever ${node.label} goes: it can’t be dragged on its own, and nothing goes under it`}
340
+ >
341
+ <span className="ag" />
342
+ <span className="an">
343
+ <Icon name={h.icon} />
344
+ <span className="nm">{h.label}</span>
345
+ <span className="meta">
346
+ <span className="cnt">
347
+ {h.docs.length} {h.docs.length === 1 ? h.one : h.many}
348
+ </span>
349
+ </span>
350
+ </span>
351
+ {refused && target?.kind === 'refused' ? (
352
+ <span className="arow-why" role="status">
353
+ <Icon name="x" size="sm" />
354
+ {target.reason}
355
+ </span>
356
+ ) : (
357
+ <>
358
+ <span className="aa">{moved ? <span className="new">{base}/…</span> : `${base}/…`}</span>
359
+ <span className="ae">
360
+ <span className="carried">Moves with {node.label}</span>
361
+ <span className="atw-gap" />
362
+ </span>
363
+ </>
364
+ )}
365
+ </div>
366
+ );
367
+ }
368
+
369
+ function SubLine({ check, depth, id, help }: { check: NameCheck; depth: number; id: string; help: string }) {
370
+ if (check.err) {
371
+ return (
372
+ <div className="arow-sub err" id={`${id}-sub`} style={depthStyle(depth)}>
373
+ <Icon name="x" size="sm" />
374
+ {check.err}
375
+ </div>
376
+ );
377
+ }
378
+ return (
379
+ <div className="arow-sub" id={`${id}-sub`} style={depthStyle(depth)}>
380
+ {check.info ?? help}
381
+ </div>
382
+ );
383
+ }
384
+
385
+ function renameHelp(node: ArrangeNode): string {
386
+ const k = countPages(node.kids);
387
+ if (!k) return 'Its address changes with its name.';
388
+ const names = node.kids.filter((c) => c.kind === 'page').map((c) => c.label);
389
+ return `${andList(names.slice(0, 3))}${k > 3 ? ` and ${k - 3} more` : ''} move${k === 1 ? 's' : ''} with it. Its address changes with its name.`;
390
+ }
391
+
392
+ function NamingRow() {
393
+ const editor = useEditor();
394
+ const arrange = arrangeFor(editor);
395
+ const s = useStore(arrange.state);
396
+ const value = s.naming?.value ?? '';
397
+ const c = nameCheck(arrange.site, s.tree, value, null, null);
398
+ const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
399
+ if (e.key === 'Enter') {
400
+ e.preventDefault();
401
+ arrange.confirmNaming();
402
+ } else if (e.key === 'Escape') {
403
+ e.preventDefault();
404
+ arrange.setNaming(null);
405
+ }
406
+ };
407
+ return (
408
+ <>
409
+ <div className="arow is-group is-naming" style={depthStyle(0)} data-id="naming">
410
+ <span className="ag" />
411
+ <span className="an">
412
+ <Icon name="folder" />
413
+ <input
414
+ className="input"
415
+ id="ng-in"
416
+ value={value}
417
+ placeholder="Group name, e.g. Company"
418
+ aria-label="New group’s name"
419
+ autoComplete="off"
420
+ spellCheck={false}
421
+ aria-invalid={c.err ? true : undefined}
422
+ onChange={(e) => arrange.setNaming(e.target.value)}
423
+ onKeyDown={onKey}
424
+ />
425
+ </span>
426
+ <span className="aa">
427
+ <span className="new" id="ng-addr">{`${arrange.site.base}${c.slug || '…'}/`}</span>
428
+ </span>
429
+ <span className="ae">
430
+ <button type="button" className="btn sm ghost" data-act="ng-cancel" onClick={() => arrange.setNaming(null)}>
431
+ Cancel
432
+ </button>
433
+ <button type="button" className="btn sm ink" data-act="ng-ok" id="ng-ok" disabled={c.bad} onClick={() => arrange.confirmNaming()}>
434
+ Add group
435
+ </button>
436
+ <span className="atw-gap" />
437
+ </span>
438
+ </div>
439
+ <SubLine check={c} depth={0} id="ng" help="Goes at the end of the top level. Then drag pages onto it." />
440
+ </>
441
+ );
442
+ }
443
+
444
+ /** The card under the pointer: what's dragged, what goes with it, and where it would land. */
445
+ function Ghost() {
446
+ const editor = useEditor();
447
+ const arrange = arrangeFor(editor);
448
+ const s = useStore(arrange.state);
449
+ if (!s.drag) return null;
450
+ const node = locate(s.tree, s.drag.id)?.node;
451
+ if (!node) return null;
452
+ const t = s.drag.target;
453
+ const index = indexTree(s.tree);
454
+ const k = countPages(node.kids);
455
+ const bits: string[] = [];
456
+ if (node.holds) bits.push(`its ${plural(node.holds.docs.length, node.holds.one, node.holds.many)}`);
457
+ if (k === 1) bits.push(node.kids.find((c) => c.kind === 'page')!.label);
458
+ else if (k > 1) bits.push(`${k} pages`);
459
+ const from = addressAt(arrange.site, node.kind, index.get(node.id)!.path);
460
+ let to: ReactElement;
461
+ if (!t) to = <span className="to plain">{from}</span>;
462
+ else if (t.kind === 'refused') {
463
+ to = (
464
+ <span className="to">
465
+ <Icon name="x" size="xs" />
466
+ Can’t go here
467
+ </span>
468
+ );
469
+ } else if (t.same) to = <span className="to plain">Only the order changes</span>;
470
+ else {
471
+ const landing = t.merge ? addressAt(arrange.site, 'page', index.get(t.merge)!.path) : addressAt(arrange.site, node.kind, `${t.parentId ? `${index.get(t.parentId)!.path}/` : ''}${node.slug}`);
472
+ to = <span className="to">{`${from} → ${landing}${t.merge ? `, as ${index.get(t.merge)!.node.label}’s own page` : ''}`}</span>;
473
+ }
474
+ return (
475
+ <div className={`adrag${t?.kind === 'refused' ? ' no' : ''}`} id="aghost" style={{ translate: `${ghostAt.x}px ${ghostAt.y}px` }}>
476
+ <Icon name={node.kind === 'group' ? 'folder' : 'page'} />
477
+ <span className="nm">
478
+ {node.label}
479
+ {bits.length ? <small>and {andList(bits)}</small> : null}
480
+ </span>
481
+ {to}
482
+ </div>
483
+ );
484
+ }
485
+
486
+ /** Where the card sits (just past the pointer), kept outside React's state so a move doesn't render. */
487
+ const ghostAt = { x: -1000, y: -1000 };
488
+ const placeGhost = (x: number, y: number) => {
489
+ ghostAt.x = Math.round(x + 14);
490
+ ghostAt.y = Math.round(y + 10);
491
+ const g = document.getElementById('aghost');
492
+ if (g) g.style.translate = `${ghostAt.x}px ${ghostAt.y}px`;
493
+ };
494
+
495
+ /** The beside marker: a line at the landing level's indent, against its row's top or bottom edge, with the level as its tag. */
496
+ function useMarker(arrange: Arrange, s: ArrangeState, tree: { current: HTMLDivElement | null }) {
497
+ useLayoutEffect(() => {
498
+ const m = tree.current?.querySelector<HTMLElement>('#amark');
499
+ if (!m) return;
500
+ const t = s.drag?.target;
501
+ const row = t && t.kind === 'beside' ? tree.current!.querySelector<HTMLElement>(`.arow[data-id="${CSS.escape(t.rowId)}"]`) : null;
502
+ if (!t || t.kind !== 'beside' || !row) {
503
+ m.hidden = true;
504
+ return;
505
+ }
506
+ const css = getComputedStyle(document.documentElement);
507
+ const grip = parseFloat(css.getPropertyValue('--arr-grip-w')) || 24;
508
+ const indent = parseFloat(css.getPropertyValue('--tree-indent')) || 16;
509
+ const y = t.edge === 'top' ? row.offsetTop : row.offsetTop + row.offsetHeight;
510
+ m.style.top = `${y - 1}px`;
511
+ m.style.left = `${grip + t.depth * indent}px`;
512
+ const tag = m.querySelector('.tag');
513
+ if (tag) tag.textContent = t.merge ? `Becomes ${locate(s.tree, t.merge)?.node.label ?? ''}’s own page` : (t.tag ?? '');
514
+ m.hidden = false;
515
+ });
516
+ }
517
+
518
+ /** Dragging rows with the mouse (the mockup's onPointerDown / onPointerMove / endDrag). */
519
+ function useDrag(arrange: Arrange, tree: { current: HTMLDivElement | null }) {
520
+ const editor = useEditor();
521
+ useEffect(() => {
522
+ const el = tree.current;
523
+ if (!el) return;
524
+ let drag: { id: string; x: number; y: number; started: boolean; key: string } | null = null;
525
+ const end = (commit: boolean) => {
526
+ if (!drag) return;
527
+ const d = drag;
528
+ drag = null;
529
+ document.body.classList.remove('is-dragging', 'is-no');
530
+ if (!d.started) return;
531
+ const t = arrange.state.get().drag?.target ?? null;
532
+ if (commit && t && t.kind !== 'refused') arrange.drop(d.id, t);
533
+ else arrange.setDrag(null);
534
+ };
535
+ const down = (e: PointerEvent) => {
536
+ const s = arrange.state.get();
537
+ if (s.mode !== 'arrange' || s.naming || s.renaming || e.button !== 0) return;
538
+ const at = e.target as HTMLElement;
539
+ if (at.closest('button, input, a')) return;
540
+ const row = at.closest<HTMLElement>('.arow[data-drag]');
541
+ if (!row?.dataset.id) return;
542
+ drag = { id: row.dataset.id, x: e.clientX, y: e.clientY, started: false, key: '' };
543
+ e.preventDefault();
544
+ };
545
+ const move = (e: PointerEvent) => {
546
+ if (!drag) return;
547
+ if (!drag.started) {
548
+ if (Math.hypot(e.clientX - drag.x, e.clientY - drag.y) < 4) return;
549
+ drag.started = true;
550
+ document.body.classList.add('is-dragging');
551
+ placeGhost(e.clientX, e.clientY);
552
+ arrange.setDrag({ id: drag.id, target: null });
553
+ }
554
+ const t = targetAt(arrange, editor.documents.get(), el, drag.id, e.clientX, e.clientY);
555
+ const key = JSON.stringify(t);
556
+ if (key !== drag.key) {
557
+ drag.key = key;
558
+ document.body.classList.toggle('is-no', t?.kind === 'refused');
559
+ arrange.setDrag({ id: drag.id, target: t });
560
+ }
561
+ placeGhost(e.clientX, e.clientY);
562
+ };
563
+ const up = () => end(true);
564
+ const cancel = () => end(false);
565
+ const key = (e: globalThis.KeyboardEvent) => {
566
+ if (e.key === 'Escape' && drag) {
567
+ e.preventDefault();
568
+ e.stopImmediatePropagation();
569
+ end(false);
570
+ }
571
+ };
572
+ el.addEventListener('pointerdown', down);
573
+ window.addEventListener('pointermove', move);
574
+ window.addEventListener('pointerup', up);
575
+ window.addEventListener('pointercancel', cancel);
576
+ window.addEventListener('keydown', key, true);
577
+ return () => {
578
+ el.removeEventListener('pointerdown', down);
579
+ window.removeEventListener('pointermove', move);
580
+ window.removeEventListener('pointerup', up);
581
+ window.removeEventListener('pointercancel', cancel);
582
+ window.removeEventListener('keydown', key, true);
583
+ document.body.classList.remove('is-dragging', 'is-no');
584
+ };
585
+ }, [arrange, editor, tree]);
586
+ }
587
+
588
+ /** Where a drop at (x, y) would go: beside a row (its top or bottom quarter), inside it (its middle), or refused. */
589
+ function targetAt(arrange: Arrange, documents: Parameters<typeof checkDrop>[2], el: HTMLElement, src: string, x: number, y: number): DropTarget | null {
590
+ const s = arrange.state.get();
591
+ const check = (t: DropTarget) => checkDrop(arrange.site, s.tree, documents, src, t);
592
+ const label = (id: string) => locate(s.tree, id)?.node.label ?? '';
593
+ const nextSibling = (list: ArrangeNode[], i: number) => list.slice(i + 1).find((n) => n.id !== src)?.id ?? null;
594
+ const row = (document.elementFromPoint(x, y) as HTMLElement | null)?.closest<HTMLElement>('#atree .arow');
595
+ if (!row) {
596
+ // Just below the last row: the end of the top level.
597
+ const box = el.getBoundingClientRect();
598
+ if (x >= box.left && x <= box.right && y > box.bottom - 2 && y < box.bottom + 120) {
599
+ const last = [...el.querySelectorAll<HTMLElement>('.arow[data-id]')].pop();
600
+ if (!last || last.dataset.id === src || last.dataset.id === 'naming') return null;
601
+ return check({ kind: 'beside', parentId: null, beforeId: null, rowId: last.dataset.id!, edge: 'bottom', depth: 0 });
602
+ }
603
+ return null;
604
+ }
605
+ const id = row.dataset.id!;
606
+ if (id === src || id === 'naming') return null;
607
+ const r = row.getBoundingClientRect();
608
+ const f = (y - r.top) / r.height;
609
+ if (row.dataset.holder) {
610
+ const h = label(row.dataset.holder);
611
+ return { kind: 'refused', rowId: id, reason: `Moves with ${h}: drop on ${h} instead` };
612
+ }
613
+ const at = locate(s.tree, id);
614
+ if (!at) return null;
615
+ const index: Map<string, Placed> = indexTree(s.tree);
616
+ if (index.get(id)!.path === arrange.site.home && !at.parentId) {
617
+ if (f > 0.72) return check({ kind: 'beside', parentId: null, beforeId: nextSibling(at.list, at.i), rowId: id, edge: 'bottom', depth: 0 });
618
+ return { kind: 'refused', rowId: id, reason: f < 0.28 ? 'Home stays first' : 'Home takes no pages' };
619
+ }
620
+ const node = at.node;
621
+ const depth = Number(row.dataset.depth) || 0;
622
+ const open = !s.collapsed.includes(id) && (node.kids.some((k) => k.id !== src) || !!node.holds);
623
+ if (f < 0.28) return check({ kind: 'beside', parentId: at.parentId, beforeId: id, rowId: id, edge: 'top', depth });
624
+ if (f > 0.72) {
625
+ if (open) return check({ kind: 'beside', parentId: id, beforeId: node.kids.find((k) => k.id !== src)?.id ?? null, rowId: node.holds ? `held:${id}` : id, edge: 'bottom', depth: depth + 1 });
626
+ return check({ kind: 'beside', parentId: at.parentId, beforeId: nextSibling(at.list, at.i), rowId: id, edge: 'bottom', depth });
627
+ }
628
+ return check({ kind: 'inside', parentId: id, beforeId: null, rowId: id, depth: depth + 1 });
629
+ }