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,642 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List (M5a Decision 5): `list({ role: text(), name: text() })` stores objects,
|
|
3
|
+
* `list(text())` a flat array. The mockup's `.items`: one row per item, one open
|
|
4
|
+
* at a time; a collapsed row sums itself up in a line (the field's `itemLabel`,
|
|
5
|
+
* else its first text field's value; for `list(text())` the string itself; for
|
|
6
|
+
* `list(reference())` the referenced document's title, drawn as the mockup's
|
|
7
|
+
* `.refs`).
|
|
8
|
+
*
|
|
9
|
+
* Rows add, remove (at once, with M2's Undo toast: Decision 14, through the
|
|
10
|
+
* document store) and reorder by drag and ⌥↑/⌥↓, the block list's mechanism
|
|
11
|
+
* (block-list/reorder.ts). An empty list says so.
|
|
12
|
+
*
|
|
13
|
+
* Clicking an item's render in the preview opens its row and focuses its field
|
|
14
|
+
* (Decision 13): the view's picked path `credits.1.name` opens row 1.
|
|
15
|
+
*
|
|
16
|
+
* M7 (Decisions 14–15): an item field that is itself a list sits inside its open
|
|
17
|
+
* outer row (the mockup's `.sublist`: inset, on the sunken surface, with its own
|
|
18
|
+
* label, count, Add and drag hint). Its rows are the same rows, with their own one
|
|
19
|
+
* open at a time, and their drag and keys move only their own list's rows. A picked
|
|
20
|
+
* `menu.1.links.0.label` opens row 1 of `menu`, then row 0 of its `links`.
|
|
21
|
+
*/
|
|
22
|
+
import { useEffect, useRef, useState, type DragEvent, type KeyboardEvent, type ReactNode } from 'react';
|
|
23
|
+
import {
|
|
24
|
+
parseField,
|
|
25
|
+
titleCase,
|
|
26
|
+
type Field,
|
|
27
|
+
type FieldWarning,
|
|
28
|
+
type LinkValue,
|
|
29
|
+
type ListField as ListDescriptor,
|
|
30
|
+
} from '../../../fields.ts';
|
|
31
|
+
import { rowKeyAction, useRowDrag } from '../block-list/reorder.ts';
|
|
32
|
+
import { useEditor, useStore } from '../context.tsx';
|
|
33
|
+
import { Icon } from '../shell/Icons.tsx';
|
|
34
|
+
import { FieldFrame } from './FieldFrame.tsx';
|
|
35
|
+
import { useFieldWarnings, useHost, warningsAt, within } from './host.tsx';
|
|
36
|
+
import { LinkControl } from './LinkField.tsx';
|
|
37
|
+
import { Warnings } from './parts.tsx';
|
|
38
|
+
import { RefPicker, RefRow, useCollection } from './ReferenceField.tsx';
|
|
39
|
+
import { FieldControl } from './FieldControl.tsx';
|
|
40
|
+
import { itemNoun, itemSummary } from './list-summary.ts';
|
|
41
|
+
import { fileOfKey, MediaSlot, ownerOf } from './MediaField.tsx';
|
|
42
|
+
import { clipStill, thumbnailSrc } from '../media/store.ts';
|
|
43
|
+
import { parseMedia } from '../../../media-value.ts';
|
|
44
|
+
|
|
45
|
+
const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
46
|
+
|
|
47
|
+
/** A new, empty item. */
|
|
48
|
+
const blankItem = (list: ListDescriptor): unknown =>
|
|
49
|
+
list.item ? parseField(list.item, undefined) : Object.fromEntries(Object.entries(list.fields ?? {}).map(([k, f]) => [k, parseField(f, undefined)]));
|
|
50
|
+
|
|
51
|
+
/** The index a picked path opens in this list: `credits.1.name` → 1. */
|
|
52
|
+
const pickedIndex = (path: string | null, name: string): number | null => {
|
|
53
|
+
if (!within(path, name) || path === name) return null;
|
|
54
|
+
const part = path!.slice(name.length + 1).split('.')[0]!;
|
|
55
|
+
return /^\d+$/.test(part) ? Number(part) : null;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function ListField(props: { name: string; label: string; field: ListDescriptor; value: unknown }) {
|
|
59
|
+
const { field } = props;
|
|
60
|
+
if (field.item?.kind === 'reference') return <ReferenceList {...props} to={field.item.to} />;
|
|
61
|
+
return <ItemList {...props} />;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The titles of referenced documents, for summaries. */
|
|
65
|
+
function useTitleOf(): (to: string, path: string) => string | null {
|
|
66
|
+
const editor = useEditor();
|
|
67
|
+
const documents = useStore(editor.documents);
|
|
68
|
+
return (to, path) => {
|
|
69
|
+
const doc = documents.find((d) => d.collection === to && d.path === path);
|
|
70
|
+
return doc ? doc.label || doc.title : null;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function useReorder(name: string, items: readonly unknown[], onMoved?: (from: number, to: number) => void) {
|
|
75
|
+
const host = useHost();
|
|
76
|
+
const editor = useEditor();
|
|
77
|
+
const move = (from: number, to: number, announce = true) => {
|
|
78
|
+
if (to < 0 || to >= items.length || to === from) return;
|
|
79
|
+
const next = [...items];
|
|
80
|
+
const [moved] = next.splice(from, 1);
|
|
81
|
+
next.splice(to, 0, moved);
|
|
82
|
+
host.set(name, next);
|
|
83
|
+
onMoved?.(from, to);
|
|
84
|
+
if (announce) editor.announce(`Moved to ${to + 1} of ${items.length}`);
|
|
85
|
+
};
|
|
86
|
+
const drag = useRowDrag({
|
|
87
|
+
// Its own rows only (`>`): an inner list's rows sit inside an open row of this one (M7).
|
|
88
|
+
rowOf: (target) => target.closest?.(`[data-list="${CSS.escape(name)}"] > [data-row]`) as HTMLElement | null,
|
|
89
|
+
keyOf: (row) => row.dataset.row!,
|
|
90
|
+
indexOf: (key) => Number(key),
|
|
91
|
+
onMove: (key, to) => move(Number(key), to),
|
|
92
|
+
});
|
|
93
|
+
return { move, drag };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function ItemList(props: { name: string; label: string; field: ListDescriptor; value: unknown }) {
|
|
97
|
+
const host = useHost();
|
|
98
|
+
const { field } = props;
|
|
99
|
+
const items = Array.isArray(props.value) ? props.value : [];
|
|
100
|
+
const one = itemNoun(props.label);
|
|
101
|
+
const mediaItem = field.item?.kind === 'media';
|
|
102
|
+
return (
|
|
103
|
+
<FieldFrame
|
|
104
|
+
blockKey={host.scope}
|
|
105
|
+
name={props.name}
|
|
106
|
+
label={props.label}
|
|
107
|
+
help={field.help}
|
|
108
|
+
className={mediaItem ? 'media-field' : undefined}
|
|
109
|
+
meta={
|
|
110
|
+
<span className="field-meta">
|
|
111
|
+
{mediaItem
|
|
112
|
+
? `${items.length} ${one}${items.length === 1 ? '' : 's'}${field.min !== undefined || field.max !== undefined ? ` · ${field.min ?? 0} to ${field.max ?? '∞'}` : ''}`
|
|
113
|
+
: items.length}
|
|
114
|
+
</span>
|
|
115
|
+
}
|
|
116
|
+
>
|
|
117
|
+
<Rows {...props} />
|
|
118
|
+
</FieldFrame>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* M7: a list inside an open row of a list (the mockup's `.sublist`): its label and count,
|
|
124
|
+
* its help, then the same rows, Add and drag hint as a list's.
|
|
125
|
+
*/
|
|
126
|
+
function InnerList(props: { name: string; label: string; field: ListDescriptor; value: unknown }) {
|
|
127
|
+
const count = Array.isArray(props.value) ? props.value.length : 0;
|
|
128
|
+
return (
|
|
129
|
+
<div className="sublist" data-sublist={props.name}>
|
|
130
|
+
<div className="sublist-h">
|
|
131
|
+
<span className="sub-label">{props.label}</span>
|
|
132
|
+
<span className="field-meta">{count}</span>
|
|
133
|
+
</div>
|
|
134
|
+
<SubHelp help={props.field.help} />
|
|
135
|
+
<Rows {...props} />
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A list's rows, its own warnings, and its Add and drag hint. */
|
|
141
|
+
function Rows(props: { name: string; label: string; field: ListDescriptor; value: unknown }) {
|
|
142
|
+
const host = useHost();
|
|
143
|
+
const editor = useEditor();
|
|
144
|
+
const { name, field } = props;
|
|
145
|
+
const items = Array.isArray(props.value) ? props.value : [];
|
|
146
|
+
const one = itemNoun(props.label);
|
|
147
|
+
const titleOf = useTitleOf();
|
|
148
|
+
const warnings = useFieldWarnings(name, field, props.value);
|
|
149
|
+
const [open, setOpen] = useState<number | null>(null);
|
|
150
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
151
|
+
// `:scope >`: this list's own rows, never an inner list's (M7).
|
|
152
|
+
const focusRow = (i: number) => requestAnimationFrame(() => listRef.current?.querySelector<HTMLElement>(`:scope > [data-row="${i}"] > .item-h > .item-sum`)?.focus());
|
|
153
|
+
|
|
154
|
+
// A path picked from the page (or the sidebar) inside an item opens that item.
|
|
155
|
+
const picked = useStore(editor.view, (v) => host.picked(v));
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
const i = pickedIndex(picked, name);
|
|
158
|
+
if (i !== null && i < items.length) setOpen(i);
|
|
159
|
+
}, [picked, name]);
|
|
160
|
+
|
|
161
|
+
const { move, drag } = useReorder(name, items, (from, to) => {
|
|
162
|
+
setOpen((o) => (o === from ? to : o === to ? from : o));
|
|
163
|
+
});
|
|
164
|
+
const remove = (i: number) => {
|
|
165
|
+
const { main } = itemSummary(field, items[i], titleOf);
|
|
166
|
+
setOpen((o) => (o === null || o < i ? o : o === i ? null : o - 1));
|
|
167
|
+
host.removeItem(name, i, `Removed ${one}: ${main || 'empty'}`);
|
|
168
|
+
};
|
|
169
|
+
// A list of media (the gallery): Add opens the library as a picker that takes several, each added as a row (M5b Decision 7).
|
|
170
|
+
const mediaItem = field.item?.kind === 'media' ? field.item : null;
|
|
171
|
+
const add = () => {
|
|
172
|
+
if (mediaItem) {
|
|
173
|
+
if (host.readOnly) return;
|
|
174
|
+
const { owner, back } = ownerOf(editor, host.scope);
|
|
175
|
+
editor.openMedia({
|
|
176
|
+
pick: { scope: host.scope, path: `${name}.${items.length}`, only: mediaItem.only, play: mediaItem.play, label: `${owner} · ${props.label}, ${one} ${items.length + 1}`, owner, multi: true, back },
|
|
177
|
+
tab: mediaItem.only === 'video' ? 'clips' : 'images',
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
host.set(`${name}.${items.length}`, blankItem(field));
|
|
182
|
+
setOpen(items.length);
|
|
183
|
+
requestAnimationFrame(() => listRef.current?.querySelector<HTMLElement>(`:scope > [data-row="${items.length}"] > .item-b :is(input,textarea,select,[contenteditable="true"])`)?.focus());
|
|
184
|
+
};
|
|
185
|
+
const onKey = (event: KeyboardEvent, i: number) => {
|
|
186
|
+
if (host.readOnly) return;
|
|
187
|
+
const action = rowKeyAction(event, i, items.length);
|
|
188
|
+
if (action?.kind === 'move') {
|
|
189
|
+
move(i, action.to);
|
|
190
|
+
focusRow(action.to);
|
|
191
|
+
} else if (action?.kind === 'remove') {
|
|
192
|
+
remove(i);
|
|
193
|
+
focusRow(Math.min(i, items.length - 2));
|
|
194
|
+
} else if (action?.kind === 'focus') focusRow(action.to);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<>
|
|
199
|
+
{items.length === 0 ? (
|
|
200
|
+
<div className="list-empty">No {props.label.toLowerCase()} yet.</div>
|
|
201
|
+
) : (
|
|
202
|
+
<div className="items" ref={listRef} data-list={name} onDragOver={drag.over} onDrop={drag.drop}>
|
|
203
|
+
{items.map((item, i) => {
|
|
204
|
+
const isOpen = open === i;
|
|
205
|
+
const own = warningsAt(warnings, String(i));
|
|
206
|
+
const summary = itemSummary(field, item, titleOf);
|
|
207
|
+
const { main } = summary;
|
|
208
|
+
// A link to a page that's gone says so in its row (the mockup's #link-missing).
|
|
209
|
+
// M6c G4: a link to a file that isn't in the library, likewise.
|
|
210
|
+
const gone = own.find((w) => w.code === 'link-missing');
|
|
211
|
+
const more = gone ? (gone.message.startsWith("There's no file") ? "a file that isn't in the library" : "a page that's gone") : summary.more;
|
|
212
|
+
return (
|
|
213
|
+
<div
|
|
214
|
+
key={i}
|
|
215
|
+
className={['item', isOpen ? 'is-open' : '', own.length && !isOpen ? 'is-warn' : '', drag.classes(String(i))].filter(Boolean).join(' ')}
|
|
216
|
+
data-row={i}
|
|
217
|
+
draggable={!host.readOnly && !isOpen}
|
|
218
|
+
onDragStart={(e) => {
|
|
219
|
+
// An inner list's row (M7): its drag is its own list's, not the open outer row's.
|
|
220
|
+
e.stopPropagation();
|
|
221
|
+
drag.start(e, String(i));
|
|
222
|
+
}}
|
|
223
|
+
onDragEnd={(e) => {
|
|
224
|
+
e.stopPropagation();
|
|
225
|
+
drag.cancel();
|
|
226
|
+
}}
|
|
227
|
+
>
|
|
228
|
+
<div className="item-h">
|
|
229
|
+
<span className="grip" aria-hidden="true">
|
|
230
|
+
{host.readOnly ? null : <Icon name="grip" />}
|
|
231
|
+
</span>
|
|
232
|
+
<button
|
|
233
|
+
type="button"
|
|
234
|
+
className="item-sum"
|
|
235
|
+
aria-expanded={isOpen}
|
|
236
|
+
aria-label={`${main || `New ${one}`}, ${i + 1} of ${items.length}. Alt and arrow keys move it`}
|
|
237
|
+
onClick={() => {
|
|
238
|
+
setOpen(isOpen ? null : i);
|
|
239
|
+
// A gallery's open row picks its own image, so the page rings that one (the mockup's #gallery-row).
|
|
240
|
+
if (mediaItem) host.pick(isOpen ? name : `${name}.${i}`);
|
|
241
|
+
}}
|
|
242
|
+
onKeyDown={(e) => onKey(e, i)}
|
|
243
|
+
>
|
|
244
|
+
{mediaItem ? <MediaMini item={item} /> : null}
|
|
245
|
+
{own.length ? (
|
|
246
|
+
<span className="wmark">
|
|
247
|
+
<Icon name="warn" size="xs" />
|
|
248
|
+
</span>
|
|
249
|
+
) : null}
|
|
250
|
+
{mediaItem ? (
|
|
251
|
+
<MediaRowSummary item={item} />
|
|
252
|
+
) : (
|
|
253
|
+
<span className="s">
|
|
254
|
+
{main ? main : <em>{field.item ? 'Empty' : `New ${one}`}</em>}
|
|
255
|
+
{more ? <span> · {more}</span> : null}
|
|
256
|
+
</span>
|
|
257
|
+
)}
|
|
258
|
+
<Icon name="chev-r" size="xs" />
|
|
259
|
+
</button>
|
|
260
|
+
{host.readOnly ? null : <ItemMenu name={name} index={i} count={items.length} onMove={(to) => { move(i, to); focusRow(to); }} onRemove={() => remove(i)} label={main || `this ${one}`} />}
|
|
261
|
+
</div>
|
|
262
|
+
{isOpen ? (
|
|
263
|
+
<div className="item-b">
|
|
264
|
+
<ItemBody list={field} name={name} index={i} item={item} warnings={own} />
|
|
265
|
+
</div>
|
|
266
|
+
) : null}
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
})}
|
|
270
|
+
</div>
|
|
271
|
+
)}
|
|
272
|
+
<Warnings warnings={warnings.filter((w) => w.path === '')} />
|
|
273
|
+
<div className="list-foot">
|
|
274
|
+
<button type="button" className="linkbtn subtle" onClick={add}>
|
|
275
|
+
<Icon name="plus" size="xs" />
|
|
276
|
+
Add {one}
|
|
277
|
+
</button>
|
|
278
|
+
</div>
|
|
279
|
+
</>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The row menu: Move up, Move down, Remove, with their keys (as the block list's). */
|
|
284
|
+
function ItemMenu(props: { name: string; index: number; count: number; label: string; onMove: (to: number) => void; onRemove: () => void }) {
|
|
285
|
+
const [open, setOpen] = useState(false);
|
|
286
|
+
const menu = useRef<HTMLDivElement>(null);
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
if (!open) return;
|
|
289
|
+
menu.current?.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
|
|
290
|
+
const onDown = (event: MouseEvent) => {
|
|
291
|
+
if (!(event.target as Element).closest?.('.menu, [data-item-more]')) setOpen(false);
|
|
292
|
+
};
|
|
293
|
+
document.addEventListener('mousedown', onDown);
|
|
294
|
+
return () => document.removeEventListener('mousedown', onDown);
|
|
295
|
+
}, [open]);
|
|
296
|
+
const onKey = (event: KeyboardEvent) => {
|
|
297
|
+
if (event.key === 'Escape') {
|
|
298
|
+
event.preventDefault();
|
|
299
|
+
setOpen(false);
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
const { index, count } = props;
|
|
303
|
+
return (
|
|
304
|
+
<>
|
|
305
|
+
<button type="button" className="ib" data-item-more="" aria-label={`Actions for ${props.label}`} aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(!open)}>
|
|
306
|
+
<Icon name="more" />
|
|
307
|
+
</button>
|
|
308
|
+
{open ? (
|
|
309
|
+
<div ref={menu} className="menu" role="menu" onKeyDown={onKey}>
|
|
310
|
+
<button type="button" role="menuitem" disabled={index === 0} onClick={() => (setOpen(false), props.onMove(index - 1))}>
|
|
311
|
+
<Icon name="up" />
|
|
312
|
+
Move up
|
|
313
|
+
<span className="k">
|
|
314
|
+
<kbd>⌥</kbd>
|
|
315
|
+
<kbd>↑</kbd>
|
|
316
|
+
</span>
|
|
317
|
+
</button>
|
|
318
|
+
<button type="button" role="menuitem" disabled={index === count - 1} onClick={() => (setOpen(false), props.onMove(index + 1))}>
|
|
319
|
+
<Icon name="down" />
|
|
320
|
+
Move down
|
|
321
|
+
<span className="k">
|
|
322
|
+
<kbd>⌥</kbd>
|
|
323
|
+
<kbd>↓</kbd>
|
|
324
|
+
</span>
|
|
325
|
+
</button>
|
|
326
|
+
<hr />
|
|
327
|
+
<button type="button" role="menuitem" className="danger" onClick={() => (setOpen(false), props.onRemove())}>
|
|
328
|
+
<Icon name="trash" />
|
|
329
|
+
Remove
|
|
330
|
+
<span className="k">
|
|
331
|
+
<kbd>⌫</kbd>
|
|
332
|
+
</span>
|
|
333
|
+
</button>
|
|
334
|
+
</div>
|
|
335
|
+
) : null}
|
|
336
|
+
</>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** An open item's fields: simple ones as a sub-label and a control (the mockup's subField), others as their full field. */
|
|
341
|
+
function ItemBody(props: { list: ListDescriptor; name: string; index: number; item: unknown; warnings: readonly FieldWarning[] }) {
|
|
342
|
+
const { list, name, index } = props;
|
|
343
|
+
const base = `${name}.${index}`;
|
|
344
|
+
if (list.item) {
|
|
345
|
+
return <SubField field={list.item} path={base} label={titleCase(itemNoun(list.label ?? name))} value={props.item} warnings={props.warnings} rel={String(index)} />;
|
|
346
|
+
}
|
|
347
|
+
const data = isObject(props.item) ? props.item : {};
|
|
348
|
+
const entries = Object.entries(list.fields ?? {});
|
|
349
|
+
const simple = entries.every(([, f]) => f.kind === 'text' && !f.multiline);
|
|
350
|
+
const fields = entries.map(([key, f]) => (
|
|
351
|
+
<div key={key}>
|
|
352
|
+
{/* An inner list of references keeps its own field (the mockup's `.refs`), as at the top level. */}
|
|
353
|
+
{f.kind === 'list' && f.item?.kind !== 'reference' ? (
|
|
354
|
+
<InnerList name={`${base}.${key}`} label={f.label ?? titleCase(key)} field={f} value={data[key]} />
|
|
355
|
+
) : (
|
|
356
|
+
<SubField field={f} path={`${base}.${key}`} label={f.label ?? titleCase(key)} value={data[key]} warnings={warningsAt(props.warnings, `${index}.${key}`)} rel={`${index}.${key}`} />
|
|
357
|
+
)}
|
|
358
|
+
</div>
|
|
359
|
+
));
|
|
360
|
+
// Two short text fields sit side by side, as the mockup's credits do.
|
|
361
|
+
return simple && entries.length === 2 ? <div className="two">{fields}</div> : <>{fields}</>;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** M6c (G8): an item field's help, under its sub-label. */
|
|
365
|
+
function SubHelp({ help }: { help: string | undefined }) {
|
|
366
|
+
return help ? (
|
|
367
|
+
<p className="field-help" data-help="">
|
|
368
|
+
{help}
|
|
369
|
+
</p>
|
|
370
|
+
) : null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** One field inside a list item. `path` is from the host's root; `rel` is the warnings' path relative to the list. */
|
|
374
|
+
function SubField(props: { field: Field; path: string; label: string; value: unknown; warnings: readonly FieldWarning[]; rel: string }) {
|
|
375
|
+
const host = useHost();
|
|
376
|
+
const { field, path, value } = props;
|
|
377
|
+
const id = `f-${host.scope}-${path}`.replace(/\./g, '-');
|
|
378
|
+
const set = (v: unknown) => host.set(path, v);
|
|
379
|
+
const messages = (ws: readonly FieldWarning[]) => <Warnings warnings={ws} />;
|
|
380
|
+
let control: ReactNode;
|
|
381
|
+
switch (field.kind) {
|
|
382
|
+
case 'text':
|
|
383
|
+
control = field.multiline ? (
|
|
384
|
+
<textarea className="input" id={id} rows={3} data-path={path} value={typeof value === 'string' ? value : ''} spellCheck={false} onChange={(e) => set(e.target.value)} />
|
|
385
|
+
) : (
|
|
386
|
+
<input className="input" id={id} data-path={path} value={typeof value === 'string' ? value : ''} autoComplete="off" spellCheck={false} onChange={(e) => set(e.target.value.replace(/\n+/g, ' '))} />
|
|
387
|
+
);
|
|
388
|
+
break;
|
|
389
|
+
case 'select':
|
|
390
|
+
control = (
|
|
391
|
+
<select className="input" id={id} data-path={path} value={typeof value === 'string' ? value : field.default} onChange={(e) => set(e.target.value)}>
|
|
392
|
+
{field.options.map((o) => (
|
|
393
|
+
<option key={o.value} value={o.value}>
|
|
394
|
+
{o.label}
|
|
395
|
+
</option>
|
|
396
|
+
))}
|
|
397
|
+
</select>
|
|
398
|
+
);
|
|
399
|
+
break;
|
|
400
|
+
case 'toggle': {
|
|
401
|
+
const on = typeof value === 'boolean' ? value : field.default;
|
|
402
|
+
control = (
|
|
403
|
+
<button type="button" className="switch" role="switch" id={id} data-path={path} aria-checked={on} onClick={() => set(!on)}>
|
|
404
|
+
<span className="track" />
|
|
405
|
+
<span>{on ? 'On' : 'Off'}</span>
|
|
406
|
+
</button>
|
|
407
|
+
);
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
case 'date':
|
|
411
|
+
control = <input className="input" type="date" id={id} data-path={path} value={typeof value === 'string' ? value : ''} onChange={(e) => set(e.target.value === '' ? null : e.target.value)} />;
|
|
412
|
+
break;
|
|
413
|
+
case 'number':
|
|
414
|
+
control = (
|
|
415
|
+
<input
|
|
416
|
+
className="input"
|
|
417
|
+
type="number"
|
|
418
|
+
id={id}
|
|
419
|
+
data-path={path}
|
|
420
|
+
value={typeof value === 'number' ? value : ''}
|
|
421
|
+
min={field.min}
|
|
422
|
+
max={field.max}
|
|
423
|
+
step={field.step ?? 'any'}
|
|
424
|
+
onChange={(e) => set(e.target.value === '' ? null : Number(e.target.value))}
|
|
425
|
+
/>
|
|
426
|
+
);
|
|
427
|
+
break;
|
|
428
|
+
case 'link': {
|
|
429
|
+
const v = (isObject(value) ? value : { label: '', href: '' }) as unknown as LinkValue;
|
|
430
|
+
const rel = props.rel;
|
|
431
|
+
// G9: an address-only link item is where it goes, under its sub-label.
|
|
432
|
+
if (field.addressOnly) {
|
|
433
|
+
return (
|
|
434
|
+
<>
|
|
435
|
+
<span className="sub-label">{props.label}</span>
|
|
436
|
+
<SubHelp help={field.help} />
|
|
437
|
+
<LinkControl path={`${path}.href`} href={v.href ?? ''} label={props.label} warnings={warningsAt(props.warnings, `${rel}.href`)} onChange={(href) => host.set(`${path}.href`, href)} />
|
|
438
|
+
</>
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
// The mockup's link item: its words, then where it goes, each under its own sub-label.
|
|
442
|
+
return (
|
|
443
|
+
<>
|
|
444
|
+
<label className="sub-label" htmlFor={id}>
|
|
445
|
+
Label
|
|
446
|
+
</label>
|
|
447
|
+
<input
|
|
448
|
+
className="input"
|
|
449
|
+
id={id}
|
|
450
|
+
data-path={`${path}.label`}
|
|
451
|
+
placeholder="Label"
|
|
452
|
+
value={v.label}
|
|
453
|
+
autoComplete="off"
|
|
454
|
+
spellCheck={false}
|
|
455
|
+
onChange={(e) => host.set(`${path}.label`, e.target.value.replace(/\n+/g, ' '))}
|
|
456
|
+
/>
|
|
457
|
+
<span className="sub-label">{props.label}</span>
|
|
458
|
+
<SubHelp help={field.help} />
|
|
459
|
+
<LinkControl path={`${path}.href`} href={v.href} warnings={warningsAt(props.warnings, `${rel}.href`)} onChange={(href) => host.set(`${path}.href`, href)} />
|
|
460
|
+
{messages(props.warnings.filter((w) => w.code === 'half-link'))}
|
|
461
|
+
</>
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
case 'reference':
|
|
465
|
+
return <SubReference label={props.label} help={field.help} to={field.to} path={path} value={value} warnings={props.warnings} />;
|
|
466
|
+
case 'media': {
|
|
467
|
+
// A gallery row's body is the slot itself (the mockup's gallery-row); its Remove removes the row.
|
|
468
|
+
const [list, index] = [path.slice(0, path.lastIndexOf('.')), Number(path.slice(path.lastIndexOf('.') + 1))];
|
|
469
|
+
const rel = (w: FieldWarning) => ({ ...w, path: w.path === props.rel ? '' : w.path.slice(props.rel.length + 1) });
|
|
470
|
+
return (
|
|
471
|
+
<>
|
|
472
|
+
<SubHelp help={field.help} />
|
|
473
|
+
<MediaSlot
|
|
474
|
+
path={path}
|
|
475
|
+
field={field}
|
|
476
|
+
value={value}
|
|
477
|
+
warnings={props.warnings.map(rel)}
|
|
478
|
+
pickLabel={`${props.label} ${index + 1}`}
|
|
479
|
+
row={{ list, index, label: `${props.label.toLowerCase()} ${index + 1}` }}
|
|
480
|
+
/>
|
|
481
|
+
</>
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
default:
|
|
485
|
+
// Rich text, a lockup or buttons inside a list item: their own field, at this path.
|
|
486
|
+
return <FieldControl name={path} label={props.label} field={field} value={value} />;
|
|
487
|
+
}
|
|
488
|
+
return (
|
|
489
|
+
<>
|
|
490
|
+
<label className="sub-label" htmlFor={id}>
|
|
491
|
+
{props.label}
|
|
492
|
+
</label>
|
|
493
|
+
<SubHelp help={field.help} />
|
|
494
|
+
{control}
|
|
495
|
+
{messages(props.warnings)}
|
|
496
|
+
</>
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function SubReference(props: { label: string; help?: string; to: string; path: string; value: unknown; warnings: readonly FieldWarning[] }) {
|
|
501
|
+
const host = useHost();
|
|
502
|
+
const [picking, setPicking] = useState(false);
|
|
503
|
+
const value = typeof props.value === 'string' && props.value !== '' ? props.value : null;
|
|
504
|
+
const { label } = useCollection(props.to);
|
|
505
|
+
return (
|
|
506
|
+
<>
|
|
507
|
+
<span className="sub-label">{props.label}</span>
|
|
508
|
+
<SubHelp help={props.help} />
|
|
509
|
+
{value ? <RefRow to={props.to} path={value} onRemove={() => host.set(props.path, null)} /> : null}
|
|
510
|
+
<Warnings warnings={props.warnings} />
|
|
511
|
+
<div className="lnk" style={{ marginTop: value ? 'var(--space-1-5)' : 0 }}>
|
|
512
|
+
<button type="button" className="linkbtn subtle" data-path={props.path} aria-expanded={picking} onClick={() => setPicking(!picking)}>
|
|
513
|
+
<Icon name="plus" size="xs" />
|
|
514
|
+
{value ? 'Choose another' : `Choose from ${label}`}
|
|
515
|
+
</button>
|
|
516
|
+
{picking ? (
|
|
517
|
+
<RefPicker
|
|
518
|
+
to={props.to}
|
|
519
|
+
taken={value ? [value] : []}
|
|
520
|
+
onPick={(p) => {
|
|
521
|
+
host.set(props.path, p);
|
|
522
|
+
setPicking(false);
|
|
523
|
+
}}
|
|
524
|
+
onClose={() => setPicking(false)}
|
|
525
|
+
/>
|
|
526
|
+
) : null}
|
|
527
|
+
</div>
|
|
528
|
+
</>
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** `list(reference({ to }))`: the mockup's `.refs`, each row the referenced document; a missing one warned. */
|
|
533
|
+
function ReferenceList(props: { name: string; label: string; field: ListDescriptor; value: unknown; to: string }) {
|
|
534
|
+
const host = useHost();
|
|
535
|
+
const { name } = props;
|
|
536
|
+
const refs = (Array.isArray(props.value) ? props.value : []).map((v) => (typeof v === 'string' ? v : ''));
|
|
537
|
+
const one = itemNoun(props.label);
|
|
538
|
+
const titleOf = useTitleOf();
|
|
539
|
+
const warnings = useFieldWarnings(name, props.field, props.value);
|
|
540
|
+
const [picking, setPicking] = useState(false);
|
|
541
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
542
|
+
const { move, drag } = useReorder(name, refs);
|
|
543
|
+
const focusRow = (i: number) => requestAnimationFrame(() => listRef.current?.querySelector<HTMLElement>(`[data-row="${i}"]`)?.focus());
|
|
544
|
+
const remove = (i: number) => host.removeItem(name, i, `Removed ${one}: ${titleOf(props.to, refs[i]!) ?? 'the missing one'}`);
|
|
545
|
+
const onKey = (event: KeyboardEvent, i: number) => {
|
|
546
|
+
if (host.readOnly || event.target !== event.currentTarget) return;
|
|
547
|
+
const action = rowKeyAction(event, i, refs.length);
|
|
548
|
+
if (action?.kind === 'move') {
|
|
549
|
+
move(i, action.to);
|
|
550
|
+
focusRow(action.to);
|
|
551
|
+
} else if (action?.kind === 'remove') {
|
|
552
|
+
remove(i);
|
|
553
|
+
focusRow(Math.min(i, refs.length - 2));
|
|
554
|
+
} else if (action?.kind === 'focus') focusRow(action.to);
|
|
555
|
+
};
|
|
556
|
+
const gone = warnings.filter((w) => w.code === 'missing-reference').length;
|
|
557
|
+
return (
|
|
558
|
+
<FieldFrame blockKey={host.scope} name={name} label={props.label} help={props.field.help} meta={<span className="field-meta">{refs.length}</span>}>
|
|
559
|
+
{refs.length === 0 ? (
|
|
560
|
+
<div className="list-empty">None yet.</div>
|
|
561
|
+
) : (
|
|
562
|
+
<div className="refs" ref={listRef} data-list={name} onDragOver={drag.over} onDrop={drag.drop}>
|
|
563
|
+
{refs.map((path, i) => (
|
|
564
|
+
// The row is the reference itself (the mockup's .refs > .ref), focusable and draggable.
|
|
565
|
+
<RefRow
|
|
566
|
+
key={`${i}-${path}`}
|
|
567
|
+
to={props.to}
|
|
568
|
+
path={path}
|
|
569
|
+
grip={!host.readOnly}
|
|
570
|
+
onRemove={() => remove(i)}
|
|
571
|
+
rowClass={drag.classes(String(i))}
|
|
572
|
+
rowProps={{
|
|
573
|
+
'data-row': i,
|
|
574
|
+
draggable: !host.readOnly,
|
|
575
|
+
tabIndex: 0,
|
|
576
|
+
'data-path': `${name}.${i}`,
|
|
577
|
+
'aria-label': `${titleOf(props.to, path) ?? 'One that’s gone'}, ${i + 1} of ${refs.length}. Alt and arrow keys move it`,
|
|
578
|
+
onDragStart: (e: DragEvent) => drag.start(e, String(i)),
|
|
579
|
+
onDragEnd: drag.cancel,
|
|
580
|
+
onKeyDown: (e: KeyboardEvent) => onKey(e, i),
|
|
581
|
+
}}
|
|
582
|
+
/>
|
|
583
|
+
))}
|
|
584
|
+
</div>
|
|
585
|
+
)}
|
|
586
|
+
{gone ? (
|
|
587
|
+
<Warnings
|
|
588
|
+
warnings={[
|
|
589
|
+
{
|
|
590
|
+
path: '',
|
|
591
|
+
code: 'missing-reference',
|
|
592
|
+
message: gone === 1 ? 'One was deleted and is skipped on the site.' : `${gone} were deleted and are skipped on the site.`,
|
|
593
|
+
},
|
|
594
|
+
]}
|
|
595
|
+
/>
|
|
596
|
+
) : null}
|
|
597
|
+
<Warnings warnings={warnings.filter((w) => w.code !== 'missing-reference')} />
|
|
598
|
+
<div className="lnk" style={{ marginTop: 'var(--space-2)' }}>
|
|
599
|
+
<button type="button" className="linkbtn subtle" aria-expanded={picking} onClick={() => setPicking(!picking)}>
|
|
600
|
+
<Icon name="plus" size="xs" />
|
|
601
|
+
Add {one}
|
|
602
|
+
</button>
|
|
603
|
+
{picking ? (
|
|
604
|
+
<RefPicker
|
|
605
|
+
to={props.to}
|
|
606
|
+
taken={refs}
|
|
607
|
+
onPick={(p) => {
|
|
608
|
+
host.set(`${name}.${refs.length}`, p);
|
|
609
|
+
setPicking(false);
|
|
610
|
+
}}
|
|
611
|
+
onClose={() => setPicking(false)}
|
|
612
|
+
/>
|
|
613
|
+
) : null}
|
|
614
|
+
</div>
|
|
615
|
+
</FieldFrame>
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** A gallery row's picture: the server's signed thumbnail (a clip's still), small, at its focal point. */
|
|
620
|
+
function MediaMini({ item }: { item: unknown }) {
|
|
621
|
+
const v = parseMedia(item);
|
|
622
|
+
if (!v) return null;
|
|
623
|
+
const src = 'key' in v ? thumbnailSrc(v.key, 160) : clipStill(v.video, v.poster, 160);
|
|
624
|
+
const position = 'key' in v && v.focal ? `${Math.round(v.focal[0] * 100)}% ${Math.round(v.focal[1] * 100)}%` : undefined;
|
|
625
|
+
return (
|
|
626
|
+
<span className="mini ph">
|
|
627
|
+
<img className="ph-img" src={src} alt="" style={position ? { objectPosition: position } : undefined} />
|
|
628
|
+
</span>
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** A gallery row's line: its alt text (Decorative, or No alt text), then its file. */
|
|
633
|
+
function MediaRowSummary({ item }: { item: unknown }) {
|
|
634
|
+
const v = parseMedia(item);
|
|
635
|
+
if (!v) return <span className="s"><em>Empty</em></span>;
|
|
636
|
+
if ('video' in v) return <span className="s">{v.name || <em>A clip</em>}</span>;
|
|
637
|
+
return (
|
|
638
|
+
<span className="s">
|
|
639
|
+
{v.decorative ? <em>Decorative</em> : v.alt.trim() ? v.alt : <em>No alt text</em>} <span>· {fileOfKey(v.key)}</span>
|
|
640
|
+
</span>
|
|
641
|
+
);
|
|
642
|
+
}
|