caracms 0.3.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -3
- package/bin/templates/catch-all.astro.txt +10 -9
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +21 -4
- package/skills/caracms-blocks/SKILL.md +36 -13
- package/skills/caracms-content/SKILL.md +27 -6
- package/skills/caracms-tina/SKILL.md +53 -11
- package/skills/caracms-tina/field-kinds.md +2 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +71 -3
- package/src/cli/validate.ts +103 -12
- package/src/code-routes.ts +26 -0
- package/src/components/Head.astro +5 -5
- package/src/components/RenderDocument.astro +3 -3
- package/src/components/sitemap.xml.ts +5 -4
- package/src/config.ts +57 -11
- package/src/content-schema.ts +6 -2
- package/src/content.ts +188 -27
- package/src/document.ts +13 -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/collections/CollectionTable.tsx +9 -1
- package/src/editor/client/documents/actions.ts +75 -19
- package/src/editor/client/documents/dialogs.tsx +137 -18
- package/src/editor/client/editor.ts +78 -6
- package/src/editor/client/fields/HiddenToggle.tsx +18 -2
- package/src/editor/client/nav/DocRow.tsx +8 -1
- package/src/editor/client/nav/tree.ts +26 -1
- 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 +68 -13
- package/src/editor/client/pane/parts.tsx +14 -1
- 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/routes/move.ts +1 -1
- package/src/editor/server/admin-props.ts +24 -4
- package/src/editor/server/arrange.ts +245 -0
- package/src/editor/server/create.ts +13 -2
- package/src/editor/server/drafts-store.ts +55 -10
- package/src/editor/server/move.ts +300 -81
- package/src/editor/styles/editor.css +5 -0
- package/src/editor/styles/levels/arrange.css +152 -0
- package/src/editor/styles/levels/collections.css +2 -0
- package/src/editor/styles/levels/m5a-documents.css +15 -0
- package/src/editor/types.ts +83 -7
- package/src/held.ts +116 -0
- package/src/index.ts +6 -2
- package/src/live/README.md +3 -2
- package/src/live/auth/middleware.ts +11 -0
- package/src/live/autosaves.ts +88 -1
- package/src/live/cloud.ts +131 -0
- package/src/live/drafts-store.ts +170 -77
- package/src/live/editor.ts +26 -4
- package/src/live/git.ts +63 -12
- package/src/live/github.ts +69 -7
- package/src/live/mode.ts +42 -6
- package/src/live/runtime.ts +18 -2
- package/src/page-order.ts +253 -0
- package/src/redirects.ts +64 -1
- package/src/relink.ts +40 -9
- package/src/reserved.ts +3 -2
- package/src/serialize.ts +18 -1
- package/src/slug.ts +15 -4
|
@@ -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
|
|
|
@@ -103,7 +104,7 @@ export function DocumentsLevel() {
|
|
|
103
104
|
) : (
|
|
104
105
|
<>
|
|
105
106
|
{nested.map((c) => (
|
|
106
|
-
<PagesSection key={c.name} collection={c} documents={documents.filter((d) => d.collection === c.name)} />
|
|
107
|
+
<PagesSection key={c.name} collection={c} documents={documents.filter((d) => d.collection === c.name)} all={documents} />
|
|
107
108
|
))}
|
|
108
109
|
{flat.length > 0 ? (
|
|
109
110
|
<section className="grp" aria-label="Collections">
|
|
@@ -131,15 +132,28 @@ export function DocumentsLevel() {
|
|
|
131
132
|
);
|
|
132
133
|
}
|
|
133
134
|
|
|
134
|
-
function PagesSection({ collection, documents }: { collection: EditorCollection; documents: ListedDocument[] }) {
|
|
135
|
+
function PagesSection({ collection, documents, all }: { collection: EditorCollection; documents: ListedDocument[]; all: ListedDocument[] }) {
|
|
135
136
|
const editor = useEditor();
|
|
136
137
|
const open = useStore(editor.ui, (u) => u.open);
|
|
137
|
-
|
|
138
|
+
// Collections under pages (Decision 4): each collection a page here holds, as a row under it.
|
|
139
|
+
const held = editor.site.collections
|
|
140
|
+
.filter((c) => c.heldBy === collection.name && c.holder?.collection === collection.name)
|
|
141
|
+
.map((c) => ({ collection: c.name, label: c.label, holder: c.holder!.path, files: all.filter((d) => d.collection === c.name).map((d) => d.file) }));
|
|
142
|
+
const rows = treeRows(collection.name, documents, open, held);
|
|
138
143
|
return (
|
|
139
144
|
<section className="grp" aria-label={collection.label} data-collection={collection.name}>
|
|
140
145
|
<div className="grp-h">
|
|
141
146
|
{collection.label} <span className="n">{documents.length}</span>
|
|
142
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}
|
|
143
157
|
<button type="button" className="linkbtn" data-act="new-page" onClick={() => openDialog(editor, { kind: 'new', collection: collection.name })}>
|
|
144
158
|
<Icon name="plus" size="xs" />
|
|
145
159
|
New page
|
|
@@ -155,6 +169,22 @@ function PagesSection({ collection, documents }: { collection: EditorCollection;
|
|
|
155
169
|
function TreeNode({ row, collection }: { row: TreeRow; collection: EditorCollection }) {
|
|
156
170
|
const editor = useEditor();
|
|
157
171
|
const depth = { ['--depth' as string]: row.depth };
|
|
172
|
+
if (row.kind === 'held') {
|
|
173
|
+
const held = editor.site.collections.find((c) => c.name === row.collection);
|
|
174
|
+
return (
|
|
175
|
+
<div className="tnode" data-tnode-held={row.collection}>
|
|
176
|
+
<button type="button" className="drow t coll is-held" style={depth} data-held={row.collection} aria-label={`${row.label}, ${row.count}`} title={`${row.label}, held by this page: its documents are at its address`} onClick={() => editor.openCollection(row.collection)}>
|
|
177
|
+
<Icon name={docIcon(held)} />
|
|
178
|
+
<span className="nm">{row.label}</span>
|
|
179
|
+
<span className="meta">
|
|
180
|
+
{editor.mode === 'live' ? <Bubble files={row.files} /> : <span data-slot="bubble" hidden />}
|
|
181
|
+
<span className="cnt">{row.count}</span>
|
|
182
|
+
</span>
|
|
183
|
+
<Icon name="chev-r" size="sm" />
|
|
184
|
+
</button>
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
158
188
|
if (row.kind === 'group') {
|
|
159
189
|
return (
|
|
160
190
|
<div className="tnode" data-tnode-group={row.path}>
|
|
@@ -344,17 +374,42 @@ function SubmissionsRow() {
|
|
|
344
374
|
function CollectionRow({ collection, documents }: { collection: EditorCollection; documents: ListedDocument[] }) {
|
|
345
375
|
const editor = useEditor();
|
|
346
376
|
const n = documents.length;
|
|
377
|
+
// Collections under pages (Decision 13): a held collection no page holds (or two do) has no addresses; its entry says why.
|
|
378
|
+
const problem = collection.problem ?? null;
|
|
347
379
|
return (
|
|
348
|
-
|
|
349
|
-
<
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
{
|
|
353
|
-
{
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
380
|
+
<>
|
|
381
|
+
<button
|
|
382
|
+
type="button"
|
|
383
|
+
className={problem ? 'drow coll is-warned' : 'drow coll'}
|
|
384
|
+
data-coll={collection.name}
|
|
385
|
+
aria-label={`${collection.label}, ${n}${problem ? ', has no addresses' : ''}`}
|
|
386
|
+
title={problem ?? undefined}
|
|
387
|
+
onClick={() => editor.openCollection(collection.name)}
|
|
388
|
+
>
|
|
389
|
+
<Icon name={docIcon(collection)} />
|
|
390
|
+
<span className="nm">{collection.label}</span>
|
|
391
|
+
<span className="meta">
|
|
392
|
+
{problem ? (
|
|
393
|
+
<span className="wmark" data-warning="not-held">
|
|
394
|
+
<Icon name="warn" size="xs" />
|
|
395
|
+
</span>
|
|
396
|
+
) : null}
|
|
397
|
+
{/* M4: presence and waiting dots inside the collection, bubbled up. */}
|
|
398
|
+
{editor.mode === 'live' ? <Bubble files={documents.map((d) => d.file)} /> : <span data-slot="bubble" hidden />}
|
|
399
|
+
<span className="cnt">{n}</span>
|
|
400
|
+
</span>
|
|
401
|
+
<Icon name="chev-r" size="sm" />
|
|
402
|
+
</button>
|
|
403
|
+
{problem ? (
|
|
404
|
+
<div className="note warn tight doc-why" role="note" data-why={`coll-${collection.name}`}>
|
|
405
|
+
<Icon name="warn" />
|
|
406
|
+
<div>
|
|
407
|
+
<b>{collection.label} has no addresses</b>
|
|
408
|
+
<p>{problem}</p>
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
411
|
+
) : null}
|
|
412
|
+
</>
|
|
358
413
|
);
|
|
359
414
|
}
|
|
360
415
|
|
|
@@ -107,12 +107,25 @@ function GitHubAccessNote() {
|
|
|
107
107
|
<Icon name="warn" />
|
|
108
108
|
<div>
|
|
109
109
|
<b>{access.title}</b>
|
|
110
|
-
<p>{access.message}</p>
|
|
110
|
+
<p>{withLink(access.message, access.link ?? null)}</p>
|
|
111
111
|
</div>
|
|
112
112
|
</div>
|
|
113
113
|
);
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/** The sentence as it came, with `link` as a link where it names it (or after it); the words are never changed. */
|
|
117
|
+
function withLink(message: string, link: string | null) {
|
|
118
|
+
if (!link) return message;
|
|
119
|
+
const a = (key: number) => (
|
|
120
|
+
<a key={key} href={link} target="_blank" rel="noopener noreferrer">
|
|
121
|
+
{link}
|
|
122
|
+
</a>
|
|
123
|
+
);
|
|
124
|
+
const parts = message.split(link);
|
|
125
|
+
if (parts.length === 1) return [message, ' ', a(0)];
|
|
126
|
+
return parts.flatMap((part, i) => (i === 0 ? [part] : [a(i), part]));
|
|
127
|
+
}
|
|
128
|
+
|
|
116
129
|
function KeepNotes() {
|
|
117
130
|
const editor = useEditor();
|
|
118
131
|
const state = useStore(editor.keep, (k) => k.state);
|
|
@@ -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) });
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* POST /_caracms/move: `{ collection, path, to }` → MoveResponse, or `{ error, code }`
|
|
2
|
+
* POST /_caracms/move: `{ collection, path, to, redirect? }` → MoveResponse, or `{ error, code }`
|
|
3
3
|
* (pre-M7 item 8; shapes: ../types.ts → MoveRequest). The rules are in ../server/move.ts;
|
|
4
4
|
* the store is the mode's (server/mode.ts), as for create and delete: on disk in local
|
|
5
5
|
* mode, autosaves under this tab's locks in live mode, where the request carries
|
|
@@ -7,7 +7,8 @@ import { createHash } from 'node:crypto';
|
|
|
7
7
|
import { relative } from 'node:path';
|
|
8
8
|
import type { Brand, ResolvedSite } from '../../config.ts';
|
|
9
9
|
import { DocumentError } from '../../content.ts';
|
|
10
|
-
import {
|
|
10
|
+
import { heldBases, withHeldBases } from '../../held.ts';
|
|
11
|
+
import { inboxFromAddress, isArrangeAddress, refFromAddress } from '../addresses.ts';
|
|
11
12
|
import { handleSubmissions, SUBMISSIONS_PREFIX, submissionsSetupQuick, type SubmissionResponse, type SubmissionsListResponse } from '../../forms/inbox.ts';
|
|
12
13
|
import { ENDPOINTS, type AdminProps, type DocRef, type EditorSite, type ListedDocument, type LiveProps, type MediaSetup, type EditorForm, type Person, type StatusResponse } from '../types.ts';
|
|
13
14
|
import { parseUiState, UI_COOKIE, unsentCookieName, withOpenAncestors, withRecent, withSide } from '../ui-state.ts';
|
|
@@ -34,7 +35,24 @@ export function homeOf(site: Pick<ResolvedSite, 'collections'>, documents: reado
|
|
|
34
35
|
*/
|
|
35
36
|
export function editorSite(site: ResolvedSite, documents: readonly ListedDocument[], host: string, brand: Brand): EditorSite {
|
|
36
37
|
// Every kind of collection (M5a Phase 5): the site's own in config order, then `shared` and `settings`.
|
|
37
|
-
|
|
38
|
+
// Collections under pages (Decision 7): a held collection's urlBase is resolved from the listing, so the browser never works one out.
|
|
39
|
+
const bases = heldBases(site.collections, documents);
|
|
40
|
+
const collections = Object.values(withHeldBases(site, bases).collections).map(({ name, label, kind, urlBase, nested, fields, columns, sort, view, blocks, heldBy }) => {
|
|
41
|
+
const held = heldBy ? bases[name] : undefined;
|
|
42
|
+
return {
|
|
43
|
+
name,
|
|
44
|
+
label,
|
|
45
|
+
kind,
|
|
46
|
+
urlBase,
|
|
47
|
+
nested,
|
|
48
|
+
fields,
|
|
49
|
+
columns,
|
|
50
|
+
sort: sort ?? null,
|
|
51
|
+
view: view ?? null,
|
|
52
|
+
blocks: blocks ?? null,
|
|
53
|
+
...(held ? { heldBy, holder: held.holder && !held.problem ? { collection: held.holder.collection, path: held.holder.path } : null, problem: held.problem } : {}),
|
|
54
|
+
};
|
|
55
|
+
});
|
|
38
56
|
return {
|
|
39
57
|
collections,
|
|
40
58
|
allCollections: collections,
|
|
@@ -71,7 +89,9 @@ export async function adminProps(input: {
|
|
|
71
89
|
// M6 Decision 9: /admin/submissions[/<id>] opens the editor on the inbox, over Home.
|
|
72
90
|
const inbox = inboxFromAddress(url.pathname);
|
|
73
91
|
if (inbox === 'bad') return { ok: false, status: 404, message: `There's no submission at ${url.pathname}.` };
|
|
74
|
-
|
|
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);
|
|
75
95
|
if (!ref) return { ok: false, status: 404, message: `There's no document at ${url.pathname}.` };
|
|
76
96
|
try {
|
|
77
97
|
const opened = await store.read(ref.collection, ref.path);
|
|
@@ -84,7 +104,7 @@ export async function adminProps(input: {
|
|
|
84
104
|
const submissions = await submissionsSetupQuick({ site });
|
|
85
105
|
// The inbox's first page (and the submission the address names) in the first paint: no empty list that fills in.
|
|
86
106
|
const inboxFirst = inbox && submissions.unread !== null ? await inboxFirstPaint(site, url, inbox.id, input.person ?? null) : null;
|
|
87
|
-
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 } };
|
|
88
108
|
} catch (error) {
|
|
89
109
|
if (error instanceof DocumentError) return { ok: false, status: error.code === 'not-found' ? 404 : 400, message: error.message };
|
|
90
110
|
throw error;
|