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.
- 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 +2 -0
- 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,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arrange pages (notes/2026-10-25-arrange-pages.md, Decision 16; the mockup's
|
|
3
|
+
* notes/mockups/arrange-pages.html): the pane while the arrange screen has the stage.
|
|
4
|
+
* Arranging, it lists what's been arranged so far and has Cancel and Review…, off with its
|
|
5
|
+
* reason while a new group is empty or a name is half typed (Decision 9). After Review…,
|
|
6
|
+
* the review: every old → new address, a holder's documents as one count line with a
|
|
7
|
+
* disclosure, the "goes: no pages left" line, the one tick (Decision 3), or the order-only
|
|
8
|
+
* panel (Decision 21); then Back and Apply. A refused Apply shows here, named.
|
|
9
|
+
*/
|
|
10
|
+
import { useEditor, useStore } from '../../context.tsx';
|
|
11
|
+
import { arrangeFor, type ArrangeNote } from '../../arrange/store.ts';
|
|
12
|
+
import { plural, whereOrder, type Entry, type Plan } from '../../arrange/model.ts';
|
|
13
|
+
import { Avatar } from '../../live/marks.tsx';
|
|
14
|
+
import { Icon } from '../../shell/Icons.tsx';
|
|
15
|
+
import { LevelTab, Trail } from '../parts.tsx';
|
|
16
|
+
|
|
17
|
+
/** Text and bold parts in turn (model.ts → Change), as spans and <b>s. */
|
|
18
|
+
function Words({ parts }: { parts: string[] }) {
|
|
19
|
+
return <>{parts.map((p, i) => (i % 2 ? <b key={i}>{p}</b> : p))}</>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function ArrangeLevel() {
|
|
23
|
+
const editor = useEditor();
|
|
24
|
+
const arrange = arrangeFor(editor);
|
|
25
|
+
const s = useStore(arrange.state);
|
|
26
|
+
const brand = useStore(editor.brand, (b) => b.name);
|
|
27
|
+
const count = useStore(editor.documents, (d) => d.length);
|
|
28
|
+
const plan = arrange.plan();
|
|
29
|
+
const reviewing = s.mode !== 'arrange';
|
|
30
|
+
const busy = s.mode === 'applying';
|
|
31
|
+
const sub = plan.orderOnly ? 'The order only' : `${plan.lead} ${plan.verb} address`;
|
|
32
|
+
return (
|
|
33
|
+
<>
|
|
34
|
+
<Trail>
|
|
35
|
+
<LevelTab name={brand} count={String(count)} onClick={() => arrange.leave()} />
|
|
36
|
+
{reviewing ? <LevelTab name="Arrange pages" onClick={() => arrange.back()} /> : null}
|
|
37
|
+
<div className="cur">
|
|
38
|
+
<div className="cur-row">
|
|
39
|
+
{reviewing ? (
|
|
40
|
+
<button type="button" className="ib" aria-label="Back to arranging" disabled={busy} onClick={() => arrange.back()}>
|
|
41
|
+
<Icon name="chev-l" />
|
|
42
|
+
</button>
|
|
43
|
+
) : (
|
|
44
|
+
<button type="button" className="ib" aria-label="Back to documents" onClick={() => arrange.leave()}>
|
|
45
|
+
<Icon name="chev-l" />
|
|
46
|
+
</button>
|
|
47
|
+
)}
|
|
48
|
+
<div className="cur-title">
|
|
49
|
+
<span className="t">
|
|
50
|
+
<span>{reviewing ? 'Review' : 'Arrange pages'}</span>
|
|
51
|
+
</span>
|
|
52
|
+
<span className="sub">{reviewing ? sub : 'Nothing changes until you apply'}</span>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</Trail>
|
|
57
|
+
{reviewing ? <Review plan={plan} /> : <Arranging plan={plan} />}
|
|
58
|
+
</>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function Arranging({ plan }: { plan: Plan }) {
|
|
63
|
+
const editor = useEditor();
|
|
64
|
+
const arrange = arrangeFor(editor);
|
|
65
|
+
useStore(arrange.state);
|
|
66
|
+
const why = arrange.blocked();
|
|
67
|
+
const items = plan.changes;
|
|
68
|
+
return (
|
|
69
|
+
<>
|
|
70
|
+
<div className="side-body" data-arrange="changes">
|
|
71
|
+
<h3 className="sec-h">
|
|
72
|
+
Changes{items.length ? <> <span className="n">{items.length}</span></> : null}
|
|
73
|
+
</h3>
|
|
74
|
+
{items.length ? (
|
|
75
|
+
<ul className="chg">
|
|
76
|
+
{items.map((it, i) => (
|
|
77
|
+
<li key={i} className={it.muted ? 'is-muted' : undefined}>
|
|
78
|
+
<Icon name={it.icon} />
|
|
79
|
+
<span>
|
|
80
|
+
<Words parts={it.parts} />
|
|
81
|
+
</span>
|
|
82
|
+
</li>
|
|
83
|
+
))}
|
|
84
|
+
</ul>
|
|
85
|
+
) : (
|
|
86
|
+
<p className="arr-none">None yet. Drag a row on the right to start.</p>
|
|
87
|
+
)}
|
|
88
|
+
<p className="fixed-line">
|
|
89
|
+
<Icon name="clock" />
|
|
90
|
+
<span>Kept only in this tab until you apply. Leaving or reloading the page drops it.</span>
|
|
91
|
+
</p>
|
|
92
|
+
</div>
|
|
93
|
+
<div className="arr-foot">
|
|
94
|
+
{why ? (
|
|
95
|
+
<p className="why" id="rv-why">
|
|
96
|
+
<Icon name="warn" />
|
|
97
|
+
<span>
|
|
98
|
+
<Words parts={why} />
|
|
99
|
+
</span>
|
|
100
|
+
</p>
|
|
101
|
+
) : null}
|
|
102
|
+
<div className="acts">
|
|
103
|
+
<button type="button" className="btn" data-act="cancel" onClick={() => arrange.leave()}>
|
|
104
|
+
Cancel
|
|
105
|
+
</button>
|
|
106
|
+
<button type="button" className="btn ink" data-act="review" disabled={!!why} aria-describedby={why ? 'rv-why' : undefined} onClick={() => arrange.review()}>
|
|
107
|
+
Review…
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function Note({ note }: { note: ArrangeNote }) {
|
|
116
|
+
if (note.kind === 'lock') {
|
|
117
|
+
return (
|
|
118
|
+
<div className="note lock" role="alert" data-note="arrange-lock">
|
|
119
|
+
{note.person ? <Avatar person={note.person} size="sm" /> : <Icon name="lock" />}
|
|
120
|
+
<div>
|
|
121
|
+
<b>{note.error}</b>
|
|
122
|
+
<p>So nothing was applied. It frees up when they close it, or a few minutes after they stop typing. Then apply again.</p>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (note.kind === 'underneath') {
|
|
128
|
+
return (
|
|
129
|
+
<div className="note warn" role="alert" data-note="arrange-underneath">
|
|
130
|
+
<Icon name="warn" />
|
|
131
|
+
<div>
|
|
132
|
+
<b>Something changed since you opened this</b>
|
|
133
|
+
{[...note.lines, ...note.clashes].map((line, i) => (
|
|
134
|
+
<p key={i}>{line}</p>
|
|
135
|
+
))}
|
|
136
|
+
<p>{note.clashes.length ? 'Nothing was applied. Go back and arrange it again.' : 'Nothing was applied. The list below is worked out again on the site as it is now: check it, then apply.'}</p>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
if (note.kind === 'refused') {
|
|
142
|
+
return (
|
|
143
|
+
<div className="note warn" role="alert" data-note="arrange-refused">
|
|
144
|
+
<Icon name="warn" />
|
|
145
|
+
<div>
|
|
146
|
+
<b>Nothing was applied</b>
|
|
147
|
+
<p>{note.error}</p>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return (
|
|
153
|
+
<div className="note danger" role="alert" data-note="arrange-failed">
|
|
154
|
+
<Icon name="warn" />
|
|
155
|
+
<div>
|
|
156
|
+
<b>Couldn’t apply this</b>
|
|
157
|
+
<p>{note.error} Nothing on the site changed, and your arrangement is still here. Try again.</p>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function Review({ plan }: { plan: Plan }) {
|
|
164
|
+
const editor = useEditor();
|
|
165
|
+
const arrange = arrangeFor(editor);
|
|
166
|
+
const s = useStore(arrange.state);
|
|
167
|
+
const busy = s.mode === 'applying';
|
|
168
|
+
const where = whereOrder(plan);
|
|
169
|
+
const clashed = s.note?.kind === 'underneath' && s.note.clashes.length > 0;
|
|
170
|
+
const publish = editor.mode === 'live' ? ' Goes live at the next Publish.' : '';
|
|
171
|
+
return (
|
|
172
|
+
<>
|
|
173
|
+
<div className="side-body" data-arrange="review">
|
|
174
|
+
{s.note ? <Note note={s.note} /> : null}
|
|
175
|
+
{plan.orderOnly ? (
|
|
176
|
+
<div className="panel" data-review="order-only">
|
|
177
|
+
<h3>No address changes; the order of the Pages tree changes</h3>
|
|
178
|
+
<p>
|
|
179
|
+
{where ? `${where[0]!.toUpperCase()}${where.slice(1)}. ` : ''}Only the editor’s Pages tree reads this order: the site’s menus stay as they are in Site Settings.
|
|
180
|
+
</p>
|
|
181
|
+
</div>
|
|
182
|
+
) : (
|
|
183
|
+
<>
|
|
184
|
+
<p className="rv-lead">
|
|
185
|
+
{plan.lead} {plan.verb} address
|
|
186
|
+
<small>
|
|
187
|
+
Each page takes what’s under it.{plan.reorders.length ? ` The order changes ${where} too.` : ''}
|
|
188
|
+
</small>
|
|
189
|
+
</p>
|
|
190
|
+
<h3 className="sec-h">Old → new address</h3>
|
|
191
|
+
<ul className="rv">
|
|
192
|
+
{plan.entries.map((e, i) => (
|
|
193
|
+
<ReviewEntry key={`${e.t}:${e.id}:${i}`} entry={e} />
|
|
194
|
+
))}
|
|
195
|
+
</ul>
|
|
196
|
+
<p className="fixed-line">
|
|
197
|
+
<Icon name="shared" />
|
|
198
|
+
<span>Links to these pages from the site’s own pages change to the new addresses.</span>
|
|
199
|
+
</p>
|
|
200
|
+
{plan.live ? (
|
|
201
|
+
<div className="rv-tick">
|
|
202
|
+
<label className="check">
|
|
203
|
+
<input type="checkbox" id="rv-tick" checked={s.tick} disabled={busy} aria-describedby="rv-tick-h" onChange={(e) => arrange.setTick(e.target.checked)} />
|
|
204
|
+
<span>Leave redirects from the old addresses</span>
|
|
205
|
+
</label>
|
|
206
|
+
<p className="field-help" id="rv-tick-h">
|
|
207
|
+
{s.tick ? `Anyone with an old address lands on its page’s new one.${publish}` : 'Nothing will be at the old addresses: links to them from elsewhere will find no page.'}
|
|
208
|
+
</p>
|
|
209
|
+
</div>
|
|
210
|
+
) : null}
|
|
211
|
+
</>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
<div className="arr-foot">
|
|
215
|
+
{busy ? (
|
|
216
|
+
<>
|
|
217
|
+
<div className="progress" aria-hidden="true" />
|
|
218
|
+
<p className="busy" role="status">
|
|
219
|
+
Applying: {plan.orderOnly ? 'saving the order' : `moving ${plan.lead}`}…
|
|
220
|
+
</p>
|
|
221
|
+
</>
|
|
222
|
+
) : null}
|
|
223
|
+
<div className="acts">
|
|
224
|
+
<button type="button" className="btn" data-act="back" disabled={busy} onClick={() => arrange.back()}>
|
|
225
|
+
Back
|
|
226
|
+
</button>
|
|
227
|
+
<button type="button" className="btn ink" data-act="apply" disabled={busy || clashed} onClick={() => void arrange.apply()}>
|
|
228
|
+
{busy ? 'Applying…' : 'Apply'}
|
|
229
|
+
</button>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
</>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function Move({ from, to }: { from: string; to: string }) {
|
|
237
|
+
return (
|
|
238
|
+
<div className="mv">
|
|
239
|
+
<span className="fr">{from}</span>
|
|
240
|
+
<span aria-hidden="true">→</span>
|
|
241
|
+
<span className="sr">becomes</span>
|
|
242
|
+
<span className="to">{to}</span>
|
|
243
|
+
</div>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function ReviewEntry({ entry: e }: { entry: Entry }) {
|
|
248
|
+
const editor = useEditor();
|
|
249
|
+
const arrange = arrangeFor(editor);
|
|
250
|
+
const s = useStore(arrange.state);
|
|
251
|
+
const style = { ['--in' as string]: e.ind };
|
|
252
|
+
const linked = (id: string) => ({ onMouseEnter: () => arrange.setLinked(id), onMouseLeave: () => arrange.setLinked(null) });
|
|
253
|
+
if (e.t === 'newgroup') {
|
|
254
|
+
return (
|
|
255
|
+
<li className="rv-e is-grp" style={style} data-link={e.id} {...linked(e.id)}>
|
|
256
|
+
<Icon name="folder" />
|
|
257
|
+
<div className="t">
|
|
258
|
+
<span>{e.label}</span>
|
|
259
|
+
<span className="chip select">New group</span>
|
|
260
|
+
</div>
|
|
261
|
+
</li>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
if (e.t === 'rename' || e.t === 'groupmove') {
|
|
265
|
+
return (
|
|
266
|
+
<li className="rv-e is-grp" style={style} data-link={e.id} {...linked(e.id)}>
|
|
267
|
+
<Icon name="folder" />
|
|
268
|
+
<div className="t">
|
|
269
|
+
<span>{e.t === 'rename' ? `${e.from} is renamed ${e.to}` : `${e.label} moves ${e.dest ? `into ${e.dest}` : 'to the top level'}`}</span>
|
|
270
|
+
</div>
|
|
271
|
+
<Move from={e.fromA} to={e.toA} />
|
|
272
|
+
</li>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (e.t === 'gone') {
|
|
276
|
+
return (
|
|
277
|
+
<li className="rv-e is-gone" style={style} data-link={e.id} data-review="gone" {...linked(e.id)}>
|
|
278
|
+
<Icon name="folder" />
|
|
279
|
+
<div className="t">
|
|
280
|
+
<span>{e.label} goes: no pages left in it</span>
|
|
281
|
+
</div>
|
|
282
|
+
</li>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
if (e.t === 'joined') {
|
|
286
|
+
return (
|
|
287
|
+
<li className="rv-e is-grp" style={style} data-link={e.id} data-review="joined" {...linked(e.id)}>
|
|
288
|
+
<Icon name="folder" />
|
|
289
|
+
<div className="t">
|
|
290
|
+
<span>{e.how === 'drop' ? `${e.into} becomes ${e.label}’s own page` : `${e.label} joins ${e.into}: its pages go under it`}</span>
|
|
291
|
+
</div>
|
|
292
|
+
</li>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (e.t === 'held') {
|
|
296
|
+
const open = s.openHeld.includes(e.id);
|
|
297
|
+
const added = e.held.docs.filter((d) => s.addedDocs.includes(d.path)).length;
|
|
298
|
+
return (
|
|
299
|
+
<li className="rv-e is-held" style={style} data-link={`held:${e.id}`} data-review="held" {...linked(`held:${e.id}`)}>
|
|
300
|
+
<Icon name={e.held.icon} />
|
|
301
|
+
<div className="t">
|
|
302
|
+
<span>and its {plural(e.held.docs.length, e.held.one, e.held.many)}</span>
|
|
303
|
+
{added ? <span className="chip warn">{added} added since</span> : null}
|
|
304
|
+
<button type="button" className="linkbtn subtle rv-more" aria-expanded={open} onClick={() => arrange.toggleHeld(e.id)}>
|
|
305
|
+
{open ? 'Hide' : 'Show each'} <Icon name="chev-r" size="xs" />
|
|
306
|
+
</button>
|
|
307
|
+
</div>
|
|
308
|
+
<Move from={e.from} to={e.to} />
|
|
309
|
+
{open ? (
|
|
310
|
+
<ol className="rv-docs" aria-label={`Each of ${e.holder}’s ${e.held.many}, at its new address`}>
|
|
311
|
+
{/* Added since you opened this, first (Decision 10). */}
|
|
312
|
+
{[...e.held.docs.filter((d) => s.addedDocs.includes(d.path)), ...e.held.docs.filter((d) => !s.addedDocs.includes(d.path))].map((d) => (
|
|
313
|
+
<li key={d.path} className={s.addedDocs.includes(d.path) ? 'is-new' : undefined} title={s.addedDocs.includes(d.path) ? 'Added since you opened this' : undefined}>
|
|
314
|
+
{e.base}/{d.path}
|
|
315
|
+
</li>
|
|
316
|
+
))}
|
|
317
|
+
</ol>
|
|
318
|
+
) : null}
|
|
319
|
+
</li>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
if (e.t !== 'move' && e.t !== 'carried') return null;
|
|
323
|
+
return (
|
|
324
|
+
<li className={`rv-e${e.t === 'carried' ? ' is-carried' : ''}`} style={style} data-link={e.id} data-review={e.t} {...linked(e.id)}>
|
|
325
|
+
<Icon name="page" />
|
|
326
|
+
<div className="t">
|
|
327
|
+
<span>{e.label}</span>
|
|
328
|
+
{e.vis === 'hidden' ? (
|
|
329
|
+
<span className="chip warn" title="The page it goes under is hidden, so the public site stops showing it">
|
|
330
|
+
Will be hidden
|
|
331
|
+
</span>
|
|
332
|
+
) : e.vis === 'shown' ? (
|
|
333
|
+
<span className="chip live" title="It leaves the hidden page it was under, so the public site shows it">
|
|
334
|
+
Will be shown
|
|
335
|
+
</span>
|
|
336
|
+
) : null}
|
|
337
|
+
{s.added.includes(e.id) ? <span className="chip warn">Added since you opened this</span> : null}
|
|
338
|
+
</div>
|
|
339
|
+
<Move from={e.from} to={e.to} />
|
|
340
|
+
</li>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
@@ -35,6 +35,7 @@ import { openDialog } from '../../documents/dialogs.tsx';
|
|
|
35
35
|
import { Bubble } from '../../live/marks.tsx';
|
|
36
36
|
import { searchDocuments } from '../../nav/search.ts';
|
|
37
37
|
import { treeRows, type TreeRow } from '../../nav/tree.ts';
|
|
38
|
+
import { arrangeSite } from '../../arrange/store.ts';
|
|
38
39
|
import type { EditorCollection, ListedDocument } from '../../../types.ts';
|
|
39
40
|
import { CurrentHead, GlobalNotes, RecentStrip, Trail } from '../parts.tsx';
|
|
40
41
|
|
|
@@ -144,6 +145,15 @@ function PagesSection({ collection, documents, all }: { collection: EditorCollec
|
|
|
144
145
|
<div className="grp-h">
|
|
145
146
|
{collection.label} <span className="n">{documents.length}</span>
|
|
146
147
|
<span className="sp" />
|
|
148
|
+
{/* Arrange pages (Decision 2): the deliberate place to order, nest and group; nothing drags in the sidebar. */}
|
|
149
|
+
{collection.name === arrangeSite(editor).collection ? (
|
|
150
|
+
<>
|
|
151
|
+
<button type="button" className="linkbtn subtle" data-act="open-arrange" onClick={() => editor.openArrange()}>
|
|
152
|
+
Arrange pages…
|
|
153
|
+
</button>
|
|
154
|
+
<span style={{ width: 6 }} />
|
|
155
|
+
</>
|
|
156
|
+
) : null}
|
|
147
157
|
<button type="button" className="linkbtn" data-act="new-page" onClick={() => openDialog(editor, { kind: 'new', collection: collection.name })}>
|
|
148
158
|
<Icon name="plus" size="xs" />
|
|
149
159
|
New page
|
|
@@ -18,6 +18,7 @@ import { Icon } from './Icons.tsx';
|
|
|
18
18
|
import { LibraryStage } from '../media/LibraryStage.tsx';
|
|
19
19
|
import { InboxStage } from '../inbox/InboxStage.tsx';
|
|
20
20
|
import { CollectionTable, useTableCollection } from '../collections/CollectionTable.tsx';
|
|
21
|
+
import { ArrangeStage } from '../arrange/ArrangeStage.tsx';
|
|
21
22
|
|
|
22
23
|
export function useFrameScale(width: Width): number | null {
|
|
23
24
|
const editor = useEditor();
|
|
@@ -41,8 +42,10 @@ export function Stage() {
|
|
|
41
42
|
const inbox = useStore(editor.view, (v) => v.level === 'submissions');
|
|
42
43
|
// Pre-M7 polish: a flat collection's level shows its documents as a table, full width, in place of the preview.
|
|
43
44
|
const tableOf = useTableCollection();
|
|
44
|
-
|
|
45
|
-
const
|
|
45
|
+
// Arrange pages (Decision 16): the arrange screen takes the preview's place.
|
|
46
|
+
const arrange = useStore(editor.view, (v) => v.level === 'arrange');
|
|
47
|
+
const table = !!tableOf && !arrange;
|
|
48
|
+
const aside = !table && !arrange && (settingsAside || library || inbox);
|
|
46
49
|
const showing = useStore(editor.previewStatus, (s) => s.showing);
|
|
47
50
|
const documents = useStore(editor.documents);
|
|
48
51
|
const back = documents.find((d) => d.collection === showing.collection && d.path === showing.path);
|
|
@@ -76,7 +79,7 @@ export function Stage() {
|
|
|
76
79
|
return () => clearTimeout(timer);
|
|
77
80
|
}, [painted]);
|
|
78
81
|
|
|
79
|
-
const className = ['stage', !painted && 'unpainted', fading && 'painting', aside && 'is-aside', table && 'is-table'].filter(Boolean).join(' ');
|
|
82
|
+
const className = ['stage', !painted && 'unpainted', fading && 'painting', aside && 'is-aside', table && 'is-table', arrange && 'is-arrange'].filter(Boolean).join(' ');
|
|
80
83
|
return (
|
|
81
84
|
<main ref={stage} className={className} id="stage" aria-label="Preview of the page" data-painted={painted ? 'true' : 'false'}>
|
|
82
85
|
{/* Before the frames, as the mockup's: over the held page while a document has none of its own (Site Settings). */}
|
|
@@ -99,6 +102,7 @@ export function Stage() {
|
|
|
99
102
|
<div className="stage-table" inert={!table} data-stage-table={table ? '' : undefined}>
|
|
100
103
|
{tableOf ? <CollectionTable collection={tableOf} /> : null}
|
|
101
104
|
</div>
|
|
105
|
+
{arrange ? <ArrangeStage /> : null}
|
|
102
106
|
{WIDTHS.map((w) => {
|
|
103
107
|
const box = layout?.[w];
|
|
104
108
|
const on = w === width;
|
|
@@ -111,7 +115,7 @@ export function Stage() {
|
|
|
111
115
|
style={box ? { left: box.left, top: box.top, width: box.width, height: box.height } : undefined}
|
|
112
116
|
>
|
|
113
117
|
<iframe
|
|
114
|
-
inert={aside || table || undefined}
|
|
118
|
+
inert={aside || table || arrange || undefined}
|
|
115
119
|
ref={(el) => {
|
|
116
120
|
iframes.current[w] = el;
|
|
117
121
|
}}
|
|
@@ -55,7 +55,7 @@ const html = renderEditor(props);
|
|
|
55
55
|
const json = JSON.stringify(props).replace(/</g, '\\u003c');
|
|
56
56
|
const { brand } = props.site;
|
|
57
57
|
// M6: the inbox names itself (it has no document of its own).
|
|
58
|
-
const title = `${props.inbox ? 'Submissions' : props.opened.document.title || props.opened.entry.label} · ${brand.name}`;
|
|
58
|
+
const title = `${props.inbox ? 'Submissions' : props.arrange ? 'Arrange pages' : props.opened.document.title || props.opened.entry.label} · ${brand.name}`;
|
|
59
59
|
const accent = `--accent:${brand.accent};--accent-ink:${brand.accentInk};--accent-edge:${brand.accentEdge}`;
|
|
60
60
|
---
|
|
61
61
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /_caracms/arrange: ArrangeRequest → `{ ok: true }`, or `{ error, code }` as the move
|
|
3
|
+
* route refuses (arrange pages, Contracts; shapes: ../types.ts → ArrangeRequest). The rules
|
|
4
|
+
* are in ../server/arrange.ts; the store is the mode's (server/mode.ts), as for move: on
|
|
5
|
+
* disk in local mode, autosaves under this tab's locks in live mode, where the request
|
|
6
|
+
* carries `X-Caracms-Tab` and the guard has checked the session (its `/_caracms/` prefix rule).
|
|
7
|
+
*/
|
|
8
|
+
import type { APIRoute } from 'astro';
|
|
9
|
+
import { site } from 'virtual:caracms/config';
|
|
10
|
+
import { personOf } from '../../live/locals.ts';
|
|
11
|
+
import { handleArrange } from '../server/arrange.ts';
|
|
12
|
+
import { editorStore } from '../server/mode.ts';
|
|
13
|
+
|
|
14
|
+
export const prerender = false;
|
|
15
|
+
|
|
16
|
+
/** POST, and every other method (405). */
|
|
17
|
+
export const ALL: APIRoute = async ({ request, locals }) => handleArrange(request, await editorStore(site), { person: personOf(locals) });
|
|
@@ -8,7 +8,7 @@ import { relative } from 'node:path';
|
|
|
8
8
|
import type { Brand, ResolvedSite } from '../../config.ts';
|
|
9
9
|
import { DocumentError } from '../../content.ts';
|
|
10
10
|
import { heldBases, withHeldBases } from '../../held.ts';
|
|
11
|
-
import { inboxFromAddress, refFromAddress } from '../addresses.ts';
|
|
11
|
+
import { inboxFromAddress, isArrangeAddress, refFromAddress } from '../addresses.ts';
|
|
12
12
|
import { handleSubmissions, SUBMISSIONS_PREFIX, submissionsSetupQuick, type SubmissionResponse, type SubmissionsListResponse } from '../../forms/inbox.ts';
|
|
13
13
|
import { ENDPOINTS, type AdminProps, type DocRef, type EditorSite, type ListedDocument, type LiveProps, type MediaSetup, type EditorForm, type Person, type StatusResponse } from '../types.ts';
|
|
14
14
|
import { parseUiState, UI_COOKIE, unsentCookieName, withOpenAncestors, withRecent, withSide } from '../ui-state.ts';
|
|
@@ -89,7 +89,9 @@ export async function adminProps(input: {
|
|
|
89
89
|
// M6 Decision 9: /admin/submissions[/<id>] opens the editor on the inbox, over Home.
|
|
90
90
|
const inbox = inboxFromAddress(url.pathname);
|
|
91
91
|
if (inbox === 'bad') return { ok: false, status: 404, message: `There's no submission at ${url.pathname}.` };
|
|
92
|
-
|
|
92
|
+
// Arrange pages (Decision 15): /admin/arrange opens the arrange screen, over Home, with nothing arranged.
|
|
93
|
+
const arrange = isArrangeAddress(url.pathname);
|
|
94
|
+
const ref = inbox || arrange ? editor.home : refFromAddress(url.pathname, editor.collections.map((c) => c.name), editor.home);
|
|
93
95
|
if (!ref) return { ok: false, status: 404, message: `There's no document at ${url.pathname}.` };
|
|
94
96
|
try {
|
|
95
97
|
const opened = await store.read(ref.collection, ref.path);
|
|
@@ -102,7 +104,7 @@ export async function adminProps(input: {
|
|
|
102
104
|
const submissions = await submissionsSetupQuick({ site });
|
|
103
105
|
// The inbox's first page (and the submission the address names) in the first paint: no empty list that fills in.
|
|
104
106
|
const inboxFirst = inbox && submissions.unread !== null ? await inboxFirstPaint(site, url, inbox.id, input.person ?? null) : null;
|
|
105
|
-
return { ok: true, props: { site: editor, documents, opened, ui, unsent, live, media: mediaSetup(), inbox, submissions, forms: formsOf(site), inboxFirst } };
|
|
107
|
+
return { ok: true, props: { site: editor, documents, opened, ui, unsent, live, media: mediaSetup(), inbox, ...(arrange ? { arrange } : {}), submissions, forms: formsOf(site), inboxFirst } };
|
|
106
108
|
} catch (error) {
|
|
107
109
|
if (error instanceof DocumentError) return { ok: false, status: error.code === 'not-found' ? 404 : 400, message: error.message };
|
|
108
110
|
throw error;
|