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
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
21
21
|
import { INDEX_MESSAGE, NO_ADDRESS_MESSAGE, RESERVED_SLUG, addressProblem, slugify } from '../../../slug.ts';
|
|
22
|
+
import { documentsNoun, holdsMessage } from '../../../held.ts';
|
|
22
23
|
import { useEditor, useStore } from '../context.tsx';
|
|
23
24
|
import type { Editor } from '../editor.ts';
|
|
24
25
|
import { blockIcon } from '../block-list/summary.ts';
|
|
@@ -27,7 +28,7 @@ import { Icon } from '../shell/Icons.tsx';
|
|
|
27
28
|
import { createStore, type Store } from '../state/store.ts';
|
|
28
29
|
import type { CreateDeleteRefusal, DocRef, ListedDocument } from '../../types.ts';
|
|
29
30
|
import { INSIDE_CLOSE, InsidePicker } from './InsidePicker.tsx';
|
|
30
|
-
import { addressFor, andList, createDocument, deleteDocument, insideOptions, moveDocument, pagesUnder, singular } from './actions.ts';
|
|
31
|
+
import { addressFor, addressesMoving, andList, createDocument, deleteDocument, insideOptions, moveDocument, pagesUnder, singular } from './actions.ts';
|
|
31
32
|
import { filesLabel, inboxFor } from '../inbox/store.ts';
|
|
32
33
|
import { ReplaceDialog } from '../media/ReplaceDialog.tsx';
|
|
33
34
|
|
|
@@ -43,7 +44,9 @@ export type Dialog =
|
|
|
43
44
|
/** M6 Decision 9: a submission's Delete…, permanent, so it asks first (the mockup's #submission-delete). */
|
|
44
45
|
| { kind: 'sub-delete'; id: string; who: string; files: number }
|
|
45
46
|
/** M6c G4 Decision 9: Replace… on a file in the library (its key). */
|
|
46
|
-
| { 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 };
|
|
47
50
|
|
|
48
51
|
const dialogs = new WeakMap<Editor, Store<Dialog | null>>();
|
|
49
52
|
|
|
@@ -80,13 +83,21 @@ export function DocumentDialogs() {
|
|
|
80
83
|
return <DeleteSubmission key={dialog.id} {...dialog} />;
|
|
81
84
|
case 'replace':
|
|
82
85
|
return <ReplaceDialog key={dialog.key} fileKey={dialog.key} />;
|
|
86
|
+
case 'arrange-leave':
|
|
87
|
+
return <LeaveArrange go={dialog.go} />;
|
|
83
88
|
}
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
/** The dialog frame: the scrim, focus on its first control, Esc closing it alone, focus back where it was. */
|
|
87
|
-
|
|
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 }) {
|
|
88
97
|
const editor = useEditor();
|
|
89
98
|
const box = useRef<HTMLDivElement>(null);
|
|
99
|
+
const returnTo = useRef(props.returnTo);
|
|
100
|
+
returnTo.current = props.returnTo;
|
|
90
101
|
useEffect(() => {
|
|
91
102
|
const before = document.activeElement as HTMLElement | null;
|
|
92
103
|
const first =
|
|
@@ -118,7 +129,8 @@ export function Frame(props: { alert?: boolean; label: string; describedBy?: str
|
|
|
118
129
|
window.addEventListener('keydown', onKey, true);
|
|
119
130
|
return () => {
|
|
120
131
|
window.removeEventListener('keydown', onKey, true);
|
|
121
|
-
|
|
132
|
+
const opener = before?.isConnected && before !== document.body ? before : (returnTo.current?.() ?? null);
|
|
133
|
+
if (opener?.isConnected) opener.focus({ preventScroll: true });
|
|
122
134
|
};
|
|
123
135
|
}, [editor]);
|
|
124
136
|
return (
|
|
@@ -297,7 +309,18 @@ function NewDocument(props: { collection: string; inside?: string; at?: string }
|
|
|
297
309
|
/**
|
|
298
310
|
* A document's new address: for nested Pages the group it sits in ("Inside", New page's
|
|
299
311
|
* select, with New group…) and the address's last part, shown whole as it's typed, with
|
|
300
|
-
* New page's rules checked before asking the server.
|
|
312
|
+
* New page's rules checked before asking the server.
|
|
313
|
+
*
|
|
314
|
+
* **Everything under it moves with it** (collections under pages, Decision 9): the pages
|
|
315
|
+
* under it at any depth, and the documents of a collection it (or a page under it) holds.
|
|
316
|
+
* Then the dialog lists every address that changes ("Its 1 page moves with it",
|
|
317
|
+
* "Its 24 journal entries move with it", the collection's own word for its documents), warns about a route of the site's own code under
|
|
318
|
+
* the old address (it doesn't move), and has one tick for the lot. A page with nothing
|
|
319
|
+
* under it keeps the single-page dialog.
|
|
320
|
+
*
|
|
321
|
+
* "Leave a redirect from …" ("Leave redirects from the old addresses" for several), ticked
|
|
322
|
+
* to start with, says whether the old addresses redirect; it shows only when there's one to
|
|
323
|
+
* leave (a live address, not one an earlier move since the last Publish already redirects).
|
|
301
324
|
*/
|
|
302
325
|
function MoveDocument({ docRef }: { docRef: DocRef }) {
|
|
303
326
|
const editor = useEditor();
|
|
@@ -311,8 +334,9 @@ function MoveDocument({ docRef }: { docRef: DocRef }) {
|
|
|
311
334
|
const [part, setPart] = useState(docRef.path.slice(slash + 1));
|
|
312
335
|
const [error, setError] = useState<string | null>(null);
|
|
313
336
|
const [busy, setBusy] = useState(false);
|
|
314
|
-
|
|
315
|
-
|
|
337
|
+
const [redirect, setRedirect] = useState(true);
|
|
338
|
+
// Every page but this one and the pages under it: a page can't go under itself.
|
|
339
|
+
const options = useMemo(() => (collection?.nested ? insideOptions(collection, documents).filter((o) => o.path !== docRef.path && !o.path.startsWith(`${docRef.path}/`)) : []), [collection, documents, docRef.path]);
|
|
316
340
|
if (!doc || !collection || !doc.url) return null;
|
|
317
341
|
const nested = collection.nested;
|
|
318
342
|
const newGroup = nested && inside === NEW_GROUP;
|
|
@@ -323,8 +347,10 @@ function MoveDocument({ docRef }: { docRef: DocRef }) {
|
|
|
323
347
|
const url = addressFor(collection, path);
|
|
324
348
|
const base = addressFor(collection, folder ? `${folder}/` : '') ?? '/';
|
|
325
349
|
const same = path === docRef.path;
|
|
326
|
-
const
|
|
327
|
-
|
|
350
|
+
const moving = addressesMoving(editor.site.collections, docRef, path, documents);
|
|
351
|
+
const many = moving.length > 1;
|
|
352
|
+
const others = documents.filter((d) => !moving.some((m) => m.doc === d));
|
|
353
|
+
// New page's rules, before asking the server (it applies the same, and checks every address that changes).
|
|
328
354
|
const local = (() => {
|
|
329
355
|
if (same) return null;
|
|
330
356
|
if (newGroup && !slugify(group)) return group.trim() ? NO_ADDRESS_MESSAGE : null;
|
|
@@ -334,21 +360,38 @@ function MoveDocument({ docRef }: { docRef: DocRef }) {
|
|
|
334
360
|
})();
|
|
335
361
|
const shown = error ?? local;
|
|
336
362
|
const blocked = same || !last || (newGroup && !slugify(group)) || !!local || busy;
|
|
337
|
-
// Made since the last Publish: nobody has its address yet. Moved since then: its live address is the one it came from
|
|
338
|
-
|
|
363
|
+
// Made since the last Publish: nobody has its address yet. Moved since then: its live address is the one it came from,
|
|
364
|
+
// and its redirect from there is there already, following this move.
|
|
365
|
+
const pendingOf = (d: ListedDocument) => (waiting ?? []).find((w) => w.collection === d.collection && w.path === d.path && (w.change === 'new' || !!w.movedFrom));
|
|
366
|
+
const pending = pendingOf(doc);
|
|
339
367
|
const liveAddress = pending ? (pending.movedFrom ?? null) : doc.url;
|
|
340
|
-
const
|
|
368
|
+
const choose = many ? moving.some((m) => !pendingOf(m.doc)) : !pending;
|
|
341
369
|
const host = editor.site.host;
|
|
342
370
|
const confirm = async () => {
|
|
343
371
|
if (blocked) return;
|
|
344
372
|
setBusy(true);
|
|
345
373
|
setError(null);
|
|
346
|
-
const answer = await moveDocument(editor, docRef, path, doc.label);
|
|
374
|
+
const answer = await moveDocument(editor, docRef, path, doc.label, !choose || redirect);
|
|
347
375
|
setBusy(false);
|
|
348
376
|
if (answer.ok) closeDialog(editor);
|
|
349
377
|
else setError(answer.error);
|
|
350
378
|
};
|
|
351
|
-
const
|
|
379
|
+
const publish = editor.mode === 'live' ? ' Goes live at the next Publish.' : '';
|
|
380
|
+
const intro = !many && liveAddress && !choose ? `${liveAddress} will redirect here.${publish}` : null;
|
|
381
|
+
// What moves with it: the pages under it, then each held collection's documents.
|
|
382
|
+
const under = moving.filter((m) => !m.held && m.doc !== doc);
|
|
383
|
+
const heldBy = new Map<string, number>();
|
|
384
|
+
for (const m of moving) if (m.held) heldBy.set(m.doc.collection, (heldBy.get(m.doc.collection) ?? 0) + 1);
|
|
385
|
+
const labelOf = (name: string) => editor.site.collections.find((c) => c.name === name)?.label ?? name;
|
|
386
|
+
// "Its 1 page moves with it." "Its 24 journal entries move with it." "Its 2 pages and 24 journal entries move with it."
|
|
387
|
+
const heldParts = [...heldBy].map(([name, n]) => `${n} ${n === 1 ? singular({ kind: 'sections', nested: false, label: labelOf(name) }) : documentsNoun(labelOf(name))}`);
|
|
388
|
+
const parts = [...(under.length ? [`${under.length} page${under.length === 1 ? '' : 's'}`] : []), ...heldParts];
|
|
389
|
+
if (parts.length) parts[0] = `Its ${parts[0]}`;
|
|
390
|
+
const one = moving.length === 2 && parts.length === 1;
|
|
391
|
+
const withIt = `${andList(parts)} move${one ? 's' : ''} with it.`;
|
|
392
|
+
// A route of the site's own code under the old address stays where it is (Decision 10).
|
|
393
|
+
const oldBase = `${doc.url.replace(/\/+$/, '')}/`;
|
|
394
|
+
const routes = many ? (editor.site.codeRoutes ?? []).filter((r) => r.url.toLowerCase().startsWith(oldBase.toLowerCase())) : [];
|
|
352
395
|
return (
|
|
353
396
|
<Frame label="move">
|
|
354
397
|
<form
|
|
@@ -408,10 +451,49 @@ function MoveDocument({ docRef }: { docRef: DocRef }) {
|
|
|
408
451
|
</p>
|
|
409
452
|
)}
|
|
410
453
|
</div>
|
|
411
|
-
{
|
|
412
|
-
<
|
|
413
|
-
|
|
414
|
-
|
|
454
|
+
{many ? (
|
|
455
|
+
<div className="field" data-moving={moving.length}>
|
|
456
|
+
<p className="field-help">
|
|
457
|
+
{withIt} {same ? 'The addresses now:' : 'The new addresses:'}
|
|
458
|
+
</p>
|
|
459
|
+
<ul className="mv-list" aria-label="Every address that changes" tabIndex={0}>
|
|
460
|
+
{moving.map((m) => (
|
|
461
|
+
<li key={`${m.doc.collection}/${m.doc.path}`} className="mono" data-to={m.toUrl ?? ''}>
|
|
462
|
+
{same ? m.doc.url : (m.toUrl ?? '…')}
|
|
463
|
+
</li>
|
|
464
|
+
))}
|
|
465
|
+
</ul>
|
|
466
|
+
{routes.length ? (
|
|
467
|
+
<p className="field-help" data-routes={routes.length}>
|
|
468
|
+
<Icon name="warn" size="xs" /> {andList(routes.map((r) => r.file))} {routes.length === 1 ? 'is' : 'are'} the site’s own code under {doc.url}, so {routes.length === 1 ? 'it stays' : 'they stay'} where {routes.length === 1 ? 'it is' : 'they are'}.
|
|
469
|
+
</p>
|
|
470
|
+
) : null}
|
|
471
|
+
</div>
|
|
472
|
+
) : null}
|
|
473
|
+
{choose ? (
|
|
474
|
+
<div className="field">
|
|
475
|
+
<label className="check">
|
|
476
|
+
<input type="checkbox" id="mv-redirect" checked={redirect} aria-describedby="mv-redirect-h" onChange={(e) => setRedirect(e.target.checked)} />
|
|
477
|
+
<span>
|
|
478
|
+
{many ? (
|
|
479
|
+
'Leave redirects from the old addresses'
|
|
480
|
+
) : (
|
|
481
|
+
<>
|
|
482
|
+
Leave a redirect from <span className="mono">{doc.url}</span>
|
|
483
|
+
</>
|
|
484
|
+
)}
|
|
485
|
+
</span>
|
|
486
|
+
</label>
|
|
487
|
+
<p className="field-help" id="mv-redirect-h">
|
|
488
|
+
{many
|
|
489
|
+
? redirect
|
|
490
|
+
? `Anyone with an old address lands on the new one.${publish}`
|
|
491
|
+
: 'Nothing will be at the old addresses: links to them from elsewhere will find no page.'
|
|
492
|
+
: redirect
|
|
493
|
+
? `Anyone with the old address lands here.${publish}`
|
|
494
|
+
: `Nothing will be at ${doc.url}: links to it from elsewhere will find no page.`}
|
|
495
|
+
</p>
|
|
496
|
+
</div>
|
|
415
497
|
) : null}
|
|
416
498
|
<div className="acts">
|
|
417
499
|
<button type="button" className="btn" onClick={() => closeDialog(editor)}>
|
|
@@ -569,6 +651,24 @@ function DeleteDocument({ docRef }: { docRef: DocRef }) {
|
|
|
569
651
|
const doc = documents.find((d) => d.collection === docRef.collection && d.path === docRef.path);
|
|
570
652
|
const collection = editor.site.collections.find((c) => c.name === docRef.collection);
|
|
571
653
|
if (!doc || !collection) return null;
|
|
654
|
+
// Collections under pages (Decision 12): a page that holds a collection isn't deleted, and says so instead of asking.
|
|
655
|
+
const held = doc.holds ? editor.site.collections.find((c) => c.name === doc.holds && c.heldBy === collection.name) : undefined;
|
|
656
|
+
if (held) {
|
|
657
|
+
return (
|
|
658
|
+
<Frame alert label="holds" describedBy="dlg-d">
|
|
659
|
+
<div className="icon-h" style={{ background: 'var(--status-warning-wash)', color: 'var(--status-warning)' }}>
|
|
660
|
+
<Icon name="warn" />
|
|
661
|
+
</div>
|
|
662
|
+
<h2 id="dlg-h">{doc.label} can’t be deleted</h2>
|
|
663
|
+
<p id="dlg-d">{holdsMessage(doc.label, held.label)}</p>
|
|
664
|
+
<div className="acts">
|
|
665
|
+
<button type="button" className="btn ink" data-act="holds-ok" onClick={() => closeDialog(editor)}>
|
|
666
|
+
OK
|
|
667
|
+
</button>
|
|
668
|
+
</div>
|
|
669
|
+
</Frame>
|
|
670
|
+
);
|
|
671
|
+
}
|
|
572
672
|
const shared = collection.kind === 'shared';
|
|
573
673
|
const kids = collection.nested ? pagesUnder(collection.name, doc.path, documents) : [];
|
|
574
674
|
const host = editor.site.host;
|
|
@@ -680,6 +780,25 @@ function DeleteSubmission({ id, who, files }: { id: string; who: string; files:
|
|
|
680
780
|
}
|
|
681
781
|
|
|
682
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
|
+
|
|
683
802
|
function InUse({ docRef, usedOn }: { docRef: DocRef; usedOn?: CreateDeleteRefusal['usedOn'] }) {
|
|
684
803
|
const editor = useEditor();
|
|
685
804
|
const documents = useStore(editor.documents);
|
|
@@ -30,7 +30,8 @@
|
|
|
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
|
+
import { heldBases } from '../../held.ts';
|
|
34
35
|
import {
|
|
35
36
|
docId,
|
|
36
37
|
ENDPOINTS,
|
|
@@ -63,7 +64,7 @@ import { createKeeper, type HeldView, type Keeper, type KeepStatus } from './sta
|
|
|
63
64
|
import { createLiveSession, tabId, type LiveSession, type LiveState } from './state/live.ts';
|
|
64
65
|
import { createStore, type Store } from './state/store.ts';
|
|
65
66
|
|
|
66
|
-
export type PaneLevel = 'documents' | 'collection' | 'document' | 'block' | 'media' | 'submissions';
|
|
67
|
+
export type PaneLevel = 'documents' | 'collection' | 'document' | 'block' | 'media' | 'submissions' | 'arrange';
|
|
67
68
|
|
|
68
69
|
/**
|
|
69
70
|
* M5b: Choose… (or Replace…, or a list's Add image) opened the library as a picker for
|
|
@@ -247,6 +248,18 @@ export interface Editor {
|
|
|
247
248
|
* coming from elsewhere, replaced within the inbox.
|
|
248
249
|
*/
|
|
249
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;
|
|
250
263
|
|
|
251
264
|
openDocument(ref: DocRef, options?: { history?: 'push' | 'replace' | 'none' }): Promise<void>;
|
|
252
265
|
openBlock(key: string, field?: string | null, options?: { picked?: boolean }): void;
|
|
@@ -298,6 +311,24 @@ export interface Editor {
|
|
|
298
311
|
timeZone(): string;
|
|
299
312
|
}
|
|
300
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Collections under pages (Decision 7): each held collection's base, worked out again from a
|
|
316
|
+
* fresh listing (held.ts), so a holder that moved takes its collection's addresses with it in
|
|
317
|
+
* every dialog and table without a reload. The payload's own objects are updated in place.
|
|
318
|
+
*/
|
|
319
|
+
export function followHolders(site: Pick<EditorSite, 'collections' | 'allCollections'>, listed: readonly ListedDocument[]): void {
|
|
320
|
+
if (!site.collections.some((c) => c.heldBy)) return;
|
|
321
|
+
const bases = heldBases(Object.fromEntries(site.collections.map((c) => [c.name, c])), listed);
|
|
322
|
+
for (const b of Object.values(bases)) {
|
|
323
|
+
for (const c of [...site.collections, ...(site.allCollections ?? [])]) {
|
|
324
|
+
if (c.name !== b.collection) continue;
|
|
325
|
+
c.urlBase = b.base;
|
|
326
|
+
c.holder = b.holder ? { collection: b.holder.collection, path: b.holder.path } : null;
|
|
327
|
+
c.problem = b.problem;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
301
332
|
export function createEditor(props: AdminProps, options: { client: boolean }): Editor {
|
|
302
333
|
const { site } = props;
|
|
303
334
|
const opened = props.opened;
|
|
@@ -325,6 +356,8 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
|
|
|
325
356
|
const submissions = createStore<SubmissionsSetup>(props.submissions ?? { unread: 0 });
|
|
326
357
|
// M6: /admin/submissions[/<id>] opens on the inbox, with Home under it.
|
|
327
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' });
|
|
328
361
|
|
|
329
362
|
// Live mode (M4): the poll and the server calls; a tab id that lasts the tab's life.
|
|
330
363
|
const tab = liveProps ? (options.client ? tabId() : 'server') : null;
|
|
@@ -570,7 +603,8 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
|
|
|
570
603
|
|
|
571
604
|
function setTitle() {
|
|
572
605
|
if (typeof document === 'undefined') return;
|
|
573
|
-
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;
|
|
574
608
|
document.title = `${what} · ${brand.get().name}`;
|
|
575
609
|
}
|
|
576
610
|
|
|
@@ -599,7 +633,20 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
|
|
|
599
633
|
submissions,
|
|
600
634
|
inboxFirst: props.inboxFirst ?? null,
|
|
601
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
|
+
|
|
602
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;
|
|
603
650
|
const before = view.get();
|
|
604
651
|
const within = before.level === 'submissions';
|
|
605
652
|
view.update({ level: 'submissions', inbox: { id }, block: null, field: null, picked: false, pop: null, media: null });
|
|
@@ -623,6 +670,7 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
|
|
|
623
670
|
},
|
|
624
671
|
|
|
625
672
|
async openDocument(ref, { history = 'push' } = {}) {
|
|
673
|
+
if (editor.arrangeGuard?.ask(() => void editor.openDocument(ref, { history }))) return;
|
|
626
674
|
const current = doc.get().ref;
|
|
627
675
|
if (sameDoc(ref, current) && !view.get().opening) {
|
|
628
676
|
view.update({ level: 'document', block: null, field: null, picked: false, pop: null, inbox: null });
|
|
@@ -688,6 +736,11 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
|
|
|
688
736
|
|
|
689
737
|
goUp() {
|
|
690
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
|
+
}
|
|
691
744
|
const { kind } = doc.get().document;
|
|
692
745
|
// M6: the inbox: an open submission → the list; the list → documents.
|
|
693
746
|
if (level === 'submissions') {
|
|
@@ -743,7 +796,9 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
|
|
|
743
796
|
async refreshDocuments() {
|
|
744
797
|
const response = await fetch(ENDPOINTS.documents, { headers: { Accept: 'application/json' } });
|
|
745
798
|
if (!response.ok) return;
|
|
746
|
-
|
|
799
|
+
const listed = (await response.json()) as ListedDocument[];
|
|
800
|
+
followHolders(site, listed);
|
|
801
|
+
documents.set(listed);
|
|
747
802
|
// M6 Decision 9: the unread count rides along (absent when it couldn't be read).
|
|
748
803
|
const unread = response.headers.get(UNREAD_HEADER);
|
|
749
804
|
if (unread !== null && /^\d+$/.test(unread)) submissions.set({ unread: Number(unread) });
|
|
@@ -961,7 +1016,10 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
|
|
|
961
1016
|
});
|
|
962
1017
|
};
|
|
963
1018
|
const waitingKey = (st: LiveState) => (st.status?.waiting ?? []).map((w) => `${w.file}:${w.change}`).sort().join('\n');
|
|
964
|
-
|
|
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()));
|
|
965
1023
|
stops.push(keep.subscribe((next, previous) => next.state === 'kept' && previous.state !== 'kept' && refresh()));
|
|
966
1024
|
|
|
967
1025
|
// The cookie follows the sidebar's per-browser state (Decision 20).
|
|
@@ -970,9 +1028,23 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
|
|
|
970
1028
|
|
|
971
1029
|
// Addresses (Decision 19): the address names the open document; back and forward open theirs.
|
|
972
1030
|
// M6: the inbox's own address (`/admin/submissions[/<id>]`) stays as it is.
|
|
973
|
-
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);
|
|
974
1032
|
if (location.pathname !== canonical) history.replaceState({ caracms: id }, '', canonical + location.search);
|
|
975
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
|
+
}
|
|
976
1048
|
const inbox = inboxFromAddress(location.pathname);
|
|
977
1049
|
if (inbox && inbox !== 'bad') {
|
|
978
1050
|
editor.openInbox(inbox.id, { history: 'none' });
|
|
@@ -7,11 +7,16 @@
|
|
|
7
7
|
* which can't be hidden.
|
|
8
8
|
* - A page under a hidden page is hidden with it (content.ts → `hiddenBy`): the switch shows
|
|
9
9
|
* on and can't be turned off here, and its help names the page to show again.
|
|
10
|
+
* - Collections under pages (Decision 5): a page that holds a collection says so beside it,
|
|
11
|
+
* "Holds Journal (24)" (every document, hidden ones included). Nothing here changes it:
|
|
12
|
+
* the `holds` line is the file's, a developer's call.
|
|
10
13
|
*/
|
|
11
14
|
import { useMemo } from 'react';
|
|
12
15
|
import { useEditor, useStore } from '../context.tsx';
|
|
13
16
|
import { FieldFrame } from './FieldFrame.tsx';
|
|
14
17
|
import { FieldHostProvider, type FieldHost } from './host.tsx';
|
|
18
|
+
import { docIcon } from '../nav/DocRow.tsx';
|
|
19
|
+
import { Icon } from '../shell/Icons.tsx';
|
|
15
20
|
|
|
16
21
|
const SCOPE = 'page-hidden';
|
|
17
22
|
const NAME = 'hidden';
|
|
@@ -30,11 +35,14 @@ export function HiddenToggle() {
|
|
|
30
35
|
const collection = editor.site.collections.find((c) => c.name === ref.collection);
|
|
31
36
|
if (kind !== 'sections' || !collection?.urlBase || ref.path === 'index') return null;
|
|
32
37
|
const listed = documents.find((d) => d.collection === ref.collection && d.path === ref.path);
|
|
33
|
-
|
|
38
|
+
// Collections under pages (Decision 11): a held document is hidden with the page that holds its collection.
|
|
39
|
+
const byCollection = listed?.hiddenByCollection ?? ref.collection;
|
|
40
|
+
const by = listed?.hiddenBy ? documents.find((d) => d.collection === byCollection && d.path === listed.hiddenBy) : undefined;
|
|
34
41
|
const on = hidden || !!by;
|
|
42
|
+
const holds = listed?.holds ? editor.site.collections.find((c) => c.name === listed.holds && c.heldBy === ref.collection) : undefined;
|
|
35
43
|
const id = `f-${SCOPE}-${NAME}`;
|
|
36
44
|
const help = by
|
|
37
|
-
? `Hidden with ${by.label}, the page it's under. Show that page again to show this one.`
|
|
45
|
+
? `Hidden with ${by.label}, the page ${listed?.hiddenByCollection ? `that holds ${collection.label}` : "it's under"}. Show that page again to show this one.`
|
|
38
46
|
: 'Visitors get the 404 page, and it’s left out of the sitemap and the link picker. You can still edit, preview and publish it.';
|
|
39
47
|
return (
|
|
40
48
|
<div className="page-hidden" data-page-hidden={on ? 'on' : 'off'}>
|
|
@@ -46,6 +54,14 @@ export function HiddenToggle() {
|
|
|
46
54
|
</button>
|
|
47
55
|
</FieldFrame>
|
|
48
56
|
</FieldHostProvider>
|
|
57
|
+
{holds ? (
|
|
58
|
+
<p className="page-holds" data-holds={holds.name}>
|
|
59
|
+
<Icon name={docIcon(holds)} size="xs" />
|
|
60
|
+
<span>
|
|
61
|
+
Holds {holds.label} ({documents.filter((d) => d.collection === holds.name).length})
|
|
62
|
+
</span>
|
|
63
|
+
</p>
|
|
64
|
+
) : null}
|
|
49
65
|
</div>
|
|
50
66
|
);
|
|
51
67
|
}
|
|
@@ -26,6 +26,13 @@ export function docIcon(collection: { nested: boolean; kind?: string } | undefin
|
|
|
26
26
|
return 'journal';
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/** A hidden document's mark's words: "Journal is hidden" for one hidden with the page that holds its collection (or the page it's under). */
|
|
30
|
+
export function hiddenTitle(doc: ListedDocument, documents: readonly ListedDocument[]): string {
|
|
31
|
+
if (!doc.hiddenBy) return 'Hidden from the site';
|
|
32
|
+
const by = documents.find((d) => d.collection === (doc.hiddenByCollection ?? doc.collection) && d.path === doc.hiddenBy);
|
|
33
|
+
return by ? `${by.label} is hidden` : 'Hidden with the page it’s under';
|
|
34
|
+
}
|
|
35
|
+
|
|
29
36
|
export function DocRow(props: {
|
|
30
37
|
doc: ListedDocument;
|
|
31
38
|
crumb?: string;
|
|
@@ -84,7 +91,7 @@ export function DocRow(props: {
|
|
|
84
91
|
) : null}
|
|
85
92
|
{/* M9 Decision 7: hidden from the site (or under a hidden page). */}
|
|
86
93
|
{doc.hidden ? (
|
|
87
|
-
<span className="hmark" data-mark="hidden" title={doc.
|
|
94
|
+
<span className="hmark" data-mark="hidden" title={hiddenTitle(doc, editor.documents.get())}>
|
|
88
95
|
Hidden
|
|
89
96
|
</span>
|
|
90
97
|
) : null}
|
|
@@ -29,6 +29,22 @@ export type TreeRow =
|
|
|
29
29
|
/** Documents under it (any depth), for a bubbled mark when it's closed. */
|
|
30
30
|
under: ListedDocument[];
|
|
31
31
|
}
|
|
32
|
+
| {
|
|
33
|
+
/**
|
|
34
|
+
* Collections under pages (Decision 4): a collection the page above holds, as one row
|
|
35
|
+
* (its name and count, opening its table), the first row under its holder whenever the
|
|
36
|
+
* holder's row shows. Its documents never go in the tree.
|
|
37
|
+
*/
|
|
38
|
+
kind: 'held';
|
|
39
|
+
/** The holder's path (the row's position). */
|
|
40
|
+
path: string;
|
|
41
|
+
depth: number;
|
|
42
|
+
collection: string;
|
|
43
|
+
label: string;
|
|
44
|
+
/** Every document in it, hidden ones included (as its sidebar entry counts them). */
|
|
45
|
+
count: number;
|
|
46
|
+
files: string[];
|
|
47
|
+
}
|
|
32
48
|
| {
|
|
33
49
|
/** A folder with no page of its own: shows or hides its children only. */
|
|
34
50
|
kind: 'group';
|
|
@@ -46,7 +62,13 @@ export type TreeRow =
|
|
|
46
62
|
* closed group (at any depth) isn't returned. `documents` must be one collection's,
|
|
47
63
|
* in the reader's order.
|
|
48
64
|
*/
|
|
49
|
-
export function treeRows(
|
|
65
|
+
export function treeRows(
|
|
66
|
+
collection: string,
|
|
67
|
+
documents: readonly ListedDocument[],
|
|
68
|
+
open: UiState['open'],
|
|
69
|
+
/** The collections pages of this one hold, each with its holder's path and its documents' files. */
|
|
70
|
+
held: readonly { collection: string; label: string; holder: string; files: string[] }[] = [],
|
|
71
|
+
): TreeRow[] {
|
|
50
72
|
const paths = new Set(documents.map((d) => d.path));
|
|
51
73
|
const isOpen = (path: string) => open[groupKey(collection, path)] === true;
|
|
52
74
|
const under = (path: string) => documents.filter((d) => d.path.startsWith(`${path}/`));
|
|
@@ -88,6 +110,9 @@ export function treeRows(collection: string, documents: readonly ListedDocument[
|
|
|
88
110
|
key: groupKey(collection, doc.path),
|
|
89
111
|
under: kids,
|
|
90
112
|
});
|
|
113
|
+
for (const h of held) {
|
|
114
|
+
if (h.holder === doc.path) rows.push({ kind: 'held', path: doc.path, depth: segments.length, collection: h.collection, label: h.label, count: h.files.length, files: h.files });
|
|
115
|
+
}
|
|
91
116
|
}
|
|
92
117
|
return rows;
|
|
93
118
|
}
|
|
@@ -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
|
/**
|