caracms 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/package.json +1 -1
- package/src/cli/validate.ts +53 -3
- package/src/content-schema.ts +1 -0
- package/src/content.ts +41 -20
- package/src/document.ts +5 -2
- package/src/editor/README.md +3 -0
- package/src/editor/addresses.ts +6 -1
- package/src/editor/client/arrange/ArrangeStage.tsx +629 -0
- package/src/editor/client/arrange/model.ts +659 -0
- package/src/editor/client/arrange/store.ts +335 -0
- package/src/editor/client/documents/actions.ts +14 -3
- package/src/editor/client/documents/dialogs.tsx +33 -3
- package/src/editor/client/editor.ts +56 -5
- package/src/editor/client/pane/Pane.tsx +3 -0
- package/src/editor/client/pane/levels/ArrangeLevel.tsx +342 -0
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +10 -0
- package/src/editor/client/shell/Stage.tsx +8 -4
- package/src/editor/routes/admin.astro +1 -1
- package/src/editor/routes/arrange.ts +17 -0
- package/src/editor/server/admin-props.ts +5 -3
- package/src/editor/server/arrange.ts +245 -0
- package/src/editor/server/drafts-store.ts +34 -4
- package/src/editor/server/move.ts +41 -7
- package/src/editor/styles/levels/arrange.css +152 -0
- package/src/editor/types.ts +52 -2
- package/src/index.ts +45 -3
- package/src/live/drafts-store.ts +62 -4
- package/src/live/editor.ts +4 -0
- package/src/page-order.ts +253 -0
- package/src/redirects.ts +39 -7
- package/src/reserved.ts +3 -2
- package/src/serialize.ts +17 -0
|
@@ -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,
|
|
216
|
-
*
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
/**
|