caracms 0.4.0 → 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.
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Arrange pages (notes/2026-10-25-arrange-pages.md): the screen's state and actions, one
3
+ * per editor (`arrangeFor(editor)`), read by the stage (ArrangeStage.tsx) and the pane's
4
+ * level (pane/levels/ArrangeLevel.tsx). Nothing is saved until Apply (Decision 15): the
5
+ * state starts again from the listing each time the screen opens, and a reload does too.
6
+ *
7
+ * arrange.state { mode, orig, tree, drag, naming, renaming, collapsed, … }
8
+ * arrange.plan() what it means (model.ts → makePlan), worked out once per tree
9
+ * arrange.drop(id, t) / newGroup() / rename… / remove(id)
10
+ * arrange.review() / back() / apply()
11
+ * arrange.leave(go?) asks first when there are changes (the leave dialog), then goes
12
+ *
13
+ * Apply (Decision 10): this tab's unsent edits to what moves go first (as Change address…),
14
+ * then one POST of the plan's request. A refusal re-reads the listing, re-plans the same
15
+ * arrangement on it and shows the review again, naming what changed; a lock refusal names
16
+ * the person and the document.
17
+ */
18
+ import { isOrderedCollection, listingStamp } from '../../../page-order.ts';
19
+ import { SETTINGS_PATH } from '../../../reserved.ts';
20
+ import type { Editor } from '../editor.ts';
21
+ import { closeDialog, openDialog } from '../documents/dialogs.tsx';
22
+ import { createStore, type Store } from '../state/store.ts';
23
+ import { docId, ENDPOINTS, TAB_HEADER, type ArrangeRequest, type DocRef, type LockRefusal, type Person } from '../../types.ts';
24
+ import { addGroup, buildTree, dropNode, indexTree, locate, makePlan, nameCheck, rebase, removeNode, renameGroup, type ArrangeNode, type ArrangeSite, type DropTarget, type Plan } from './model.ts';
25
+
26
+ export type ArrangeNote =
27
+ | { kind: 'lock'; person: Person | null; error: string }
28
+ | { kind: 'underneath'; lines: string[]; clashes: string[] }
29
+ | { kind: 'refused'; error: string }
30
+ | { kind: 'failed'; error: string };
31
+
32
+ export interface ArrangeState {
33
+ /** Arranging, the review, or the review while Apply runs. */
34
+ mode: 'arrange' | 'review' | 'applying';
35
+ /** The tree as the listing had it when the screen opened (or at the last re-plan), and as arranged. */
36
+ orig: ArrangeNode[];
37
+ tree: ArrangeNode[];
38
+ /** Mid-drag: the row and where it would land (null: nowhere). */
39
+ drag: { id: string; target: DropTarget | null } | null;
40
+ /** New group, being named; Rename… on a group, inline. */
41
+ naming: { value: string } | null;
42
+ renaming: { id: string; value: string } | null;
43
+ /** Rows whose rows under them are hidden (the screen's twisties). */
44
+ collapsed: string[];
45
+ /** Holders whose documents the review shows one by one. */
46
+ openHeld: string[];
47
+ /** "Leave redirects from the old addresses" (Decision 3). */
48
+ tick: boolean;
49
+ note: ArrangeNote | null;
50
+ /** After a re-plan: rows and held documents added since the screen opened. */
51
+ added: string[];
52
+ addedDocs: string[];
53
+ /** New groups made so far (their ids). */
54
+ made: number;
55
+ /** The row an entry of the review points at while hovered (washed on the screen). */
56
+ linked: string | null;
57
+ }
58
+
59
+ export interface Arrange {
60
+ state: Store<ArrangeState>;
61
+ site: ArrangeSite;
62
+ plan(): Plan;
63
+ /** Why Review… is off, as text and bold parts in turn; null when it's on. */
64
+ blocked(): string[] | null;
65
+ reset(): void;
66
+ setDrag(drag: ArrangeState['drag']): void;
67
+ drop(id: string, target: DropTarget): void;
68
+ newGroup(): void;
69
+ setNaming(value: string | null): void;
70
+ confirmNaming(): void;
71
+ startRename(id: string): void;
72
+ setRenaming(value: string | null): void;
73
+ confirmRename(): void;
74
+ remove(id: string): void;
75
+ toggleCollapsed(id: string): void;
76
+ toggleHeld(id: string): void;
77
+ setLinked(id: string | null): void;
78
+ setTick(tick: boolean): void;
79
+ review(): void;
80
+ back(): void;
81
+ apply(): Promise<void>;
82
+ /** Leave the screen (Cancel, Back, the trail): asks first when anything is arranged. */
83
+ leave(go?: () => void): void;
84
+ }
85
+
86
+ const all = new WeakMap<Editor, Arrange>();
87
+ const clone = <T>(x: T): T => JSON.parse(JSON.stringify(x)) as T;
88
+
89
+ /** The collection the screen arranges (`pages`): the one the saved order is for, by the server's rule (page-order.ts). */
90
+ export function arrangeSite(editor: Pick<Editor, 'site'>): ArrangeSite {
91
+ const pages = editor.site.collections.find(isOrderedCollection);
92
+ return {
93
+ collection: pages?.name ?? 'pages',
94
+ base: pages?.urlBase ?? '/',
95
+ home: editor.site.home.collection === pages?.name ? editor.site.home.path : 'index',
96
+ collections: editor.site.collections,
97
+ codeRoutes: editor.site.codeRoutes,
98
+ };
99
+ }
100
+
101
+ export function arrangeFor(editor: Editor): Arrange {
102
+ const had = all.get(editor);
103
+ if (had) return had;
104
+ const site = arrangeSite(editor);
105
+ const start = (): ArrangeState => {
106
+ const orig = buildTree(site, editor.documents.get());
107
+ return { mode: 'arrange', orig, tree: clone(orig), drag: null, naming: null, renaming: null, collapsed: [], openHeld: [], tick: true, note: null, added: [], addedDocs: [], made: 0, linked: null };
108
+ };
109
+ const state = createStore<ArrangeState>(start());
110
+ let cached: { orig: ArrangeNode[]; tree: ArrangeNode[]; waiting: unknown; plan: Plan } | null = null;
111
+ const plan = () => {
112
+ const { orig, tree } = state.get();
113
+ const waiting = editor.liveState.get().status?.waiting;
114
+ if (cached && cached.orig === orig && cached.tree === tree && cached.waiting === waiting) return cached.plan;
115
+ // Made since the last Publish, or moved since and already redirected: no live address there to leave a redirect from.
116
+ const pending = (collection: string, path: string) => (waiting ?? []).some((w) => w.collection === collection && w.path === path && (w.change === 'new' || !!w.movedFrom));
117
+ cached = { orig, tree, waiting, plan: makePlan(site, orig, tree, { pending }) };
118
+ return cached.plan;
119
+ };
120
+ /** A change to the tree, on a copy (the plan is worked out again from it). */
121
+ const change = (edit: (tree: ArrangeNode[]) => void, patch: Partial<ArrangeState> = {}) => {
122
+ const tree = clone(state.get().tree);
123
+ edit(tree);
124
+ state.update({ tree, ...patch });
125
+ };
126
+ const parentOf = (id: string) => locate(state.get().tree, id)?.parentId ?? null;
127
+ const goToDocuments = () => {
128
+ void editor.openDocument(editor.doc.get().ref, { history: 'push' }).then(() => editor.goTo('documents'));
129
+ };
130
+
131
+ const arrange: Arrange = {
132
+ state,
133
+ site,
134
+ plan,
135
+ blocked() {
136
+ const s = state.get();
137
+ if (s.naming) return ['Finish naming the new group first.'];
138
+ if (s.renaming) return [`Finish renaming ${locate(s.tree, s.renaming.id)?.node.label ?? 'the group'} first.`];
139
+ const p = plan();
140
+ if (p.emptyFresh.length) return ['', p.emptyFresh[0]!.label, ' is empty: add a page to it or remove it.'];
141
+ if (!p.any) return ['Nothing to review yet.'];
142
+ return null;
143
+ },
144
+ reset() {
145
+ cached = null;
146
+ state.set(start());
147
+ },
148
+ setDrag(drag) {
149
+ state.update({ drag });
150
+ },
151
+ drop(id, target) {
152
+ if (target.kind === 'refused') return state.update({ drag: null });
153
+ change((tree) => dropNode(tree, id, target), { drag: null });
154
+ },
155
+ newGroup() {
156
+ state.update({ naming: { value: '' }, renaming: null });
157
+ },
158
+ setNaming(value) {
159
+ state.update({ naming: value === null ? null : { value } });
160
+ },
161
+ confirmNaming() {
162
+ const s = state.get();
163
+ if (!s.naming) return;
164
+ const c = nameCheck(site, s.tree, s.naming.value, null, null);
165
+ if (c.bad) return;
166
+ // A group named like a page at the top level is that page (Decision 8): nothing to add.
167
+ if (c.merge) return state.update({ naming: null });
168
+ change((tree) => addGroup(tree, s.naming!.value, s.made + 1), { naming: null, made: s.made + 1 });
169
+ },
170
+ startRename(id) {
171
+ const node = locate(state.get().tree, id)?.node;
172
+ if (node) state.update({ renaming: { id, value: node.label }, naming: null });
173
+ },
174
+ setRenaming(value) {
175
+ const r = state.get().renaming;
176
+ state.update({ renaming: value === null || !r ? null : { id: r.id, value } });
177
+ },
178
+ confirmRename() {
179
+ const s = state.get();
180
+ if (!s.renaming) return;
181
+ const { id, value } = s.renaming;
182
+ const c = nameCheck(site, s.tree, value, parentOf(id), id);
183
+ if (c.bad) return;
184
+ change((tree) => renameGroup(tree, id, value, c.merge ?? null), { renaming: null });
185
+ },
186
+ remove(id) {
187
+ change((tree) => removeNode(tree, id));
188
+ },
189
+ toggleCollapsed(id) {
190
+ const c = state.get().collapsed;
191
+ state.update({ collapsed: c.includes(id) ? c.filter((x) => x !== id) : [...c, id] });
192
+ },
193
+ toggleHeld(id) {
194
+ const o = state.get().openHeld;
195
+ state.update({ openHeld: o.includes(id) ? o.filter((x) => x !== id) : [...o, id] });
196
+ },
197
+ setLinked(linked) {
198
+ state.update({ linked });
199
+ },
200
+ setTick(tick) {
201
+ state.update({ tick });
202
+ },
203
+ review() {
204
+ if (arrange.blocked()) return;
205
+ state.update({ mode: 'review', note: null, drag: null });
206
+ },
207
+ back() {
208
+ if (state.get().mode === 'applying') return;
209
+ state.update({ mode: 'arrange', note: null, added: [], addedDocs: [] });
210
+ },
211
+ async apply() {
212
+ const s = state.get();
213
+ const p = plan();
214
+ if (s.mode !== 'review' || (s.note?.kind === 'underneath' && s.note.clashes.length)) return;
215
+ state.update({ mode: 'applying', note: null });
216
+ const refused = (note: ArrangeNote) => state.update({ mode: 'review', note });
217
+ // This tab's unsent edits to what moves (and to Site Settings, which the order rides) go first.
218
+ const refs: DocRef[] = [...movingRefs(site, p), { collection: 'settings', path: SETTINGS_PATH }];
219
+ for (const r of refs) {
220
+ const id = docId(r);
221
+ let flushed = true;
222
+ await editor.keeper?.flush(id).catch(() => (flushed = false));
223
+ if (!flushed || editor.keeper?.dirty(id)) {
224
+ const what = editor.documents.get().find((d) => d.collection === r.collection && d.path === r.path)?.label ?? r.path;
225
+ return refused({ kind: 'refused', error: `${what} has changes that haven't been saved yet, so nothing can move. Sort those out first (Use mine or Discard mine, or sign in again), then apply again.` });
226
+ }
227
+ }
228
+ const body: ArrangeRequest = { moves: p.moves, order: p.order, expect: expected(site, p), ...(p.live && !s.tick ? { redirect: false } : {}) };
229
+ const answer = await post(editor, body);
230
+ if (!answer.ok) {
231
+ if (answer.code === 'locked') return refused({ kind: 'lock', person: answer.lock?.person ?? null, error: answer.error });
232
+ if (answer.status === 0 || answer.status >= 500 || answer.status === 401) return refused({ kind: 'failed', error: answer.error });
233
+ // Something changed underneath (Decision 10): the same arrangement on the listing as it is now.
234
+ await editor.refreshDocuments().catch(() => undefined);
235
+ const now = state.get();
236
+ const r = rebase(site, now.orig, now.tree, editor.documents.get());
237
+ const holders = [...indexTree(r.tree).values()].filter((x) => x.node.holds?.docs.some((d) => r.addedDocs.includes(d.path))).map((x) => x.node.id);
238
+ state.update({ orig: r.orig, tree: r.tree, added: r.added, addedDocs: r.addedDocs, openHeld: [...new Set([...now.openHeld, ...holders])] });
239
+ return refused(r.notes.length || r.clashes.length ? { kind: 'underneath', lines: r.notes, clashes: r.clashes } : { kind: 'refused', error: answer.error });
240
+ }
241
+ for (const r of refs) editor.keeper?.discard(docId(r));
242
+ await editor.refreshDocuments().catch(() => undefined);
243
+ // The document under the screen goes with its page when it moved.
244
+ const open = editor.doc.get().ref;
245
+ const to = open.collection === site.collection ? newPathOf(p, open.path) : null;
246
+ const total = p.total;
247
+ const later = editor.mode === 'live' ? (total === 1 ? ' It goes live at the next Publish.' : ' They go live at the next Publish.') : '';
248
+ const text = total ? `Applied: ${p.lead} ${total === 1 ? 'has a new address' : 'have new addresses'}.${later}` : 'Applied: the Pages tree’s new order is saved.';
249
+ arrange.reset();
250
+ await editor.openDocument(to ? { collection: open.collection, path: to } : open, { history: 'push' });
251
+ editor.goTo('documents');
252
+ editor.toast(text, 'check');
253
+ editor.announce(text);
254
+ },
255
+ leave(go = goToDocuments) {
256
+ if (state.get().mode === 'applying') return;
257
+ const proceed = () => {
258
+ arrange.reset();
259
+ go();
260
+ };
261
+ if (!plan().any) return proceed();
262
+ openDialog(editor, {
263
+ kind: 'arrange-leave',
264
+ go: () => {
265
+ closeDialog(editor);
266
+ proceed();
267
+ },
268
+ });
269
+ },
270
+ };
271
+ editor.arrangeGuard = {
272
+ ask(go) {
273
+ if (editor.view.get().level !== 'arrange' || !plan().any) {
274
+ if (editor.view.get().level === 'arrange') arrange.reset();
275
+ return false;
276
+ }
277
+ arrange.leave(go);
278
+ return true;
279
+ },
280
+ up() {
281
+ const s = state.get();
282
+ if (s.naming || s.renaming) return;
283
+ if (s.mode === 'review') arrange.back();
284
+ else if (s.mode === 'arrange') arrange.leave();
285
+ },
286
+ };
287
+ // Each time the screen opens, it starts from the listing as it is (Decision 15).
288
+ editor.view.subscribe((next, previous) => {
289
+ if (next.level === 'arrange' && previous.level !== 'arrange') arrange.reset();
290
+ });
291
+ all.set(editor, arrange);
292
+ return arrange;
293
+ }
294
+
295
+ /** Every document whose address the plan changes, by its path now: the pages, and the held documents that go with them. */
296
+ function movingRefs(site: ArrangeSite, p: Plan): DocRef[] {
297
+ const out: DocRef[] = [];
298
+ for (const id of p.moved) out.push({ collection: site.collection, path: p.orig.get(id)!.path });
299
+ for (const h of p.heldMoved) for (const d of h.docs) out.push({ collection: h.collection, path: d.path });
300
+ return out;
301
+ }
302
+
303
+ /**
304
+ * Decision 10: the listing the review was planned on, as the server checks it at Apply
305
+ * (`ArrangeRequest.expect`): every page's path, and each moving holder's documents'.
306
+ */
307
+ function expected(site: ArrangeSite, p: Plan): Record<string, string> {
308
+ const pages = [...p.orig.values()].filter((x) => x.node.kind === 'page').map((x) => x.path);
309
+ const out: Record<string, string> = { [site.collection]: listingStamp(pages) };
310
+ for (const h of p.heldMoved) out[h.collection] = listingStamp(h.docs.map((d) => d.path));
311
+ return out;
312
+ }
313
+
314
+ /** Where a page of the arranged collection is after the plan (its path now, if it moves). */
315
+ function newPathOf(p: Plan, path: string): string | null {
316
+ for (const [id, y] of p.orig) if (y.path === path && p.now.has(id) && p.now.get(id)!.path !== path) return p.now.get(id)!.path;
317
+ return null;
318
+ }
319
+
320
+ type Answer = { ok: true } | { ok: false; status: number; error: string; code: string | null; lock: LockRefusal['lock'] | null };
321
+
322
+ async function post(editor: Editor, body: ArrangeRequest): Promise<Answer> {
323
+ const headers: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' };
324
+ if (editor.liveSession) headers[TAB_HEADER] = editor.liveSession.tab;
325
+ let response: Response;
326
+ try {
327
+ response = await fetch(ENDPOINTS.arrange, { method: 'POST', headers, body: JSON.stringify(body) });
328
+ } catch {
329
+ return { ok: false, status: 0, error: "The server can't be reached. Nothing was applied; try again in a moment.", code: 'offline', lock: null };
330
+ }
331
+ const said = (await response.json().catch(() => ({}))) as Partial<LockRefusal> & { error?: string; code?: string };
332
+ if (response.status === 401 && editor.liveSession) editor.liveState.update({ signedOut: said as never });
333
+ if (!response.ok) return { ok: false, status: response.status, error: said.error ?? `The server said no (${response.status}).`, code: said.code ?? null, lock: said.lock ?? null };
334
+ return { ok: true };
335
+ }
@@ -212,8 +212,8 @@ export interface InsideOption {
212
212
  * New page's "Inside" (Decision 11): the top level, then every group and every page, at
213
213
  * any depth, in tree order. A page with nothing under it yet is offered too, so a page can
214
214
  * go under it (Urban Return, 2026-10-24); Home isn't, since no address goes under `/index/`.
215
- * At each level, what already holds pages comes first (as folders come first in a file
216
- * list), so the site's shape is at the top while the rest are a scroll or a search away.
215
+ * At each level, the Pages tree's order (arrange pages, Decision 19): `documents` is the
216
+ * listing, already in that order (Home, the saved order, then what holds pages, then A–Z).
217
217
  */
218
218
  export function insideOptions(collection: Pick<EditorAnyCollection, 'name' | 'urlBase'>, documents: readonly ListedDocument[]): InsideOption[] {
219
219
  const base = collection.urlBase ?? '/';
@@ -227,11 +227,22 @@ export function insideOptions(collection: Pick<EditorAnyCollection, 'name' | 'ur
227
227
  const labelOf = (path: string) => docs.find((d) => d.path === path)?.label ?? titleCase(path.split('/').pop()!);
228
228
  const holds = (path: string) => docs.some((d) => d.path.startsWith(`${path}/`));
229
229
  const parentOf = (path: string) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '');
230
+ // Arrange pages (Decision 19): the tree's order, as the listing gives it (content.ts → pageTree,
231
+ // page-order.ts's rule): a folder sits where its page, or its first page down, is listed.
232
+ const first = new Map<string, number>();
233
+ docs.forEach((d, i) => {
234
+ const parts = d.path.split('/');
235
+ for (let n = 1; n <= parts.length; n++) {
236
+ const at = parts.slice(0, n).join('/');
237
+ if (!first.has(at)) first.set(at, i);
238
+ }
239
+ });
240
+ const rank = (path: string) => first.get(path) ?? Number.MAX_SAFE_INTEGER;
230
241
  const children = new Map<string, string[]>();
231
242
  for (const f of folders) children.set(parentOf(f), [...(children.get(parentOf(f)) ?? []), f]);
232
243
  const out: InsideOption[] = [{ path: '', label: 'Top level', name: 'Top level', depth: 0, page: false, holds: true, base }];
233
244
  const walk = (parent: string) => {
234
- const kids = (children.get(parent) ?? []).sort((a, b) => Number(holds(b)) - Number(holds(a)) || a.localeCompare(b));
245
+ const kids = (children.get(parent) ?? []).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
235
246
  for (const path of kids) {
236
247
  const parts = path.split('/');
237
248
  out.push({
@@ -44,7 +44,9 @@ export type Dialog =
44
44
  /** M6 Decision 9: a submission's Delete…, permanent, so it asks first (the mockup's #submission-delete). */
45
45
  | { kind: 'sub-delete'; id: string; who: string; files: number }
46
46
  /** M6c G4 Decision 9: Replace… on a file in the library (its key). */
47
- | { kind: 'replace'; key: string };
47
+ | { kind: 'replace'; key: string }
48
+ /** Arrange pages (Decision 15): leaving the screen with something arranged; `go` leaves, dropping it. */
49
+ | { kind: 'arrange-leave'; go: () => void };
48
50
 
49
51
  const dialogs = new WeakMap<Editor, Store<Dialog | null>>();
50
52
 
@@ -81,13 +83,21 @@ export function DocumentDialogs() {
81
83
  return <DeleteSubmission key={dialog.id} {...dialog} />;
82
84
  case 'replace':
83
85
  return <ReplaceDialog key={dialog.key} fileKey={dialog.key} />;
86
+ case 'arrange-leave':
87
+ return <LeaveArrange go={dialog.go} />;
84
88
  }
85
89
  }
86
90
 
87
91
  /** The dialog frame: the scrim, focus on its first control, Esc closing it alone, focus back where it was. */
88
- export function Frame(props: { alert?: boolean; label: string; describedBy?: string; children: ReactNode }) {
92
+ /**
93
+ * `returnTo`: where focus goes when the dialog closes and no control opened it (the browser's
94
+ * Back, say, leaves it on the page body); otherwise it goes back to the control that did.
95
+ */
96
+ export function Frame(props: { alert?: boolean; label: string; describedBy?: string; returnTo?: () => HTMLElement | null; children: ReactNode }) {
89
97
  const editor = useEditor();
90
98
  const box = useRef<HTMLDivElement>(null);
99
+ const returnTo = useRef(props.returnTo);
100
+ returnTo.current = props.returnTo;
91
101
  useEffect(() => {
92
102
  const before = document.activeElement as HTMLElement | null;
93
103
  const first =
@@ -119,7 +129,8 @@ export function Frame(props: { alert?: boolean; label: string; describedBy?: str
119
129
  window.addEventListener('keydown', onKey, true);
120
130
  return () => {
121
131
  window.removeEventListener('keydown', onKey, true);
122
- if (before?.isConnected) before.focus({ preventScroll: true });
132
+ const opener = before?.isConnected && before !== document.body ? before : (returnTo.current?.() ?? null);
133
+ if (opener?.isConnected) opener.focus({ preventScroll: true });
123
134
  };
124
135
  }, [editor]);
125
136
  return (
@@ -769,6 +780,25 @@ function DeleteSubmission({ id, who, files }: { id: string; who: string; files:
769
780
  }
770
781
 
771
782
  /** A shared block that pages still use can't be deleted (Decision 8): where it's used, each a way there. */
783
+ /** Arrange pages (Decision 15; the mockup's #leave): what's arranged isn't saved anywhere, so leaving asks first. */
784
+ function LeaveArrange({ go }: { go: () => void }) {
785
+ const editor = useEditor();
786
+ return (
787
+ <Frame label="arrange-leave" returnTo={() => document.querySelector<HTMLElement>('[data-pane-level="arrange"] [data-act="cancel"]')}>
788
+ <h2 id="dlg-h">Leave without applying?</h2>
789
+ <p>What you’ve arranged isn’t saved anywhere: leave now and it’s gone. Nothing on the site has changed.</p>
790
+ <div className="acts">
791
+ <button type="button" className="btn" data-act="leave-stay" onClick={() => closeDialog(editor)}>
792
+ Keep arranging
793
+ </button>
794
+ <button type="button" className="btn ink" data-act="leave-go" onClick={go}>
795
+ Leave without applying
796
+ </button>
797
+ </div>
798
+ </Frame>
799
+ );
800
+ }
801
+
772
802
  function InUse({ docRef, usedOn }: { docRef: DocRef; usedOn?: CreateDeleteRefusal['usedOn'] }) {
773
803
  const editor = useEditor();
774
804
  const documents = useStore(editor.documents);
@@ -30,7 +30,7 @@
30
30
  * editor.editHere() / useMine() / discardMine() / publish() / tryAgain() /
31
31
  * dismissNotice(id) / signOut() / signInAgain()
32
32
  */
33
- import { addressOf, inboxAddress, inboxFromAddress, refFromAddress } from '../addresses.ts';
33
+ import { ARRANGE_ADDRESS, addressOf, inboxAddress, inboxFromAddress, isArrangeAddress, refFromAddress } from '../addresses.ts';
34
34
  import { heldBases } from '../../held.ts';
35
35
  import {
36
36
  docId,
@@ -64,7 +64,7 @@ import { createKeeper, type HeldView, type Keeper, type KeepStatus } from './sta
64
64
  import { createLiveSession, tabId, type LiveSession, type LiveState } from './state/live.ts';
65
65
  import { createStore, type Store } from './state/store.ts';
66
66
 
67
- export type PaneLevel = 'documents' | 'collection' | 'document' | 'block' | 'media' | 'submissions';
67
+ export type PaneLevel = 'documents' | 'collection' | 'document' | 'block' | 'media' | 'submissions' | 'arrange';
68
68
 
69
69
  /**
70
70
  * M5b: Choose… (or Replace…, or a list's Add image) opened the library as a picker for
@@ -248,6 +248,18 @@ export interface Editor {
248
248
  * coming from elsewhere, replaced within the inbox.
249
249
  */
250
250
  openInbox(id?: string | null, options?: { history?: 'push' | 'replace' | 'none' }): void;
251
+ /**
252
+ * Arrange pages (Decisions 2, 15, 16): the arrange screen in the preview's place, its level
253
+ * in the pane, at `/admin/arrange` (pushed when coming from elsewhere). It starts from the
254
+ * listing as it is each time (client/arrange/store.ts).
255
+ */
256
+ openArrange(options?: { history?: 'push' | 'replace' | 'none' }): void;
257
+ /**
258
+ * Arrange pages (Decision 15): the screen's say when the tab would leave it, set by
259
+ * client/arrange/store.ts. `ask(go)` returns true when it took over (anything arranged:
260
+ * it asks first, then calls `go`); `up()` is Esc there. Null until the screen is first shown.
261
+ */
262
+ arrangeGuard: { ask(go: () => void): boolean; up(): void } | null;
251
263
 
252
264
  openDocument(ref: DocRef, options?: { history?: 'push' | 'replace' | 'none' }): Promise<void>;
253
265
  openBlock(key: string, field?: string | null, options?: { picked?: boolean }): void;
@@ -344,6 +356,8 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
344
356
  const submissions = createStore<SubmissionsSetup>(props.submissions ?? { unread: 0 });
345
357
  // M6: /admin/submissions[/<id>] opens on the inbox, with Home under it.
346
358
  if (props.inbox) view.update({ level: 'submissions', inbox: { id: props.inbox.id } });
359
+ // Arrange pages: /admin/arrange opens on the screen, with Home under it.
360
+ if (props.arrange) view.update({ level: 'arrange' });
347
361
 
348
362
  // Live mode (M4): the poll and the server calls; a tab id that lasts the tab's life.
349
363
  const tab = liveProps ? (options.client ? tabId() : 'server') : null;
@@ -589,7 +603,8 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
589
603
 
590
604
  function setTitle() {
591
605
  if (typeof document === 'undefined') return;
592
- const what = view.get().level === 'submissions' ? 'Submissions' : doc.get().document.title || doc.get().entry.label;
606
+ const level = view.get().level;
607
+ const what = level === 'submissions' ? 'Submissions' : level === 'arrange' ? 'Arrange pages' : doc.get().document.title || doc.get().entry.label;
593
608
  document.title = `${what} · ${brand.get().name}`;
594
609
  }
595
610
 
@@ -618,7 +633,20 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
618
633
  submissions,
619
634
  inboxFirst: props.inboxFirst ?? null,
620
635
 
636
+ arrangeGuard: null,
637
+
638
+ openArrange({ history = 'push' } = {}) {
639
+ view.update({ level: 'arrange', inbox: null, media: null, collection: null, block: null, field: null, picked: false, pop: null });
640
+ if (history !== 'none' && typeof window !== 'undefined' && location.pathname !== ARRANGE_ADDRESS) {
641
+ if (history === 'push') window.history.pushState({ caracms: 'arrange' }, '', ARRANGE_ADDRESS);
642
+ else window.history.replaceState({ caracms: 'arrange' }, '', ARRANGE_ADDRESS);
643
+ }
644
+ setTitle();
645
+ },
646
+
621
647
  openInbox(id = null, { history } = {}) {
648
+ // Leaving the arrange screen with something arranged asks first (Decision 15).
649
+ if (editor.arrangeGuard?.ask(() => editor.openInbox(id, { history }))) return;
622
650
  const before = view.get();
623
651
  const within = before.level === 'submissions';
624
652
  view.update({ level: 'submissions', inbox: { id }, block: null, field: null, picked: false, pop: null, media: null });
@@ -642,6 +670,7 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
642
670
  },
643
671
 
644
672
  async openDocument(ref, { history = 'push' } = {}) {
673
+ if (editor.arrangeGuard?.ask(() => void editor.openDocument(ref, { history }))) return;
645
674
  const current = doc.get().ref;
646
675
  if (sameDoc(ref, current) && !view.get().opening) {
647
676
  view.update({ level: 'document', block: null, field: null, picked: false, pop: null, inbox: null });
@@ -707,6 +736,11 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
707
736
 
708
737
  goUp() {
709
738
  const { level, media, inbox } = view.get();
739
+ // Arrange pages: the review → arranging; arranging → leave (asking first when anything is arranged).
740
+ if (level === 'arrange') {
741
+ editor.arrangeGuard?.up();
742
+ return true;
743
+ }
710
744
  const { kind } = doc.get().document;
711
745
  // M6: the inbox: an open submission → the list; the list → documents.
712
746
  if (level === 'submissions') {
@@ -982,7 +1016,10 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
982
1016
  });
983
1017
  };
984
1018
  const waitingKey = (st: LiveState) => (st.status?.waiting ?? []).map((w) => `${w.file}:${w.change}`).sort().join('\n');
985
- if (liveSession) stops.push(liveState.subscribe((next, previous) => next.status && previous.status && waitingKey(next) !== waitingKey(previous) && refresh()));
1019
+ // Arrange pages (Decision 18): an order-only Apply elsewhere changes no waiting key when Site Settings was
1020
+ // already waiting, so the saved order's stamp is watched too.
1021
+ const orderKey = (st: LiveState) => st.status?.pageOrderStamp ?? '';
1022
+ if (liveSession) stops.push(liveState.subscribe((next, previous) => next.status && previous.status && (waitingKey(next) !== waitingKey(previous) || orderKey(next) !== orderKey(previous)) && refresh()));
986
1023
  stops.push(keep.subscribe((next, previous) => next.state === 'kept' && previous.state !== 'kept' && refresh()));
987
1024
 
988
1025
  // The cookie follows the sidebar's per-browser state (Decision 20).
@@ -991,9 +1028,23 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
991
1028
 
992
1029
  // Addresses (Decision 19): the address names the open document; back and forward open theirs.
993
1030
  // M6: the inbox's own address (`/admin/submissions[/<id>]`) stays as it is.
994
- const canonical = view.get().level === 'submissions' ? inboxAddress(view.get().inbox?.id ?? null) : addressOf(doc.get().ref, site.home);
1031
+ const canonical = view.get().level === 'submissions' ? inboxAddress(view.get().inbox?.id ?? null) : view.get().level === 'arrange' ? ARRANGE_ADDRESS : addressOf(doc.get().ref, site.home);
995
1032
  if (location.pathname !== canonical) history.replaceState({ caracms: id }, '', canonical + location.search);
996
1033
  const onPopState = () => {
1034
+ if (isArrangeAddress(location.pathname)) {
1035
+ editor.openArrange({ history: 'none' });
1036
+ return;
1037
+ }
1038
+ // Back from the arrange screen with something arranged: it stays, and asks first (Decision 15).
1039
+ if (view.get().level === 'arrange' && editor.arrangeGuard) {
1040
+ // The screen's entry is put back while it asks; leaving steps back off it again, so the
1041
+ // history is as a plain Back leaves it (the arrangement is gone by then, so it doesn't ask twice).
1042
+ const asked = editor.arrangeGuard.ask(() => history.back());
1043
+ if (asked) {
1044
+ history.pushState({ caracms: 'arrange' }, '', ARRANGE_ADDRESS);
1045
+ return;
1046
+ }
1047
+ }
997
1048
  const inbox = inboxFromAddress(location.pathname);
998
1049
  if (inbox && inbox !== 'bad') {
999
1050
  editor.openInbox(inbox.id, { history: 'none' });
@@ -17,6 +17,7 @@ import { DocumentLevel } from './levels/DocumentLevel.tsx';
17
17
  import { DocumentsLevel } from './levels/DocumentsLevel.tsx';
18
18
  import { MediaLevel } from './levels/MediaLevel.tsx';
19
19
  import { SubmissionsLevel } from './levels/SubmissionsLevel.tsx';
20
+ import { ArrangeLevel } from './levels/ArrangeLevel.tsx';
20
21
 
21
22
  export const LEVELS: Record<PaneLevel, ComponentType> = {
22
23
  documents: DocumentsLevel,
@@ -27,6 +28,8 @@ export const LEVELS: Record<PaneLevel, ComponentType> = {
27
28
  media: MediaLevel,
28
29
  // M6: the inbox (Site-wide → Submissions).
29
30
  submissions: SubmissionsLevel,
31
+ // Arrange pages: the screen's controls, then its review (Decision 16).
32
+ arrange: ArrangeLevel,
30
33
  };
31
34
 
32
35
  /**