caracms 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +57 -2
- package/bin/caracms.mjs +174 -0
- package/bin/gate.mjs +275 -0
- package/bin/init.mjs +712 -0
- package/bin/media.mjs +662 -0
- package/bin/site-runner.mjs +61 -0
- package/bin/templates/Page.astro.txt +31 -0
- package/bin/templates/TextBlock.astro.txt +13 -0
- package/bin/templates/caracms.config.ts.txt +22 -0
- package/bin/templates/catch-all.astro.txt +25 -0
- package/bin/templates/health.ts.txt +6 -0
- package/bin/templates/railway.ts.txt +147 -0
- package/package.json +66 -4
- package/skills/caracms-adopt/SKILL.md +508 -0
- package/skills/caracms-adopt/look-same.mjs +957 -0
- package/skills/caracms-blocks/SKILL.md +376 -0
- package/skills/caracms-content/SKILL.md +201 -0
- package/skills/caracms-tina/SKILL.md +427 -0
- package/skills/caracms-tina/field-kinds.md +88 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
- package/src/block-runs.ts +84 -0
- package/src/cards.ts +158 -0
- package/src/cli/schemas.ts +122 -0
- package/src/cli/site.ts +66 -0
- package/src/cli/validate.ts +471 -0
- package/src/code-routes-disk.ts +67 -0
- package/src/code-routes.ts +134 -0
- package/src/columns.ts +39 -0
- package/src/components/Block.astro +46 -0
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +139 -0
- package/src/components/Buttons.astro +116 -0
- package/src/components/Fonts.astro +14 -0
- package/src/components/Form.astro +221 -0
- package/src/components/Head.astro +62 -0
- package/src/components/Link.astro +68 -0
- package/src/components/Lockup.astro +116 -0
- package/src/components/Media.astro +124 -0
- package/src/components/MediaPlayer.astro +102 -0
- package/src/components/RenderDocument.astro +64 -0
- package/src/components/RichText.astro +59 -0
- package/src/components/Text.astro +81 -0
- package/src/components/attributes.ts +33 -0
- package/src/components/descriptor.ts +45 -0
- package/src/components/form-client.ts +182 -0
- package/src/components/media-player.ts +46 -0
- package/src/components/media-props.ts +35 -0
- package/src/components/sitemap.xml.ts +36 -0
- package/src/config.ts +825 -0
- package/src/content-schema.ts +166 -0
- package/src/content.ts +422 -0
- package/src/document.ts +579 -0
- package/src/editor/README.md +405 -0
- package/src/editor/addresses.ts +61 -0
- package/src/editor/brand.ts +118 -0
- package/src/editor/client/block-list/BlockList.tsx +342 -0
- package/src/editor/client/block-list/Picker.tsx +105 -0
- package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
- package/src/editor/client/block-list/insert-points.ts +56 -0
- package/src/editor/client/block-list/link.ts +71 -0
- package/src/editor/client/block-list/reorder.ts +105 -0
- package/src/editor/client/block-list/summary.ts +118 -0
- package/src/editor/client/collections/CollectionGrid.tsx +143 -0
- package/src/editor/client/collections/CollectionTable.tsx +406 -0
- package/src/editor/client/collections/table.ts +270 -0
- package/src/editor/client/context.tsx +27 -0
- package/src/editor/client/documents/DocMenu.tsx +154 -0
- package/src/editor/client/documents/actions.ts +192 -0
- package/src/editor/client/documents/dialogs.tsx +687 -0
- package/src/editor/client/editor.ts +995 -0
- package/src/editor/client/fields/ButtonsField.tsx +87 -0
- package/src/editor/client/fields/DateField.tsx +200 -0
- package/src/editor/client/fields/FieldControl.tsx +61 -0
- package/src/editor/client/fields/FieldFrame.tsx +109 -0
- package/src/editor/client/fields/GroupField.tsx +113 -0
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +307 -0
- package/src/editor/client/fields/ListField.tsx +642 -0
- package/src/editor/client/fields/LockupField.tsx +365 -0
- package/src/editor/client/fields/MediaField.tsx +823 -0
- package/src/editor/client/fields/PageFields.tsx +82 -0
- package/src/editor/client/fields/ReferenceField.tsx +209 -0
- package/src/editor/client/fields/RichTextField.tsx +306 -0
- package/src/editor/client/fields/SelectField.tsx +63 -0
- package/src/editor/client/fields/SeoGroup.tsx +282 -0
- package/src/editor/client/fields/SimpleFields.tsx +89 -0
- package/src/editor/client/fields/TextField.tsx +51 -0
- package/src/editor/client/fields/date.ts +73 -0
- package/src/editor/client/fields/host.tsx +172 -0
- package/src/editor/client/fields/list-summary.ts +85 -0
- package/src/editor/client/fields/lockup-rules.ts +181 -0
- package/src/editor/client/fields/parts.tsx +458 -0
- package/src/editor/client/fields/rich-text-commands.ts +183 -0
- package/src/editor/client/fields/rich-text-doc.ts +156 -0
- package/src/editor/client/fields/rich-text-schema.ts +224 -0
- package/src/editor/client/fields/stale.ts +58 -0
- package/src/editor/client/inbox/InboxStage.tsx +272 -0
- package/src/editor/client/inbox/store.ts +311 -0
- package/src/editor/client/live/format.ts +77 -0
- package/src/editor/client/live/marks.tsx +90 -0
- package/src/editor/client/live/notices.tsx +213 -0
- package/src/editor/client/live/publish.tsx +617 -0
- package/src/editor/client/live/session.tsx +162 -0
- package/src/editor/client/main.tsx +12 -0
- package/src/editor/client/media/LibraryStage.tsx +75 -0
- package/src/editor/client/media/ReplaceDialog.tsx +355 -0
- package/src/editor/client/media/store.ts +618 -0
- package/src/editor/client/nav/DocRow.tsx +122 -0
- package/src/editor/client/nav/search.ts +71 -0
- package/src/editor/client/nav/tree.ts +93 -0
- package/src/editor/client/overlays/OverlayMount.tsx +26 -0
- package/src/editor/client/overlays/editor-marks.ts +159 -0
- package/src/editor/client/overlays/index.ts +50 -0
- package/src/editor/client/overlays/layer.ts +437 -0
- package/src/editor/client/overlays/overlay-css.ts +177 -0
- package/src/editor/client/overlays/rich-text-bar.ts +717 -0
- package/src/editor/client/pane/Pane.tsx +91 -0
- package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
- package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
- package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
- package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
- package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
- package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
- package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
- package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
- package/src/editor/client/pane/parts.tsx +179 -0
- package/src/editor/client/pane/switcher.tsx +165 -0
- package/src/editor/client/preview/driver.ts +640 -0
- package/src/editor/client/preview/field-text.ts +71 -0
- package/src/editor/client/preview/follow-links.ts +234 -0
- package/src/editor/client/preview/layout.ts +37 -0
- package/src/editor/client/preview/link-target.ts +50 -0
- package/src/editor/client/preview/markers.ts +86 -0
- package/src/editor/client/preview/page-edit.ts +632 -0
- package/src/editor/client/preview/page-editor-registry.ts +63 -0
- package/src/editor/client/preview/page-editor.ts +235 -0
- package/src/editor/client/preview/render-sequencer.ts +113 -0
- package/src/editor/client/preview/selection.ts +116 -0
- package/src/editor/client/preview/warnings.ts +61 -0
- package/src/editor/client/shell/App.tsx +101 -0
- package/src/editor/client/shell/Bar.tsx +197 -0
- package/src/editor/client/shell/Brand.tsx +33 -0
- package/src/editor/client/shell/Icons.tsx +87 -0
- package/src/editor/client/shell/NarrowNote.tsx +47 -0
- package/src/editor/client/shell/Popovers.tsx +47 -0
- package/src/editor/client/shell/Stage.tsx +132 -0
- package/src/editor/client/shell/Toast.tsx +61 -0
- package/src/editor/client/state/document-store.ts +465 -0
- package/src/editor/client/state/keeper.ts +713 -0
- package/src/editor/client/state/live.ts +257 -0
- package/src/editor/client/state/schema.ts +42 -0
- package/src/editor/client/state/store.ts +39 -0
- package/src/editor/fonts/OFL-GeistMono.txt +93 -0
- package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
- package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
- package/src/editor/middleware.ts +62 -0
- package/src/editor/routes/_live.ts +26 -0
- package/src/editor/routes/admin.astro +80 -0
- package/src/editor/routes/create.ts +19 -0
- package/src/editor/routes/delete.ts +19 -0
- package/src/editor/routes/document.ts +39 -0
- package/src/editor/routes/documents.ts +30 -0
- package/src/editor/routes/github.ts +23 -0
- package/src/editor/routes/history.ts +9 -0
- package/src/editor/routes/lock.ts +9 -0
- package/src/editor/routes/login/assets.d.ts +10 -0
- package/src/editor/routes/login/login.ts +43 -0
- package/src/editor/routes/media.ts +16 -0
- package/src/editor/routes/move.ts +17 -0
- package/src/editor/routes/notice.ts +9 -0
- package/src/editor/routes/preview.astro +77 -0
- package/src/editor/routes/publish.ts +9 -0
- package/src/editor/routes/status.ts +9 -0
- package/src/editor/routes/submissions.ts +16 -0
- package/src/editor/routes/try-again.ts +9 -0
- package/src/editor/routes/write.ts +18 -0
- package/src/editor/server/admin-props.ts +151 -0
- package/src/editor/server/cards.ts +61 -0
- package/src/editor/server/create.ts +414 -0
- package/src/editor/server/drafts-store.ts +127 -0
- package/src/editor/server/media/alt.ts +107 -0
- package/src/editor/server/media/bucket.ts +295 -0
- package/src/editor/server/media/config.ts +78 -0
- package/src/editor/server/media/handler.ts +221 -0
- package/src/editor/server/media/library.ts +254 -0
- package/src/editor/server/media/memory.ts +184 -0
- package/src/editor/server/media/mux.ts +241 -0
- package/src/editor/server/media/sniff.ts +599 -0
- package/src/editor/server/media/upload.ts +175 -0
- package/src/editor/server/mode.ts +63 -0
- package/src/editor/server/move.ts +295 -0
- package/src/editor/server/render.tsx +9 -0
- package/src/editor/server/replace.ts +72 -0
- package/src/editor/server/settings.ts +35 -0
- package/src/editor/server/write.ts +366 -0
- package/src/editor/styles/editor.css +502 -0
- package/src/editor/styles/fonts.css +40 -0
- package/src/editor/styles/levels/README.md +4 -0
- package/src/editor/styles/levels/collections.css +99 -0
- package/src/editor/styles/levels/document-level.css +10 -0
- package/src/editor/styles/levels/documents.css +9 -0
- package/src/editor/styles/levels/fields.css +171 -0
- package/src/editor/styles/levels/files.css +95 -0
- package/src/editor/styles/levels/live.css +10 -0
- package/src/editor/styles/levels/m5a-documents.css +51 -0
- package/src/editor/styles/levels/m6.css +82 -0
- package/src/editor/styles/levels/media.css +175 -0
- package/src/editor/styles/levels/switcher.css +3 -0
- package/src/editor/styles/tokens.css +114 -0
- package/src/editor/tsconfig.json +16 -0
- package/src/editor/types.ts +881 -0
- package/src/editor/ui-state.ts +113 -0
- package/src/email.ts +67 -0
- package/src/fields.ts +1129 -0
- package/src/files-route.ts +201 -0
- package/src/files.ts +116 -0
- package/src/forms/email.ts +149 -0
- package/src/forms/endpoint.ts +347 -0
- package/src/forms/inbox.ts +273 -0
- package/src/forms/rate-limit.ts +130 -0
- package/src/forms/route.ts +24 -0
- package/src/forms/rules.ts +278 -0
- package/src/forms/store.ts +486 -0
- package/src/head.ts +125 -0
- package/src/index.ts +434 -0
- package/src/json-source.ts +116 -0
- package/src/links.ts +121 -0
- package/src/live/README.md +43 -0
- package/src/live/auth/allow-rules.ts +70 -0
- package/src/live/auth/allow.ts +128 -0
- package/src/live/auth/auth.ts +155 -0
- package/src/live/auth/email.ts +84 -0
- package/src/live/auth/guard.ts +111 -0
- package/src/live/auth/index.ts +68 -0
- package/src/live/auth/links.ts +101 -0
- package/src/live/auth/login.ts +245 -0
- package/src/live/auth/middleware.ts +53 -0
- package/src/live/auth/request.ts +56 -0
- package/src/live/auth/routes.ts +9 -0
- package/src/live/auth/session.ts +181 -0
- package/src/live/auth/views.ts +355 -0
- package/src/live/autosaves.ts +306 -0
- package/src/live/backend.ts +652 -0
- package/src/live/branches.ts +19 -0
- package/src/live/drafts-store.ts +591 -0
- package/src/live/editor.ts +465 -0
- package/src/live/git.ts +1021 -0
- package/src/live/github.ts +271 -0
- package/src/live/locals.ts +24 -0
- package/src/live/locks.ts +273 -0
- package/src/live/merge.ts +197 -0
- package/src/live/messages.ts +151 -0
- package/src/live/mode.ts +210 -0
- package/src/live/redis.ts +533 -0
- package/src/live/runtime.ts +164 -0
- package/src/live/status.ts +109 -0
- package/src/live/sweep.ts +228 -0
- package/src/live/timings.ts +47 -0
- package/src/live/webhook.ts +39 -0
- package/src/lockup-css.ts +58 -0
- package/src/measure.ts +145 -0
- package/src/media-value.ts +239 -0
- package/src/media.ts +255 -0
- package/src/read.ts +256 -0
- package/src/redirect-middleware.ts +49 -0
- package/src/redirects.ts +76 -0
- package/src/relink.ts +293 -0
- package/src/reserved.ts +24 -0
- package/src/rich-text.ts +690 -0
- package/src/schema-key.ts +63 -0
- package/src/seo.ts +124 -0
- package/src/serialize.ts +697 -0
- package/src/skipped.ts +35 -0
- package/src/slug.ts +121 -0
- package/src/text-styles.ts +62 -0
- package/src/virtual.d.ts +28 -0
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dialogs for creating and deleting documents (M5a Decisions 11 and 12; the
|
|
3
|
+
* mockup's #new-page, #new-group, #address-taken, #group-page, #new-case,
|
|
4
|
+
* #new-shared, #delete-page, #delete-parent, #shared-delete), in the editor's
|
|
5
|
+
* `#modal-root`. One at a time, from `dialogOf(editor)`:
|
|
6
|
+
*
|
|
7
|
+
* openDialog(editor, { kind: 'new', collection, inside?, at? }) New page / + New
|
|
8
|
+
* openDialog(editor, { kind: 'new-shared' }) + New shared block
|
|
9
|
+
* openDialog(editor, { kind: 'delete', ref }) Delete page… (the confirmation)
|
|
10
|
+
* openDialog(editor, { kind: 'in-use', ref, usedOn? }) a shared block still used
|
|
11
|
+
* openDialog(editor, { kind: 'move', ref }) Change address… (pre-M7 item 8)
|
|
12
|
+
* openDialog(editor, { kind: 'replace', key }) Replace… on a file (M6c G4, media/ReplaceDialog.tsx)
|
|
13
|
+
*
|
|
14
|
+
* Esc closes the dialog only (the editor's own Esc, going up a level, doesn't run).
|
|
15
|
+
* Addresses are shown before creating, made by slug.ts as the server makes them, and
|
|
16
|
+
* pre-checked with `addressProblem`; whatever the server still refuses, its own
|
|
17
|
+
* sentence shows on the field.
|
|
18
|
+
*/
|
|
19
|
+
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
20
|
+
import { INDEX_MESSAGE, NO_ADDRESS_MESSAGE, RESERVED_SLUG, addressProblem, slugify } from '../../../slug.ts';
|
|
21
|
+
import { useEditor, useStore } from '../context.tsx';
|
|
22
|
+
import type { Editor } from '../editor.ts';
|
|
23
|
+
import { blockIcon } from '../block-list/summary.ts';
|
|
24
|
+
import { docIcon } from '../nav/DocRow.tsx';
|
|
25
|
+
import { Icon } from '../shell/Icons.tsx';
|
|
26
|
+
import { createStore, type Store } from '../state/store.ts';
|
|
27
|
+
import type { CreateDeleteRefusal, DocRef, ListedDocument } from '../../types.ts';
|
|
28
|
+
import { addressFor, andList, createDocument, deleteDocument, insideOptions, moveDocument, pagesUnder, singular } from './actions.ts';
|
|
29
|
+
import { filesLabel, inboxFor } from '../inbox/store.ts';
|
|
30
|
+
import { ReplaceDialog } from '../media/ReplaceDialog.tsx';
|
|
31
|
+
|
|
32
|
+
export type Dialog =
|
|
33
|
+
| { kind: 'new'; collection: string; inside?: string; at?: string }
|
|
34
|
+
| { kind: 'new-shared' }
|
|
35
|
+
| { kind: 'delete'; ref: DocRef }
|
|
36
|
+
| { kind: 'in-use'; ref: DocRef; usedOn?: CreateDeleteRefusal['usedOn'] }
|
|
37
|
+
/** Pre-M7 item 8: Change address…, in the language of New page. */
|
|
38
|
+
| { kind: 'move'; ref: DocRef }
|
|
39
|
+
/** M6 Decision 9: a submission's Delete…, permanent, so it asks first (the mockup's #submission-delete). */
|
|
40
|
+
| { kind: 'sub-delete'; id: string; who: string; files: number }
|
|
41
|
+
/** M6c G4 Decision 9: Replace… on a file in the library (its key). */
|
|
42
|
+
| { kind: 'replace'; key: string };
|
|
43
|
+
|
|
44
|
+
const dialogs = new WeakMap<Editor, Store<Dialog | null>>();
|
|
45
|
+
|
|
46
|
+
export function dialogOf(editor: Editor): Store<Dialog | null> {
|
|
47
|
+
let store = dialogs.get(editor);
|
|
48
|
+
if (!store) dialogs.set(editor, (store = createStore<Dialog | null>(null)));
|
|
49
|
+
return store;
|
|
50
|
+
}
|
|
51
|
+
export const openDialog = (editor: Editor, dialog: Dialog) => {
|
|
52
|
+
editor.setPop(null);
|
|
53
|
+
dialogOf(editor).set(dialog);
|
|
54
|
+
};
|
|
55
|
+
export const closeDialog = (editor: Editor) => dialogOf(editor).set(null);
|
|
56
|
+
|
|
57
|
+
/** Whichever dialog is open. Mounted once, in the shell's `#modal-root`. */
|
|
58
|
+
export function DocumentDialogs() {
|
|
59
|
+
const editor = useEditor();
|
|
60
|
+
const dialog = useStore(dialogOf(editor));
|
|
61
|
+
if (!dialog) return null;
|
|
62
|
+
switch (dialog.kind) {
|
|
63
|
+
case 'new':
|
|
64
|
+
return <NewDocument key={`${dialog.collection}:${dialog.inside ?? ''}:${dialog.at ?? ''}`} {...dialog} />;
|
|
65
|
+
case 'new-shared':
|
|
66
|
+
return <NewShared />;
|
|
67
|
+
case 'delete':
|
|
68
|
+
return <DeleteDocument key={`${dialog.ref.collection}/${dialog.ref.path}`} docRef={dialog.ref} />;
|
|
69
|
+
case 'in-use':
|
|
70
|
+
return <InUse docRef={dialog.ref} usedOn={dialog.usedOn} />;
|
|
71
|
+
case 'move':
|
|
72
|
+
return <MoveDocument key={`${dialog.ref.collection}/${dialog.ref.path}`} docRef={dialog.ref} />;
|
|
73
|
+
case 'sub-delete':
|
|
74
|
+
return <DeleteSubmission key={dialog.id} {...dialog} />;
|
|
75
|
+
case 'replace':
|
|
76
|
+
return <ReplaceDialog key={dialog.key} fileKey={dialog.key} />;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The dialog frame: the scrim, focus on its first control, Esc closing it alone, focus back where it was. */
|
|
81
|
+
export function Frame(props: { alert?: boolean; label: string; describedBy?: string; children: ReactNode }) {
|
|
82
|
+
const editor = useEditor();
|
|
83
|
+
const box = useRef<HTMLDivElement>(null);
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
const before = document.activeElement as HTMLElement | null;
|
|
86
|
+
const first =
|
|
87
|
+
box.current?.querySelector<HTMLElement>('[data-autofocus]') ??
|
|
88
|
+
box.current?.querySelector<HTMLElement>('input:not([disabled]),select:not([disabled]),[role="radio"],button.btn:not([disabled])');
|
|
89
|
+
first?.focus();
|
|
90
|
+
const onKey = (event: KeyboardEvent) => {
|
|
91
|
+
if (event.key === 'Escape') {
|
|
92
|
+
// Closes the dialog only: the editor's Esc (up a level) and the picker's don't run.
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
event.stopImmediatePropagation();
|
|
95
|
+
closeDialog(editor);
|
|
96
|
+
} else if (event.key === 'Tab' && box.current) {
|
|
97
|
+
// Keep focus inside the dialog.
|
|
98
|
+
const items = [...box.current.querySelectorAll<HTMLElement>('input:not([disabled]),select:not([disabled]),button:not([disabled])')];
|
|
99
|
+
const at = items.indexOf(document.activeElement as HTMLElement);
|
|
100
|
+
if (event.shiftKey && at <= 0) {
|
|
101
|
+
event.preventDefault();
|
|
102
|
+
items.at(-1)?.focus();
|
|
103
|
+
} else if (!event.shiftKey && at === items.length - 1) {
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
items[0]?.focus();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
window.addEventListener('keydown', onKey, true);
|
|
110
|
+
return () => {
|
|
111
|
+
window.removeEventListener('keydown', onKey, true);
|
|
112
|
+
if (before?.isConnected) before.focus({ preventScroll: true });
|
|
113
|
+
};
|
|
114
|
+
}, [editor]);
|
|
115
|
+
return (
|
|
116
|
+
<div className="scrim" onMouseDown={(e) => e.target === e.currentTarget && closeDialog(editor)}>
|
|
117
|
+
<div ref={box} className="dialog" role={props.alert ? 'alertdialog' : 'dialog'} aria-modal="true" aria-labelledby="dlg-h" aria-describedby={props.describedBy} data-dialog={props.label}>
|
|
118
|
+
{props.children}
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function Err({ id, text }: { id: string; text: string | null }) {
|
|
125
|
+
if (!text) return null;
|
|
126
|
+
return (
|
|
127
|
+
<p className="field-err" id={id} role="alert">
|
|
128
|
+
<Icon name="warn" size="sm" />
|
|
129
|
+
<span>{text}</span>
|
|
130
|
+
</p>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── New page, + New ──────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
const NEW_GROUP = '__new';
|
|
137
|
+
|
|
138
|
+
function NewDocument(props: { collection: string; inside?: string; at?: string }) {
|
|
139
|
+
const editor = useEditor();
|
|
140
|
+
const documents = useStore(editor.documents);
|
|
141
|
+
const collection = editor.site.collections.find((c) => c.name === props.collection);
|
|
142
|
+
const at = props.at ?? null;
|
|
143
|
+
const atLabel = at ? (insideOptions(collection ?? { name: props.collection, urlBase: '/' }, documents).find((o) => o.path === at)?.label.split(' / ').pop() ?? at) : null;
|
|
144
|
+
const [name, setName] = useState(atLabel ?? '');
|
|
145
|
+
const [inside, setInside] = useState(props.inside ?? '');
|
|
146
|
+
const [group, setGroup] = useState('');
|
|
147
|
+
/** The address's last part as typed by hand (null: it follows the name). */
|
|
148
|
+
const [part, setPart] = useState<string | null>(null);
|
|
149
|
+
const [error, setError] = useState<string | null>(null);
|
|
150
|
+
const [busy, setBusy] = useState(false);
|
|
151
|
+
const options = useMemo(() => (collection?.nested ? insideOptions(collection, documents) : []), [collection, documents]);
|
|
152
|
+
if (!collection) return null;
|
|
153
|
+
const nested = collection.nested;
|
|
154
|
+
const what = singular(collection);
|
|
155
|
+
// M6c (G1): a collection with no pages of its own makes a file from the name, and shows no address.
|
|
156
|
+
const pageless = collection.urlBase === null;
|
|
157
|
+
const newGroup = nested && !at && inside === NEW_GROUP;
|
|
158
|
+
const folder = at ? '' : nested ? (newGroup ? slugify(group) : inside) : '';
|
|
159
|
+
const last = at ? at : slugify(part ?? name);
|
|
160
|
+
const path = at ? at : folder ? `${folder}/${last}` : last;
|
|
161
|
+
const url = addressFor(collection, path);
|
|
162
|
+
const base = addressFor(collection, folder ? `${folder}/` : '') ?? '/';
|
|
163
|
+
// What's wrong before asking the server (the same rules it applies).
|
|
164
|
+
const local = (() => {
|
|
165
|
+
if (!name.trim()) return null;
|
|
166
|
+
if (newGroup && !slugify(group)) return group.trim() ? NO_ADDRESS_MESSAGE : null;
|
|
167
|
+
if (!last) return NO_ADDRESS_MESSAGE;
|
|
168
|
+
if (path.split('/').includes(RESERVED_SLUG)) return INDEX_MESSAGE;
|
|
169
|
+
const existing = documents.find((d) => d.collection === collection.name && d.path === path);
|
|
170
|
+
if (existing) return url ? `There's already a ${what} at ${url}` : `There's already a ${what} called “${existing.label}”`;
|
|
171
|
+
return url ? (addressProblem(url, collection.name, editor.site.collections, documents, editor.site.codeRoutes)?.message ?? null) : null;
|
|
172
|
+
})();
|
|
173
|
+
const shown = error ?? local;
|
|
174
|
+
const blocked = !name.trim() || !last || (newGroup && !slugify(group)) || !!local || busy;
|
|
175
|
+
const create = async () => {
|
|
176
|
+
if (blocked) return;
|
|
177
|
+
setBusy(true);
|
|
178
|
+
setError(null);
|
|
179
|
+
const answer = await createDocument(editor, { collection: collection.name, path, title: name.trim() });
|
|
180
|
+
setBusy(false);
|
|
181
|
+
if (answer.ok) closeDialog(editor);
|
|
182
|
+
else setError(answer.error);
|
|
183
|
+
};
|
|
184
|
+
const kids = at ? pagesUnder(collection.name, at, documents) : [];
|
|
185
|
+
return (
|
|
186
|
+
<Frame label="new">
|
|
187
|
+
<form
|
|
188
|
+
onSubmit={(e) => {
|
|
189
|
+
e.preventDefault();
|
|
190
|
+
void create();
|
|
191
|
+
}}
|
|
192
|
+
>
|
|
193
|
+
<h2 id="dlg-h">{at ? `A page at ${url}` : `New ${what}`}</h2>
|
|
194
|
+
{editor.mode === 'live' ? <p>Nobody sees it until the next Publish.</p> : null}
|
|
195
|
+
{at && kids.length ? (
|
|
196
|
+
<div className="note" style={{ margin: 'var(--space-4) 0 0' }}>
|
|
197
|
+
<Icon name="folder" />
|
|
198
|
+
<div>
|
|
199
|
+
<b>{atLabel} becomes a page</b>
|
|
200
|
+
<p>
|
|
201
|
+
{andList(kids.map((k) => k.label))} stay{kids.length === 1 ? 's' : ''} under it, at the same address{kids.length === 1 ? '' : 'es'}.
|
|
202
|
+
</p>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
) : null}
|
|
206
|
+
{nested && !at ? (
|
|
207
|
+
<div className="field">
|
|
208
|
+
<label className="field-label" htmlFor="np-inside">
|
|
209
|
+
Inside
|
|
210
|
+
</label>
|
|
211
|
+
<select className="input" id="np-inside" value={inside} onChange={(e) => (setInside(e.target.value), setError(null))}>
|
|
212
|
+
{options.map((o) => (
|
|
213
|
+
<option key={o.path} value={o.path}>
|
|
214
|
+
{o.label}
|
|
215
|
+
{o.path ? ` (${o.base}…)` : ''}
|
|
216
|
+
</option>
|
|
217
|
+
))}
|
|
218
|
+
<option disabled>──────────</option>
|
|
219
|
+
<option value={NEW_GROUP}>New group…</option>
|
|
220
|
+
</select>
|
|
221
|
+
</div>
|
|
222
|
+
) : null}
|
|
223
|
+
{newGroup ? (
|
|
224
|
+
<div className="field">
|
|
225
|
+
<label className="field-label" htmlFor="np-group">
|
|
226
|
+
Group name
|
|
227
|
+
</label>
|
|
228
|
+
<input className="input" id="np-group" value={group} placeholder="e.g. Workshops" autoComplete="off" onChange={(e) => (setGroup(e.target.value), setError(null))} />
|
|
229
|
+
</div>
|
|
230
|
+
) : null}
|
|
231
|
+
<div className="field">
|
|
232
|
+
<label className="field-label" htmlFor="np-name">
|
|
233
|
+
{newGroup ? 'First page' : 'Name'}
|
|
234
|
+
</label>
|
|
235
|
+
<input
|
|
236
|
+
className="input"
|
|
237
|
+
id="np-name"
|
|
238
|
+
data-autofocus=""
|
|
239
|
+
value={name}
|
|
240
|
+
placeholder={nested ? 'e.g. Packaging' : 'Name'}
|
|
241
|
+
autoComplete="off"
|
|
242
|
+
aria-invalid={shown ? true : undefined}
|
|
243
|
+
aria-describedby={shown ? 'np-err' : undefined}
|
|
244
|
+
onChange={(e) => (setName(e.target.value), setError(null))}
|
|
245
|
+
/>
|
|
246
|
+
</div>
|
|
247
|
+
{pageless ? (
|
|
248
|
+
<Err id="np-err" text={shown} />
|
|
249
|
+
) : (
|
|
250
|
+
<div className="field">
|
|
251
|
+
<p className="urlpre" style={{ marginTop: 0 }} data-address={url ?? ''}>
|
|
252
|
+
<Icon name="page" size="xs" />
|
|
253
|
+
<span>
|
|
254
|
+
Its address:{' '}
|
|
255
|
+
<span className="mono">
|
|
256
|
+
{editor.site.host}
|
|
257
|
+
{at ? <b>{url}</b> : (
|
|
258
|
+
<>
|
|
259
|
+
{base}
|
|
260
|
+
<b>{last || '…'}</b>
|
|
261
|
+
</>
|
|
262
|
+
)}
|
|
263
|
+
</span>
|
|
264
|
+
</span>
|
|
265
|
+
{at || part !== null ? null : (
|
|
266
|
+
<button type="button" className="linkbtn subtle" data-act="edit-address" onClick={() => setPart(last)}>
|
|
267
|
+
Change
|
|
268
|
+
</button>
|
|
269
|
+
)}
|
|
270
|
+
</p>
|
|
271
|
+
{part !== null ? (
|
|
272
|
+
<>
|
|
273
|
+
<label className="sub-label" htmlFor="np-part">
|
|
274
|
+
The address's last part
|
|
275
|
+
</label>
|
|
276
|
+
<input className="input mono" id="np-part" value={part} autoComplete="off" spellCheck={false} onChange={(e) => (setPart(e.target.value), setError(null))} />
|
|
277
|
+
</>
|
|
278
|
+
) : null}
|
|
279
|
+
<Err id="np-err" text={shown} />
|
|
280
|
+
</div>
|
|
281
|
+
)}
|
|
282
|
+
<div className="acts">
|
|
283
|
+
<button type="button" className="btn" onClick={() => closeDialog(editor)}>
|
|
284
|
+
Cancel
|
|
285
|
+
</button>
|
|
286
|
+
<button type="submit" className="btn ink" data-act="create" disabled={blocked}>
|
|
287
|
+
{newGroup ? 'Create group and page' : `Create ${what}`}
|
|
288
|
+
</button>
|
|
289
|
+
</div>
|
|
290
|
+
</form>
|
|
291
|
+
</Frame>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── Change address… (pre-M7 item 8) ─────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* A document's new address: for nested Pages the group it sits in ("Inside", New page's
|
|
299
|
+
* 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. Pages under it stay where they are.
|
|
301
|
+
*/
|
|
302
|
+
function MoveDocument({ docRef }: { docRef: DocRef }) {
|
|
303
|
+
const editor = useEditor();
|
|
304
|
+
const documents = useStore(editor.documents);
|
|
305
|
+
const waiting = useStore(editor.liveState, (st) => st.status?.waiting);
|
|
306
|
+
const doc = documents.find((d) => d.collection === docRef.collection && d.path === docRef.path);
|
|
307
|
+
const collection = editor.site.collections.find((c) => c.name === docRef.collection);
|
|
308
|
+
const slash = docRef.path.lastIndexOf('/');
|
|
309
|
+
const [inside, setInside] = useState(slash === -1 ? '' : docRef.path.slice(0, slash));
|
|
310
|
+
const [group, setGroup] = useState('');
|
|
311
|
+
const [part, setPart] = useState(docRef.path.slice(slash + 1));
|
|
312
|
+
const [error, setError] = useState<string | null>(null);
|
|
313
|
+
const [busy, setBusy] = useState(false);
|
|
314
|
+
const options = useMemo(() => (collection?.nested ? insideOptions(collection, documents) : []), [collection, documents]);
|
|
315
|
+
if (!doc || !collection || !doc.url) return null;
|
|
316
|
+
const nested = collection.nested;
|
|
317
|
+
const newGroup = nested && inside === NEW_GROUP;
|
|
318
|
+
const folder = nested ? (newGroup ? slugify(group) : inside) : '';
|
|
319
|
+
// As in New page: what's typed becomes an address part (`Harbour Roasters` → `harbour-roasters`).
|
|
320
|
+
const last = slugify(part);
|
|
321
|
+
const path = folder ? `${folder}/${last}` : last;
|
|
322
|
+
const url = addressFor(collection, path);
|
|
323
|
+
const base = addressFor(collection, folder ? `${folder}/` : '') ?? '/';
|
|
324
|
+
const same = path === docRef.path;
|
|
325
|
+
const others = documents.filter((d) => !(d.collection === docRef.collection && d.path === docRef.path));
|
|
326
|
+
// New page's rules, before asking the server (it applies the same).
|
|
327
|
+
const local = (() => {
|
|
328
|
+
if (same) return null;
|
|
329
|
+
if (newGroup && !slugify(group)) return group.trim() ? NO_ADDRESS_MESSAGE : null;
|
|
330
|
+
if (!last) return part.trim() ? NO_ADDRESS_MESSAGE : null;
|
|
331
|
+
if (path.split('/').includes(RESERVED_SLUG)) return INDEX_MESSAGE;
|
|
332
|
+
return url ? (addressProblem(url, collection.name, editor.site.collections, others, editor.site.codeRoutes)?.message ?? null) : null;
|
|
333
|
+
})();
|
|
334
|
+
const shown = error ?? local;
|
|
335
|
+
const blocked = same || !last || (newGroup && !slugify(group)) || !!local || busy;
|
|
336
|
+
// Made since the last Publish: nobody has its address yet. Moved since then: its live address is the one it came from.
|
|
337
|
+
const pending = (waiting ?? []).find((w) => w.collection === docRef.collection && w.path === docRef.path && w.change === 'new');
|
|
338
|
+
const liveAddress = pending ? (pending.movedFrom ?? null) : doc.url;
|
|
339
|
+
const kids = nested ? pagesUnder(collection.name, docRef.path, documents) : [];
|
|
340
|
+
const host = editor.site.host;
|
|
341
|
+
const confirm = async () => {
|
|
342
|
+
if (blocked) return;
|
|
343
|
+
setBusy(true);
|
|
344
|
+
setError(null);
|
|
345
|
+
const answer = await moveDocument(editor, docRef, path, doc.label);
|
|
346
|
+
setBusy(false);
|
|
347
|
+
if (answer.ok) closeDialog(editor);
|
|
348
|
+
else setError(answer.error);
|
|
349
|
+
};
|
|
350
|
+
const intro = liveAddress ? `${liveAddress} will redirect here.${editor.mode === 'live' ? ' Goes live at the next Publish.' : ''}` : null;
|
|
351
|
+
return (
|
|
352
|
+
<Frame label="move">
|
|
353
|
+
<form
|
|
354
|
+
onSubmit={(e) => {
|
|
355
|
+
e.preventDefault();
|
|
356
|
+
void confirm();
|
|
357
|
+
}}
|
|
358
|
+
>
|
|
359
|
+
<h2 id="dlg-h">Change {doc.label}’s address</h2>
|
|
360
|
+
{intro ? <p>{intro}</p> : null}
|
|
361
|
+
{nested ? (
|
|
362
|
+
<div className="field">
|
|
363
|
+
<label className="field-label" htmlFor="mv-inside">
|
|
364
|
+
Inside
|
|
365
|
+
</label>
|
|
366
|
+
<select className="input" id="mv-inside" value={inside} onChange={(e) => (setInside(e.target.value), setError(null))}>
|
|
367
|
+
{options.map((o) => (
|
|
368
|
+
<option key={o.path} value={o.path}>
|
|
369
|
+
{o.label}
|
|
370
|
+
{o.path ? ` (${o.base}…)` : ''}
|
|
371
|
+
</option>
|
|
372
|
+
))}
|
|
373
|
+
<option disabled>──────────</option>
|
|
374
|
+
<option value={NEW_GROUP}>New group…</option>
|
|
375
|
+
</select>
|
|
376
|
+
</div>
|
|
377
|
+
) : null}
|
|
378
|
+
{newGroup ? (
|
|
379
|
+
<div className="field">
|
|
380
|
+
<label className="field-label" htmlFor="mv-group">
|
|
381
|
+
Group name
|
|
382
|
+
</label>
|
|
383
|
+
<input className="input" id="mv-group" value={group} placeholder="e.g. Studio" autoComplete="off" onChange={(e) => (setGroup(e.target.value), setError(null))} />
|
|
384
|
+
</div>
|
|
385
|
+
) : null}
|
|
386
|
+
<div className="field">
|
|
387
|
+
<label className="field-label" htmlFor="mv-part">
|
|
388
|
+
The address's last part
|
|
389
|
+
</label>
|
|
390
|
+
<input
|
|
391
|
+
className="input mono"
|
|
392
|
+
id="mv-part"
|
|
393
|
+
data-autofocus=""
|
|
394
|
+
value={part}
|
|
395
|
+
autoComplete="off"
|
|
396
|
+
spellCheck={false}
|
|
397
|
+
aria-invalid={shown ? true : undefined}
|
|
398
|
+
aria-describedby={shown ? 'mv-err' : undefined}
|
|
399
|
+
onChange={(e) => (setPart(e.target.value), setError(null))}
|
|
400
|
+
/>
|
|
401
|
+
<p className="urlpre" data-address={url ?? ''}>
|
|
402
|
+
<Icon name="page" size="xs" />
|
|
403
|
+
<span>
|
|
404
|
+
{same ? 'Its address now: ' : 'Its new address: '}
|
|
405
|
+
<span className="mono">
|
|
406
|
+
{host}
|
|
407
|
+
{base}
|
|
408
|
+
<b>{last || '…'}</b>
|
|
409
|
+
</span>
|
|
410
|
+
</span>
|
|
411
|
+
</p>
|
|
412
|
+
<Err id="mv-err" text={shown} />
|
|
413
|
+
{same ? null : (
|
|
414
|
+
<p className="field-help" data-from={doc.url}>
|
|
415
|
+
Now at <span className="mono">{`${host}${doc.url}`}</span>
|
|
416
|
+
</p>
|
|
417
|
+
)}
|
|
418
|
+
</div>
|
|
419
|
+
{kids.length ? (
|
|
420
|
+
<p className="field-help" data-kids={kids.length}>
|
|
421
|
+
{andList(kids.map((k) => k.label))} stay{kids.length === 1 ? 's' : ''} where {kids.length === 1 ? 'it is' : 'they are'}, at {doc.url}/…
|
|
422
|
+
</p>
|
|
423
|
+
) : null}
|
|
424
|
+
<div className="acts">
|
|
425
|
+
<button type="button" className="btn" onClick={() => closeDialog(editor)}>
|
|
426
|
+
Cancel
|
|
427
|
+
</button>
|
|
428
|
+
<button type="submit" className="btn ink" data-act="confirm-move" disabled={blocked}>
|
|
429
|
+
Change address
|
|
430
|
+
</button>
|
|
431
|
+
</div>
|
|
432
|
+
</form>
|
|
433
|
+
</Frame>
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ── + New shared block ───────────────────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
function NewShared() {
|
|
440
|
+
const editor = useEditor();
|
|
441
|
+
const documents = useStore(editor.documents);
|
|
442
|
+
const types = Object.values(editor.site.blocks);
|
|
443
|
+
const [type, setType] = useState<string | null>(null);
|
|
444
|
+
const [name, setName] = useState('');
|
|
445
|
+
const [error, setError] = useState<string | null>(null);
|
|
446
|
+
const [busy, setBusy] = useState(false);
|
|
447
|
+
const path = slugify(name);
|
|
448
|
+
const local = !name.trim()
|
|
449
|
+
? null
|
|
450
|
+
: !path
|
|
451
|
+
? NO_ADDRESS_MESSAGE
|
|
452
|
+
: path === RESERVED_SLUG
|
|
453
|
+
? INDEX_MESSAGE
|
|
454
|
+
: documents.some((d) => d.collection === 'shared' && d.path === path)
|
|
455
|
+
? `There's already a shared block called “${documents.find((d) => d.collection === 'shared' && d.path === path)!.label}”.`
|
|
456
|
+
: null;
|
|
457
|
+
const shown = error ?? local;
|
|
458
|
+
const blocked = !type || !name.trim() || !!local || busy;
|
|
459
|
+
const create = async () => {
|
|
460
|
+
if (blocked) return;
|
|
461
|
+
setBusy(true);
|
|
462
|
+
setError(null);
|
|
463
|
+
const answer = await createDocument(editor, { collection: 'shared', path, title: name.trim(), blockType: type! });
|
|
464
|
+
setBusy(false);
|
|
465
|
+
if (answer.ok) closeDialog(editor);
|
|
466
|
+
else setError(answer.error);
|
|
467
|
+
};
|
|
468
|
+
return (
|
|
469
|
+
<Frame label="new-shared">
|
|
470
|
+
<form
|
|
471
|
+
onSubmit={(e) => {
|
|
472
|
+
e.preventDefault();
|
|
473
|
+
void create();
|
|
474
|
+
}}
|
|
475
|
+
>
|
|
476
|
+
<h2 id="dlg-h">New shared block</h2>
|
|
477
|
+
<div className="field">
|
|
478
|
+
<span className="field-label" id="ns-t">
|
|
479
|
+
Block type
|
|
480
|
+
</span>
|
|
481
|
+
<div className="tgrid" role="radiogroup" aria-labelledby="ns-t" style={{ marginTop: 'var(--space-1-5)' }}>
|
|
482
|
+
{types.map((t) => (
|
|
483
|
+
<button key={t.type} type="button" className="ptype sm" role="radio" aria-checked={type === t.type} data-type={t.type} onClick={() => (setType(t.type), setError(null))}>
|
|
484
|
+
<Icon name={blockIcon({ type: t.type, known: true })} />
|
|
485
|
+
<b>{t.label}</b>
|
|
486
|
+
</button>
|
|
487
|
+
))}
|
|
488
|
+
</div>
|
|
489
|
+
</div>
|
|
490
|
+
<div className="field">
|
|
491
|
+
<label className="field-label" htmlFor="ns-name">
|
|
492
|
+
Name
|
|
493
|
+
</label>
|
|
494
|
+
<input
|
|
495
|
+
className="input"
|
|
496
|
+
id="ns-name"
|
|
497
|
+
value={name}
|
|
498
|
+
placeholder="e.g. Client quote"
|
|
499
|
+
autoComplete="off"
|
|
500
|
+
aria-invalid={shown ? true : undefined}
|
|
501
|
+
aria-describedby={shown ? 'ns-err' : undefined}
|
|
502
|
+
onChange={(e) => (setName(e.target.value), setError(null))}
|
|
503
|
+
/>
|
|
504
|
+
<p className="field-help">Visitors don't see it.</p>
|
|
505
|
+
<Err id="ns-err" text={shown} />
|
|
506
|
+
</div>
|
|
507
|
+
<div className="acts">
|
|
508
|
+
<button type="button" className="btn" onClick={() => closeDialog(editor)}>
|
|
509
|
+
Cancel
|
|
510
|
+
</button>
|
|
511
|
+
<button type="submit" className="btn ink" data-act="create-shared" disabled={blocked}>
|
|
512
|
+
Create shared block
|
|
513
|
+
</button>
|
|
514
|
+
</div>
|
|
515
|
+
</form>
|
|
516
|
+
</Frame>
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ── Delete ───────────────────────────────────────────────────────────────────
|
|
521
|
+
|
|
522
|
+
function DeleteDocument({ docRef }: { docRef: DocRef }) {
|
|
523
|
+
const editor = useEditor();
|
|
524
|
+
const documents = useStore(editor.documents);
|
|
525
|
+
const [error, setError] = useState<string | null>(null);
|
|
526
|
+
const [busy, setBusy] = useState(false);
|
|
527
|
+
const doc = documents.find((d) => d.collection === docRef.collection && d.path === docRef.path);
|
|
528
|
+
const collection = editor.site.collections.find((c) => c.name === docRef.collection);
|
|
529
|
+
if (!doc || !collection) return null;
|
|
530
|
+
const shared = collection.kind === 'shared';
|
|
531
|
+
const kids = collection.nested ? pagesUnder(collection.name, doc.path, documents) : [];
|
|
532
|
+
const host = editor.site.host;
|
|
533
|
+
const url = doc.url ?? '';
|
|
534
|
+
const confirm = async () => {
|
|
535
|
+
setBusy(true);
|
|
536
|
+
setError(null);
|
|
537
|
+
const answer = await deleteDocument(editor, docRef, doc.label);
|
|
538
|
+
setBusy(false);
|
|
539
|
+
if (answer.ok) closeDialog(editor);
|
|
540
|
+
else if (answer.code === 'in-use') openDialog(editor, { kind: 'in-use', ref: docRef, usedOn: answer.usedOn ?? undefined });
|
|
541
|
+
else setError(answer.error);
|
|
542
|
+
};
|
|
543
|
+
return (
|
|
544
|
+
<Frame alert label="delete" describedBy="dlg-d">
|
|
545
|
+
<div className="icon-h" style={{ background: 'var(--status-danger-wash)', color: 'var(--status-danger)' }}>
|
|
546
|
+
<Icon name="trash" />
|
|
547
|
+
</div>
|
|
548
|
+
<h2 id="dlg-h">Delete {doc.label}?</h2>
|
|
549
|
+
<p id="dlg-d">
|
|
550
|
+
{shared ? (
|
|
551
|
+
"It isn't on any page, so nothing on the site changes."
|
|
552
|
+
) : collection.urlBase === null ? (
|
|
553
|
+
// M6c (G1): no page of its own, so no address stops working; pages showing it drop it.
|
|
554
|
+
editor.mode === 'live' ? (
|
|
555
|
+
`${doc.label} disappears from the pages that show it at the next Publish.`
|
|
556
|
+
) : (
|
|
557
|
+
`${doc.label} is removed, and disappears from the pages that show it.`
|
|
558
|
+
)
|
|
559
|
+
) : editor.mode === 'live' ? (
|
|
560
|
+
<>
|
|
561
|
+
<span className="mono">{`${host}${url === '/' ? '' : url}`}</span> stops working at the next Publish.
|
|
562
|
+
</>
|
|
563
|
+
) : (
|
|
564
|
+
<>
|
|
565
|
+
{doc.label} is removed, and <span className="mono">{`${host}${url === '/' ? '' : url}`}</span> stops working.
|
|
566
|
+
</>
|
|
567
|
+
)}
|
|
568
|
+
</p>
|
|
569
|
+
{kids.length ? (
|
|
570
|
+
<p data-kids={kids.length}>
|
|
571
|
+
Its {kids.length} page{kids.length === 1 ? '' : 's'} stay{kids.length === 1 ? 's' : ''}, in a {doc.label} group: {andList(kids.map((k) => k.label))} keep
|
|
572
|
+
{kids.length === 1 ? 's its address' : ' their addresses'}.
|
|
573
|
+
</p>
|
|
574
|
+
) : null}
|
|
575
|
+
<Err id="del-err" text={error} />
|
|
576
|
+
<div className="acts">
|
|
577
|
+
<button type="button" className="btn" onClick={() => closeDialog(editor)}>
|
|
578
|
+
Cancel
|
|
579
|
+
</button>
|
|
580
|
+
<button type="button" className="btn danger" data-act="confirm-delete" disabled={busy} onClick={() => void confirm()}>
|
|
581
|
+
Delete {shared ? 'shared block' : singular(collection)}
|
|
582
|
+
</button>
|
|
583
|
+
</div>
|
|
584
|
+
</Frame>
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* M6 Decision 9: deleting a submission is permanent (its files, then its message), so it
|
|
590
|
+
* asks first. Done, the sheet closes and the list drops it.
|
|
591
|
+
*/
|
|
592
|
+
function DeleteSubmission({ id, who, files }: { id: string; who: string; files: number }) {
|
|
593
|
+
const editor = useEditor();
|
|
594
|
+
const [error, setError] = useState<string | null>(null);
|
|
595
|
+
const [busy, setBusy] = useState(false);
|
|
596
|
+
const confirm = async () => {
|
|
597
|
+
setBusy(true);
|
|
598
|
+
setError(null);
|
|
599
|
+
const answer = await inboxFor(editor).remove(id);
|
|
600
|
+
// The answer may come after this dialog was left and another message opened (delete one, then
|
|
601
|
+
// straight on to the next): it closes only its own dialog and its own sheet.
|
|
602
|
+
const mine = () => {
|
|
603
|
+
const open = dialogOf(editor).get();
|
|
604
|
+
return open?.kind === 'sub-delete' && open.id === id;
|
|
605
|
+
};
|
|
606
|
+
if (!answer.ok) {
|
|
607
|
+
const error = answer.error ?? "It wasn't deleted. Try again in a moment.";
|
|
608
|
+
if (mine()) {
|
|
609
|
+
setBusy(false);
|
|
610
|
+
setError(error);
|
|
611
|
+
} else editor.toast(`${who}’s message wasn’t deleted. ${error}`, 'warn');
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
if (mine()) closeDialog(editor);
|
|
615
|
+
if (editor.view.get().inbox?.id === id) editor.openInbox(null);
|
|
616
|
+
editor.toast(`Deleted ${who}’s message${files ? ` and its ${filesLabel(files)}` : ''}`, 'trash');
|
|
617
|
+
};
|
|
618
|
+
return (
|
|
619
|
+
<Frame alert label="sub-delete" describedBy="dlg-d">
|
|
620
|
+
<div className="icon-h" style={{ background: 'var(--status-danger-wash)', color: 'var(--status-danger)' }}>
|
|
621
|
+
<Icon name="trash" />
|
|
622
|
+
</div>
|
|
623
|
+
<h2 id="dlg-h">Delete {who}’s message?</h2>
|
|
624
|
+
<p id="dlg-d">
|
|
625
|
+
It’s deleted for good{files ? `, with its ${filesLabel(files)}` : ''}. There’s no undo.
|
|
626
|
+
</p>
|
|
627
|
+
<Err id="del-err" text={error} />
|
|
628
|
+
<div className="acts">
|
|
629
|
+
<button type="button" className="btn" onClick={() => closeDialog(editor)}>
|
|
630
|
+
Cancel
|
|
631
|
+
</button>
|
|
632
|
+
<button type="button" className="btn danger" data-act="sub-delete-confirm" disabled={busy} onClick={() => void confirm()}>
|
|
633
|
+
Delete for good
|
|
634
|
+
</button>
|
|
635
|
+
</div>
|
|
636
|
+
</Frame>
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** A shared block that pages still use can't be deleted (Decision 8): where it's used, each a way there. */
|
|
641
|
+
function InUse({ docRef, usedOn }: { docRef: DocRef; usedOn?: CreateDeleteRefusal['usedOn'] }) {
|
|
642
|
+
const editor = useEditor();
|
|
643
|
+
const documents = useStore(editor.documents);
|
|
644
|
+
const doc = documents.find((d) => d.collection === docRef.collection && d.path === docRef.path);
|
|
645
|
+
const where: ListedDocument[] = (usedOn ?? doc?.usedOn ?? [])
|
|
646
|
+
.map((u) => documents.find((d) => d.collection === u.collection && d.path === u.path))
|
|
647
|
+
.filter((d): d is ListedDocument => !!d);
|
|
648
|
+
const title = doc?.label ?? docRef.path;
|
|
649
|
+
const n = where.length;
|
|
650
|
+
return (
|
|
651
|
+
<Frame alert label="in-use" describedBy="dlg-d">
|
|
652
|
+
<div className="icon-h" style={{ background: 'var(--status-warning-wash)', color: 'var(--status-warning)' }}>
|
|
653
|
+
<Icon name="shared" />
|
|
654
|
+
</div>
|
|
655
|
+
<h2 id="dlg-h">
|
|
656
|
+
{title} is still on {n} page{n === 1 ? '' : 's'}
|
|
657
|
+
</h2>
|
|
658
|
+
<p id="dlg-d">Remove it from these pages first.</p>
|
|
659
|
+
<div className="dlg-list">
|
|
660
|
+
{where.map((d) => (
|
|
661
|
+
<button
|
|
662
|
+
key={`${d.collection}/${d.path}`}
|
|
663
|
+
type="button"
|
|
664
|
+
className="drow"
|
|
665
|
+
data-doc={`${d.collection}/${d.path}`}
|
|
666
|
+
onClick={() => {
|
|
667
|
+
closeDialog(editor);
|
|
668
|
+
void editor.openDocument({ collection: d.collection, path: d.path });
|
|
669
|
+
}}
|
|
670
|
+
>
|
|
671
|
+
<Icon name={docIcon(editor.site.collections.find((c) => c.name === d.collection))} />
|
|
672
|
+
<span className="nm">{d.label}</span>
|
|
673
|
+
<span className="meta">
|
|
674
|
+
<span className="mono">{d.url}</span>
|
|
675
|
+
</span>
|
|
676
|
+
<Icon name="chev-r" size="sm" />
|
|
677
|
+
</button>
|
|
678
|
+
))}
|
|
679
|
+
</div>
|
|
680
|
+
<div className="acts">
|
|
681
|
+
<button type="button" className="btn ink" onClick={() => closeDialog(editor)}>
|
|
682
|
+
OK
|
|
683
|
+
</button>
|
|
684
|
+
</div>
|
|
685
|
+
</Frame>
|
|
686
|
+
);
|
|
687
|
+
}
|