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,550 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The document levels of M5a's new kinds of document, each a form beside its preview
|
|
3
|
+
* (no block list): the document store holds each as one block (document-store.ts →
|
|
4
|
+
* formKey), so every field is mounted through a field host on that key, with Undo,
|
|
5
|
+
* the keeper and the write path as for a block.
|
|
6
|
+
*
|
|
7
|
+
* - **A list-of-fields document** (`FieldsDocumentLevel`; the mockup's #case, Decision
|
|
8
|
+
* 7): its collection stacked above, its kind, New and read-only chips on the head's sub line
|
|
9
|
+
* (the prev/next stepper went in the pre-M7 polish), then `title`
|
|
10
|
+
* (one line, always first), then the collection's fields in schema order. Click to
|
|
11
|
+
* edit from its preview opens a field here (Decision 13, nested paths too).
|
|
12
|
+
* - **A shared block as its own document** (`SharedDocumentLevel`; #shared-doc,
|
|
13
|
+
* Decision 8): where it's used, each a link, then its block's fields. Its preview is
|
|
14
|
+
* the block on its own in the site's page frame.
|
|
15
|
+
* - **Site Settings** (`SettingsLevel`; #settings, #settings-public, #settings-self,
|
|
16
|
+
* Decision 10): who can sign in (the whitelist's rules checked as you type, the
|
|
17
|
+
* agency line, removing yourself asks first), the editor's name and accent, with the
|
|
18
|
+
* logo and the tab icon (M5b Decision 11, #settings-brand: image-only media slots with
|
|
19
|
+
* no alt and no focal point), then the site's own settings.
|
|
20
|
+
* No page of its own: the stage keeps the last page, dimmed (shell/Stage.tsx).
|
|
21
|
+
*
|
|
22
|
+
* M6: the case study's "Search and sharing" group ends its form (fields/SeoGroup.tsx), and
|
|
23
|
+
* Site Settings gains its defaults, the Google tag and Form emails (settings-m6.tsx).
|
|
24
|
+
*/
|
|
25
|
+
import { Fragment, useMemo, useState, type ReactNode } from 'react';
|
|
26
|
+
import { media as mediaField, titleCase, type Field, type Fields } from '../../../../fields.ts';
|
|
27
|
+
import { parseMedia } from '../../../../media-value.ts';
|
|
28
|
+
import { parseAllowEntry } from '../../../../live/auth/allow-rules.ts';
|
|
29
|
+
import { useEditor, useStore } from '../../context.tsx';
|
|
30
|
+
import type { Editor } from '../../editor.ts';
|
|
31
|
+
import { DocMenu } from '../../documents/DocMenu.tsx';
|
|
32
|
+
import { FieldControl } from '../../fields/FieldControl.tsx';
|
|
33
|
+
import { FieldFrame } from '../../fields/FieldFrame.tsx';
|
|
34
|
+
import { FieldHostProvider, parseBase, type FieldHost } from '../../fields/host.tsx';
|
|
35
|
+
import { MediaSlot } from '../../fields/MediaField.tsx';
|
|
36
|
+
import { mediaFor } from '../../media/store.ts';
|
|
37
|
+
import { DocNotices, ReadOnlyChip } from '../../live/notices.tsx';
|
|
38
|
+
import { Icon } from '../../shell/Icons.tsx';
|
|
39
|
+
import { BrandMark } from '../../shell/Brand.tsx';
|
|
40
|
+
import { formKey } from '../../state/document-store.ts';
|
|
41
|
+
import { formFields } from '../../state/schema.ts';
|
|
42
|
+
import { docId, type ListedDocument } from '../../../types.ts';
|
|
43
|
+
import { CurrentHead, DocumentsTab, GlobalNotes, LevelTab, RecentStrip, Trail } from '../parts.tsx';
|
|
44
|
+
import { singular } from '../../documents/actions.ts';
|
|
45
|
+
import { SeoGroup } from '../../fields/SeoGroup.tsx';
|
|
46
|
+
import { SettingsM6 } from './SettingsM6.tsx';
|
|
47
|
+
import { BlockHelp } from './BlockLevel.tsx';
|
|
48
|
+
|
|
49
|
+
/** The form's host: the document's one block, its fields picked at the document level. */
|
|
50
|
+
export function formHost(editor: Editor, key: string, raw: Record<string, unknown> | undefined, readOnly: boolean): FieldHost {
|
|
51
|
+
return {
|
|
52
|
+
scope: key,
|
|
53
|
+
picked: (view) => (view.level === 'document' ? view.field : null),
|
|
54
|
+
pick(path) {
|
|
55
|
+
const view = editor.view.get();
|
|
56
|
+
if (view.field !== path || view.block !== key) editor.view.update({ field: path, picked: false, block: key });
|
|
57
|
+
},
|
|
58
|
+
set: (path, value, options) => editor.doc.setField(key, path, value, options),
|
|
59
|
+
removeItem(path, index, label) {
|
|
60
|
+
editor.doc.removeItem(key, path, index, { label });
|
|
61
|
+
editor.announce(`${label}. Undo with Command Z.`);
|
|
62
|
+
},
|
|
63
|
+
raw: (name) => (raw && Object.hasOwn(raw, name) ? raw[name] : undefined),
|
|
64
|
+
readOnly,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const asObject = (v: unknown): Record<string, unknown> | undefined => (v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined);
|
|
69
|
+
|
|
70
|
+
/** Live mode: a fieldset disabled while the document is read-only (M4 Decision 9). Local mode: no wrapper. */
|
|
71
|
+
function FormFields({ readOnly, children }: { readOnly: boolean; children: ReactNode }) {
|
|
72
|
+
const editor = useEditor();
|
|
73
|
+
if (editor.mode !== 'live') return <div data-form="">{children}</div>;
|
|
74
|
+
return (
|
|
75
|
+
<fieldset className="ro-fields" data-form="" disabled={readOnly} data-read-only={readOnly ? '' : undefined}>
|
|
76
|
+
{children}
|
|
77
|
+
</fieldset>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The "New" chip for a document that isn't on `main` yet (live mode's waiting list says so). */
|
|
82
|
+
function useIsNew(): boolean {
|
|
83
|
+
const editor = useEditor();
|
|
84
|
+
const ref = useStore(editor.doc.state, (s) => s.ref);
|
|
85
|
+
return useStore(editor.liveState, (s) => (s.status?.waiting ?? []).some((w) => w.collection === ref.collection && w.path === ref.path && w.change === 'new' && !w.movedFrom));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The collection's level tab, stacked above a document of it. */
|
|
89
|
+
export function CollectionTab({ name }: { name: string }) {
|
|
90
|
+
const editor = useEditor();
|
|
91
|
+
const collection = editor.site.collections.find((c) => c.name === name);
|
|
92
|
+
const count = useStore(editor.documents, (d) => d.filter((x) => x.collection === name).length);
|
|
93
|
+
if (!collection) return null;
|
|
94
|
+
if (collection.kind === 'shared') {
|
|
95
|
+
return (
|
|
96
|
+
<button type="button" className="lv" onClick={() => editor.openCollection(name)}>
|
|
97
|
+
<b>{collection.label}</b>
|
|
98
|
+
<span className="n">{count}</span>
|
|
99
|
+
</button>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return <LevelTab name={collection.label} kind="Collection" count={String(count)} onClick={() => editor.openCollection(name)} />;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The title field every fields document has first (Decision 7): one line, always there. */
|
|
106
|
+
function TitleField({ scope }: { scope: string }) {
|
|
107
|
+
const editor = useEditor();
|
|
108
|
+
const title = useStore(editor.doc.state, (s) => s.document.title);
|
|
109
|
+
const id = `f-${scope}-title`;
|
|
110
|
+
return (
|
|
111
|
+
<FieldFrame blockKey={scope} name="title" label="Title" htmlFor={id}>
|
|
112
|
+
<input className="input" id={id} data-path="title" value={title} autoComplete="off" spellCheck={false} onChange={(e) => editor.doc.setTitle(e.target.value.replace(/\n+/g, ' '))} />
|
|
113
|
+
</FieldFrame>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function useFormHost(): { host: FieldHost | null; data: Record<string, unknown>; key: string | null; readOnly: boolean; raw: Record<string, unknown> | undefined } {
|
|
118
|
+
const editor = useEditor();
|
|
119
|
+
const session = useStore(editor.doc.state);
|
|
120
|
+
const readOnly = useStore(editor.access, (a) => a.readOnly);
|
|
121
|
+
const key = formKey(session);
|
|
122
|
+
const kind = session.document.kind;
|
|
123
|
+
const base = parseBase(session.base);
|
|
124
|
+
const raw = kind === 'shared' ? asObject(asObject(base)?.block) : asObject(base);
|
|
125
|
+
const host = useMemo(() => (key ? formHost(editor, key, raw, readOnly) : null), [editor, key, raw, readOnly]);
|
|
126
|
+
return { host, data: session.document.blocks[0]?.data ?? {}, key, readOnly, raw };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── A list-of-fields document ────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/** The field kinds small enough to sit two to a row (the mockup's #case: Sector beside Featured). */
|
|
132
|
+
const COMPACT = new Set<Field['kind']>(['select', 'toggle', 'number']);
|
|
133
|
+
|
|
134
|
+
/** The form's rows: each field on its own, except two compact fields in a row, which share one (`.two`). */
|
|
135
|
+
export function formRows(fields: Fields): [string, Field][][] {
|
|
136
|
+
const rows: [string, Field][][] = [];
|
|
137
|
+
const entries = Object.entries(fields);
|
|
138
|
+
for (let i = 0; i < entries.length; i++) {
|
|
139
|
+
const here = entries[i]!;
|
|
140
|
+
const next = entries[i + 1];
|
|
141
|
+
if (next && COMPACT.has(here[1].kind) && COMPACT.has(next[1].kind)) {
|
|
142
|
+
rows.push([here, next]);
|
|
143
|
+
i++;
|
|
144
|
+
} else rows.push([here]);
|
|
145
|
+
}
|
|
146
|
+
return rows;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function FieldsDocumentLevel() {
|
|
150
|
+
const editor = useEditor();
|
|
151
|
+
const ref = useStore(editor.doc.state, (s) => s.ref);
|
|
152
|
+
const entry = useStore(editor.doc.state, (s) => s.entry);
|
|
153
|
+
const title = useStore(editor.doc.state, (s) => s.document.title || s.entry.label);
|
|
154
|
+
const isNew = useIsNew();
|
|
155
|
+
const { host, data, key, readOnly } = useFormHost();
|
|
156
|
+
const collection = editor.site.collections.find((c) => c.name === ref.collection);
|
|
157
|
+
if (!collection || !host || !key) return null;
|
|
158
|
+
const fields: Fields = formFields(editor.site, collection.name);
|
|
159
|
+
const what = singular(collection);
|
|
160
|
+
const chips = (
|
|
161
|
+
<>
|
|
162
|
+
<span className="chip kind">{titleCase(what)}</span>
|
|
163
|
+
{isNew ? <span className="chip waiting">New</span> : null}
|
|
164
|
+
<ReadOnlyChip />
|
|
165
|
+
</>
|
|
166
|
+
);
|
|
167
|
+
const url = entry.url ?? '';
|
|
168
|
+
return (
|
|
169
|
+
<>
|
|
170
|
+
<Trail>
|
|
171
|
+
<DocumentsTab />
|
|
172
|
+
<CollectionTab name={collection.name} />
|
|
173
|
+
<CurrentHead
|
|
174
|
+
back={{ label: `Back to ${collection.label.toLowerCase()}`, onClick: () => editor.openCollection(collection.name) }}
|
|
175
|
+
crumb={collection.label}
|
|
176
|
+
title={title}
|
|
177
|
+
sub={
|
|
178
|
+
<>
|
|
179
|
+
{chips}
|
|
180
|
+
{/* M6c (G1): a document with no page of its own has no address to show. */}
|
|
181
|
+
{collection.urlBase === null ? null : <span className="mono">{`${editor.site.host}${url}`}</span>}
|
|
182
|
+
</>
|
|
183
|
+
}
|
|
184
|
+
menu={<DocMenu label={`${titleCase(what)} actions`} />}
|
|
185
|
+
/>
|
|
186
|
+
</Trail>
|
|
187
|
+
<div className="side-body" data-document={docId(ref)}>
|
|
188
|
+
<GlobalNotes />
|
|
189
|
+
<DocNotices />
|
|
190
|
+
<FormFields readOnly={readOnly}>
|
|
191
|
+
<FieldHostProvider host={host}>
|
|
192
|
+
<TitleField scope={key} />
|
|
193
|
+
{formRows(fields).map((row) => {
|
|
194
|
+
const control = ([name, field]: [string, Field]) => (
|
|
195
|
+
<FieldControl key={`${key}:${name}`} name={name} label={field.label ?? titleCase(name)} field={field} value={data[name]} lockupIndex={0} />
|
|
196
|
+
);
|
|
197
|
+
return row.length === 2 ? (
|
|
198
|
+
<div key={`${key}:${row[0]![0]}+`} className="two">
|
|
199
|
+
{row.map(control)}
|
|
200
|
+
</div>
|
|
201
|
+
) : (
|
|
202
|
+
control(row[0]!)
|
|
203
|
+
);
|
|
204
|
+
})}
|
|
205
|
+
</FieldHostProvider>
|
|
206
|
+
{/* M6 Decision 13: Search and sharing ends a fields document's form. */}
|
|
207
|
+
<SeoGroup />
|
|
208
|
+
</FormFields>
|
|
209
|
+
</div>
|
|
210
|
+
<RecentStrip />
|
|
211
|
+
</>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── A shared block as its own document ───────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
/** "Used on 4 pages" / "Not on any page yet" (the mockup's usedLabel). N counts documents of every collection. */
|
|
218
|
+
export const usedLabel = (n: number) => (n ? `Used on ${n} page${n === 1 ? '' : 's'}` : 'Not on any page yet');
|
|
219
|
+
|
|
220
|
+
/** The documents a shared block is used on, as listed. */
|
|
221
|
+
export function usedOnDocs(documents: readonly ListedDocument[], name: string): ListedDocument[] {
|
|
222
|
+
const shared = documents.find((d) => d.collection === 'shared' && d.path === name);
|
|
223
|
+
return (shared?.usedOn ?? []).map((u) => documents.find((d) => d.collection === u.collection && d.path === u.path)).filter((d): d is ListedDocument => !!d);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function SharedDocumentLevel() {
|
|
227
|
+
const editor = useEditor();
|
|
228
|
+
const ref = useStore(editor.doc.state, (s) => s.ref);
|
|
229
|
+
const title = useStore(editor.doc.state, (s) => s.document.title || s.entry.label);
|
|
230
|
+
const block = useStore(editor.doc.state, (s) => s.document.blocks[0]);
|
|
231
|
+
const documents = useStore(editor.documents);
|
|
232
|
+
const isNew = useIsNew();
|
|
233
|
+
const { host, data, key, readOnly } = useFormHost();
|
|
234
|
+
if (!host || !key || !block) return null;
|
|
235
|
+
const schema = block.known ? editor.site.blocks[block.type] : undefined;
|
|
236
|
+
const fields = schema?.fields ?? {};
|
|
237
|
+
const used = usedOnDocs(documents, ref.path);
|
|
238
|
+
const n = used.length;
|
|
239
|
+
return (
|
|
240
|
+
<>
|
|
241
|
+
<Trail>
|
|
242
|
+
<DocumentsTab />
|
|
243
|
+
<CollectionTab name="shared" />
|
|
244
|
+
<CurrentHead
|
|
245
|
+
back={{ label: 'Back to shared blocks', onClick: () => editor.openCollection('shared') }}
|
|
246
|
+
crumb="Shared blocks"
|
|
247
|
+
title={title}
|
|
248
|
+
chips={
|
|
249
|
+
<>
|
|
250
|
+
<span className="chip shared">Shared</span>
|
|
251
|
+
{isNew ? <span className="chip waiting">New</span> : null}
|
|
252
|
+
<ReadOnlyChip />
|
|
253
|
+
</>
|
|
254
|
+
}
|
|
255
|
+
sub={`${schema?.label ?? 'Block'} · ${usedLabel(n)}`}
|
|
256
|
+
menu={<DocMenu label="Shared block actions" />}
|
|
257
|
+
/>
|
|
258
|
+
</Trail>
|
|
259
|
+
<div className="side-body" data-document={docId(ref)}>
|
|
260
|
+
<GlobalNotes />
|
|
261
|
+
<DocNotices />
|
|
262
|
+
{n ? (
|
|
263
|
+
<div className="note select" data-note="used-on">
|
|
264
|
+
<Icon name="shared" />
|
|
265
|
+
<div>
|
|
266
|
+
<b>Changes here show on every page that uses it</b>
|
|
267
|
+
<p className="used-in">
|
|
268
|
+
Used on{' '}
|
|
269
|
+
{used.map((d, i) => (
|
|
270
|
+
<Fragment key={docId(d)}>
|
|
271
|
+
{i ? ', ' : ''}
|
|
272
|
+
<button type="button" className="linkbtn" data-doc={docId(d)} onClick={() => void editor.openDocument({ collection: d.collection, path: d.path })}>
|
|
273
|
+
{d.label}
|
|
274
|
+
</button>
|
|
275
|
+
</Fragment>
|
|
276
|
+
))}
|
|
277
|
+
</p>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
) : (
|
|
281
|
+
<div className="note" data-note="used-on">
|
|
282
|
+
<Icon name="shared" />
|
|
283
|
+
<div>
|
|
284
|
+
<b>Not on any page yet</b>
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
)}
|
|
288
|
+
{!block.known ? (
|
|
289
|
+
<div className="note warn" data-note="unknown-type">
|
|
290
|
+
<Icon name="warn" />
|
|
291
|
+
<div>
|
|
292
|
+
<b>This block's type isn't in the site's code any more</b>
|
|
293
|
+
<p>Skipped on the page; kept in the file.</p>
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
) : (
|
|
297
|
+
<FormFields readOnly={readOnly}>
|
|
298
|
+
<FieldHostProvider host={host}>
|
|
299
|
+
<BlockHelp help={schema?.help} />
|
|
300
|
+
{Object.keys(fields).length === 0 ? <p className="field-help">This block has nothing to set.</p> : null}
|
|
301
|
+
{Object.entries(fields).map(([name, field]) => (
|
|
302
|
+
<FieldControl key={`${key}:${name}`} name={name} label={field.label ?? titleCase(name)} field={field} value={data[name]} lockupIndex={0} />
|
|
303
|
+
))}
|
|
304
|
+
</FieldHostProvider>
|
|
305
|
+
</FormFields>
|
|
306
|
+
)}
|
|
307
|
+
</div>
|
|
308
|
+
<RecentStrip />
|
|
309
|
+
</>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── Site Settings ────────────────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Why an editors entry lets nobody in (the whitelist's rules, M4 Decision 15), or null
|
|
317
|
+
* when it's fine. The mockup's copy.
|
|
318
|
+
*/
|
|
319
|
+
export function editorEntryProblem(value: string): string | null {
|
|
320
|
+
const entry = parseAllowEntry(value);
|
|
321
|
+
if (entry.kind === 'public-domain') {
|
|
322
|
+
return "Public mail domains let nobody in. Add each person's address.";
|
|
323
|
+
}
|
|
324
|
+
if (entry.kind === 'invalid') return 'Not an address or @domain; lets nobody in.';
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Whether an entry lets `email` in (its exact address, or its domain). */
|
|
329
|
+
export function entryCovers(value: string, email: string | null | undefined): boolean {
|
|
330
|
+
if (!email) return false;
|
|
331
|
+
const entry = parseAllowEntry(value);
|
|
332
|
+
const you = email.trim().toLowerCase();
|
|
333
|
+
if (entry.kind === 'address') return entry.value === you;
|
|
334
|
+
if (entry.kind === 'domain') return you.endsWith(`@${entry.value}`);
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Site Settings' logo and tab icon (M5b Decision 11): image values with no alt text and no focal point. */
|
|
339
|
+
const BRAND_IMAGE = mediaField({ only: 'image' });
|
|
340
|
+
const BRAND_SLOTS = [
|
|
341
|
+
{ key: 'logo', label: 'Logo', help: null, empty: 'None yet: the editor shows its own plain mark.' },
|
|
342
|
+
{ key: 'icon', label: 'Tab icon', help: 'Square works best.', empty: 'None yet: the tab uses the logo.' },
|
|
343
|
+
] as const;
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* A new logo or tab icon placed (#settings-brand). Subscribed once per editor, since Site Settings' level is off screen
|
|
347
|
+
* while the picker places it.
|
|
348
|
+
*/
|
|
349
|
+
const toasting = new WeakSet<Editor>();
|
|
350
|
+
function brandToasts(editor: Editor) {
|
|
351
|
+
if (toasting.has(editor)) return;
|
|
352
|
+
toasting.add(editor);
|
|
353
|
+
mediaFor(editor).placed.subscribe((p) => {
|
|
354
|
+
if (!p || editor.doc.get().document.kind !== 'settings' || p.scope !== formKey(editor.doc.get())) return;
|
|
355
|
+
if (p.path !== 'brand.logo' && p.path !== 'brand.icon') return;
|
|
356
|
+
editor.toast(`${p.path === 'brand.logo' ? 'Logo' : 'Tab icon'} updated`, 'check');
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function BrandSlot(props: { which: (typeof BRAND_SLOTS)[number]; value: unknown }) {
|
|
361
|
+
const editor = useEditor();
|
|
362
|
+
brandToasts(editor);
|
|
363
|
+
const { key, label, help, empty } = props.which;
|
|
364
|
+
const path = `brand.${key}`;
|
|
365
|
+
const set = parseMedia(props.value);
|
|
366
|
+
const line = set && 'key' in set ? help : empty;
|
|
367
|
+
return (
|
|
368
|
+
<div className="field media-field" data-field={key} data-ff={path}>
|
|
369
|
+
<div className="field-head">
|
|
370
|
+
<span className="field-label">{label}</span>
|
|
371
|
+
<span className="field-meta">Images only</span>
|
|
372
|
+
</div>
|
|
373
|
+
<MediaSlot path={path} field={BRAND_IMAGE} value={props.value} warnings={[]} pickLabel={label} brand={{ label }} />
|
|
374
|
+
{line ? (
|
|
375
|
+
<p className="field-help" style={{ margin: 'var(--space-1-5) 0 0' }}>
|
|
376
|
+
{line}
|
|
377
|
+
</p>
|
|
378
|
+
) : null}
|
|
379
|
+
</div>
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function SettingsLevel() {
|
|
384
|
+
const editor = useEditor();
|
|
385
|
+
const ref = useStore(editor.doc.state, (s) => s.ref);
|
|
386
|
+
const { host, data, key, readOnly } = useFormHost();
|
|
387
|
+
const brandNow = useStore(editor.brand);
|
|
388
|
+
const [adding, setAdding] = useState('');
|
|
389
|
+
const [confirm, setConfirm] = useState<number | null>(null);
|
|
390
|
+
if (!host || !key) return null;
|
|
391
|
+
const editors = Array.isArray(data.editors) ? (data.editors as string[]) : [];
|
|
392
|
+
const brand = (asObject(data.brand) ?? {}) as { name?: string; accent?: string; logo?: unknown; icon?: unknown };
|
|
393
|
+
const accent = /^#[0-9a-f]{6}$/i.test(brand.accent ?? '') ? brand.accent! : brandNow.accent;
|
|
394
|
+
const siteFields = formFields(editor.site, 'settings');
|
|
395
|
+
const you = editor.you?.email ?? null;
|
|
396
|
+
const addProblem = adding.trim() ? editorEntryProblem(adding) : null;
|
|
397
|
+
const remove = (i: number, sure = false) => {
|
|
398
|
+
if (!sure && entryCovers(editors[i]!, you)) {
|
|
399
|
+
setConfirm(i);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
setConfirm(null);
|
|
403
|
+
host.removeItem('editors', i, `Removed ${editors[i]}`);
|
|
404
|
+
};
|
|
405
|
+
const add = () => {
|
|
406
|
+
const value = adding.trim();
|
|
407
|
+
if (!value || readOnly) return;
|
|
408
|
+
host.set('editors', [...editors, value]);
|
|
409
|
+
setAdding('');
|
|
410
|
+
editor.announce(editorEntryProblem(value) ?? `Added ${value}.`);
|
|
411
|
+
};
|
|
412
|
+
return (
|
|
413
|
+
<>
|
|
414
|
+
<Trail>
|
|
415
|
+
<DocumentsTab />
|
|
416
|
+
<CurrentHead back={{ label: 'Back to documents', onClick: () => editor.goTo('documents') }} title="Site Settings" chips={<ReadOnlyChip />} />
|
|
417
|
+
</Trail>
|
|
418
|
+
<div className="side-body" data-document={docId(ref)}>
|
|
419
|
+
<GlobalNotes />
|
|
420
|
+
<DocNotices />
|
|
421
|
+
<FormFields readOnly={readOnly}>
|
|
422
|
+
<FieldHostProvider host={host}>
|
|
423
|
+
<section className="set-sec" data-ff="editors">
|
|
424
|
+
<h3>Who can sign in</h3>
|
|
425
|
+
<p className="lead">An exact address lets one person in; an @domain lets in everyone with an address there.</p>
|
|
426
|
+
<div className="eds" data-path="editors">
|
|
427
|
+
{editors.map((value, i) => {
|
|
428
|
+
const domain = value.trim().startsWith('@');
|
|
429
|
+
const why = editorEntryProblem(value);
|
|
430
|
+
const asking = confirm === i;
|
|
431
|
+
return (
|
|
432
|
+
<div key={`${i}:${value}`} className={`ed${asking || why ? ' is-confirm' : ''}`} data-editor={value} data-warned={why ? '' : undefined}>
|
|
433
|
+
<span className="tile" aria-hidden="true">
|
|
434
|
+
{domain ? '@' : <Icon name="mail" size="sm" />}
|
|
435
|
+
</span>
|
|
436
|
+
<span className="t">
|
|
437
|
+
<b>{value}</b>
|
|
438
|
+
{why ? <small style={{ color: 'var(--status-warning)' }}>Lets nobody in</small> : <small>{domain ? 'A whole domain' : 'One person'}</small>}
|
|
439
|
+
</span>
|
|
440
|
+
<button type="button" className="ib" aria-label={`Remove ${value}`} disabled={readOnly} onClick={() => remove(i)}>
|
|
441
|
+
<Icon name="x" size="sm" />
|
|
442
|
+
</button>
|
|
443
|
+
{asking ? (
|
|
444
|
+
<div className="ed-q" role="alert" data-note="remove-self">
|
|
445
|
+
This covers your own address, {you}. Remove it and you'll be signed out once the change applies.
|
|
446
|
+
<div className="acts">
|
|
447
|
+
<button type="button" className="btn sm danger" data-act="remove-self" onClick={() => remove(i, true)}>
|
|
448
|
+
Remove and sign me out
|
|
449
|
+
</button>
|
|
450
|
+
<button type="button" className="btn sm" onClick={() => setConfirm(null)}>
|
|
451
|
+
Keep it
|
|
452
|
+
</button>
|
|
453
|
+
</div>
|
|
454
|
+
</div>
|
|
455
|
+
) : null}
|
|
456
|
+
{why ? (
|
|
457
|
+
<div className="ed-q" style={{ color: 'var(--status-warning)' }} data-why="editor">
|
|
458
|
+
{why}
|
|
459
|
+
</div>
|
|
460
|
+
) : null}
|
|
461
|
+
</div>
|
|
462
|
+
);
|
|
463
|
+
})}
|
|
464
|
+
</div>
|
|
465
|
+
<form
|
|
466
|
+
className="ed-add"
|
|
467
|
+
data-form="editor"
|
|
468
|
+
onSubmit={(e) => {
|
|
469
|
+
e.preventDefault();
|
|
470
|
+
add();
|
|
471
|
+
}}
|
|
472
|
+
>
|
|
473
|
+
<input
|
|
474
|
+
className="input"
|
|
475
|
+
id="ed-new"
|
|
476
|
+
value={adding}
|
|
477
|
+
placeholder="name@example.com or @example.com"
|
|
478
|
+
aria-label="Add an address or a domain"
|
|
479
|
+
autoComplete="off"
|
|
480
|
+
spellCheck={false}
|
|
481
|
+
aria-describedby={addProblem ? 'ed-err' : undefined}
|
|
482
|
+
onChange={(e) => setAdding(e.target.value)}
|
|
483
|
+
/>
|
|
484
|
+
<button className="btn" type="submit">
|
|
485
|
+
Add
|
|
486
|
+
</button>
|
|
487
|
+
</form>
|
|
488
|
+
{addProblem ? (
|
|
489
|
+
<p className="field-warn" id="ed-err">
|
|
490
|
+
<Icon name="warn" size="sm" />
|
|
491
|
+
<span>{addProblem}</span>
|
|
492
|
+
</p>
|
|
493
|
+
) : null}
|
|
494
|
+
<div className="fixed-line">
|
|
495
|
+
<Icon name="lock" size="sm" />
|
|
496
|
+
<span>The agency can always sign in.</span>
|
|
497
|
+
</div>
|
|
498
|
+
</section>
|
|
499
|
+
<section className="set-sec">
|
|
500
|
+
<h3>Editor branding</h3>
|
|
501
|
+
<p className="lead">Doesn't change the site.</p>
|
|
502
|
+
<div className="field" data-ff="brand.name">
|
|
503
|
+
<div className="field-head">
|
|
504
|
+
<label htmlFor="set-name">Site name</label>
|
|
505
|
+
</div>
|
|
506
|
+
<input className="input" id="set-name" data-path="brand.name" value={brand.name ?? ''} placeholder="Site" autoComplete="off" onChange={(e) => host.set('brand.name', e.target.value)} />
|
|
507
|
+
</div>
|
|
508
|
+
{BRAND_SLOTS.map((which) => (
|
|
509
|
+
<BrandSlot key={which.key} which={which} value={brand[which.key] ?? null} />
|
|
510
|
+
))}
|
|
511
|
+
<div className="field" data-ff="brand.accent">
|
|
512
|
+
<div className="field-head">
|
|
513
|
+
<label htmlFor="set-accent">Accent colour</label>
|
|
514
|
+
</div>
|
|
515
|
+
<div className="swatch-in">
|
|
516
|
+
<span className="swatch" style={{ background: accent }}>
|
|
517
|
+
<input type="color" id="set-accent" data-path="brand.accent" value={accent.toLowerCase()} aria-label="Accent colour" onChange={(e) => host.set('brand.accent', e.target.value)} />
|
|
518
|
+
</span>
|
|
519
|
+
<span className="mono" id="set-accent-v">
|
|
520
|
+
{accent.toUpperCase()}
|
|
521
|
+
</span>
|
|
522
|
+
</div>
|
|
523
|
+
<div className="accent-try" aria-hidden="true">
|
|
524
|
+
<span className="logo-slot">
|
|
525
|
+
<BrandMark />
|
|
526
|
+
</span>
|
|
527
|
+
<span>{brandNow.name}</span>
|
|
528
|
+
<span className="pub">
|
|
529
|
+
<span className="pb">Publish</span>
|
|
530
|
+
</span>
|
|
531
|
+
</div>
|
|
532
|
+
</div>
|
|
533
|
+
</section>
|
|
534
|
+
{/* M6 Decision 14: the default SEO, the Google tag and Form emails. */}
|
|
535
|
+
<SettingsM6 data={data} />
|
|
536
|
+
{Object.keys(siteFields).length ? (
|
|
537
|
+
<section className="set-sec" data-section="site-settings">
|
|
538
|
+
<h3>The site</h3>
|
|
539
|
+
{Object.entries(siteFields).map(([name, field]) => (
|
|
540
|
+
<FieldControl key={`${key}:${name}`} name={name} label={field.label ?? titleCase(name)} field={field} value={data[name]} />
|
|
541
|
+
))}
|
|
542
|
+
</section>
|
|
543
|
+
) : null}
|
|
544
|
+
</FieldHostProvider>
|
|
545
|
+
</FormFields>
|
|
546
|
+
</div>
|
|
547
|
+
<RecentStrip />
|
|
548
|
+
</>
|
|
549
|
+
);
|
|
550
|
+
}
|