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,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The documents level, the pane's top (the mockup's #docs, #tree-open, #tree-closed,
|
|
3
|
+
* #find; Decisions 8 and 9):
|
|
4
|
+
*
|
|
5
|
+
* - **Pages as a tree** (client/nav/tree.ts): Home first, then alphabetical by
|
|
6
|
+
* title; a page with pages under it opens like any page and has a show/hide
|
|
7
|
+
* chevron on the left, in a column every tree row reserves; a folder with no page
|
|
8
|
+
* of its own is a group row that only shows or hides its children. Groups open or
|
|
9
|
+
* closed as the cookie says (Decision 20), and the server renders them so.
|
|
10
|
+
* - **"Collections N"**: a row per other collection (name, count, chevron) that
|
|
11
|
+
* drills into its own level (CollectionLevel.tsx). Nesting is Pages only.
|
|
12
|
+
* - **"Find a document"** searches every document's title and address, collections
|
|
13
|
+
* included, grouped by collection (client/nav/search.ts).
|
|
14
|
+
* - **"Site-wide"** (M5a): Shared blocks (a row to its own level, like a collection),
|
|
15
|
+
* then Site Settings (a document row).
|
|
16
|
+
* - **New page** (M5a) opens the New page dialog (documents/dialogs.tsx); a group row's
|
|
17
|
+
* menu offers "Create a page at /services" and "New page in Services".
|
|
18
|
+
* - Shared blocks and Site Settings are found by Find and ⌘K too (grouped by collection).
|
|
19
|
+
* - It also starts the empty page's "+ Add the first block" (block-list/insert-points.ts), once:
|
|
20
|
+
* this level is the one that's always mounted.
|
|
21
|
+
* - M4 (live mode): presence and waiting dots, bubbled up to a closed group, a
|
|
22
|
+
* closed parent and a collection's row (live/marks.tsx → Bubble). Local mode: the
|
|
23
|
+
* slots stay empty.
|
|
24
|
+
*/
|
|
25
|
+
import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
|
|
26
|
+
import { useEditor, useStore } from '../../context.tsx';
|
|
27
|
+
import { BrandMark } from '../../shell/Brand.tsx';
|
|
28
|
+
import { mediaFor } from '../../media/store.ts';
|
|
29
|
+
import { inboxFor } from '../../inbox/store.ts';
|
|
30
|
+
import { Icon } from '../../shell/Icons.tsx';
|
|
31
|
+
import { startEmptyPageAdd } from '../../block-list/insert-points.ts';
|
|
32
|
+
import { DocRow, docIcon } from '../../nav/DocRow.tsx';
|
|
33
|
+
import { addressFor } from '../../documents/actions.ts';
|
|
34
|
+
import { openDialog } from '../../documents/dialogs.tsx';
|
|
35
|
+
import { Bubble } from '../../live/marks.tsx';
|
|
36
|
+
import { searchDocuments } from '../../nav/search.ts';
|
|
37
|
+
import { treeRows, type TreeRow } from '../../nav/tree.ts';
|
|
38
|
+
import type { EditorCollection, ListedDocument } from '../../../types.ts';
|
|
39
|
+
import { CurrentHead, GlobalNotes, RecentStrip, Trail } from '../parts.tsx';
|
|
40
|
+
|
|
41
|
+
export function DocumentsLevel() {
|
|
42
|
+
const editor = useEditor();
|
|
43
|
+
const { host } = editor.site;
|
|
44
|
+
const brand = useStore(editor.brand);
|
|
45
|
+
const documents = useStore(editor.documents);
|
|
46
|
+
const [query, setQuery] = useState('');
|
|
47
|
+
const nested = editor.site.collections.filter((c) => c.nested);
|
|
48
|
+
// The site's other collections: section builders that don't nest, and list-of-fields ones (M5a).
|
|
49
|
+
const flat = editor.site.collections.filter((c) => !c.nested && (c.kind === 'sections' || c.kind === 'fields'));
|
|
50
|
+
const sharedCollection = editor.site.collections.find((c) => c.kind === 'shared');
|
|
51
|
+
const settings = documents.find((d) => d.collection === 'settings');
|
|
52
|
+
const searching = query.trim() !== '';
|
|
53
|
+
// The empty page's add (block-list/insert-points.ts). Mounted here because this level
|
|
54
|
+
// is always mounted (Pane.tsx keeps it, hidden), so it starts once per editor.
|
|
55
|
+
useEffect(() => startEmptyPageAdd(editor), [editor]);
|
|
56
|
+
|
|
57
|
+
const onFindKey = (event: KeyboardEvent<HTMLInputElement>) => {
|
|
58
|
+
if (event.key === 'Enter') {
|
|
59
|
+
const first = searchDocuments(query, documents, editor.site.collections)
|
|
60
|
+
.flatMap((g) => g.hits)
|
|
61
|
+
.find((h) => !h.doc.warning);
|
|
62
|
+
if (first) void editor.openDocument({ collection: first.doc.collection, path: first.doc.path });
|
|
63
|
+
} else if (event.key === 'Escape' && query) {
|
|
64
|
+
// Clear the box first; a second Esc leaves it (App.tsx).
|
|
65
|
+
event.preventDefault();
|
|
66
|
+
setQuery('');
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<>
|
|
72
|
+
<Trail>
|
|
73
|
+
<CurrentHead
|
|
74
|
+
lead={
|
|
75
|
+
<span className="logo-slot" style={{ background: 'var(--surface-sunken)' }}>
|
|
76
|
+
<BrandMark />
|
|
77
|
+
</span>
|
|
78
|
+
}
|
|
79
|
+
title={brand.name}
|
|
80
|
+
sub={`${documents.length} document${documents.length === 1 ? '' : 's'} · ${host}`}
|
|
81
|
+
/>
|
|
82
|
+
</Trail>
|
|
83
|
+
<div className="side-body flush">
|
|
84
|
+
<GlobalNotes />
|
|
85
|
+
<div className="find">
|
|
86
|
+
<Icon name="search" />
|
|
87
|
+
<input
|
|
88
|
+
className="input"
|
|
89
|
+
type="search"
|
|
90
|
+
placeholder="Find a document"
|
|
91
|
+
aria-label="Find a document"
|
|
92
|
+
data-filter="docs"
|
|
93
|
+
autoComplete="off"
|
|
94
|
+
spellCheck={false}
|
|
95
|
+
value={query}
|
|
96
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
97
|
+
onKeyDown={onFindKey}
|
|
98
|
+
/>
|
|
99
|
+
</div>
|
|
100
|
+
<div id="docs-list">
|
|
101
|
+
{searching ? (
|
|
102
|
+
<SearchResults query={query} documents={documents} />
|
|
103
|
+
) : (
|
|
104
|
+
<>
|
|
105
|
+
{nested.map((c) => (
|
|
106
|
+
<PagesSection key={c.name} collection={c} documents={documents.filter((d) => d.collection === c.name)} />
|
|
107
|
+
))}
|
|
108
|
+
{flat.length > 0 ? (
|
|
109
|
+
<section className="grp" aria-label="Collections">
|
|
110
|
+
<div className="grp-h">
|
|
111
|
+
Collections <span className="n">{flat.length}</span>
|
|
112
|
+
</div>
|
|
113
|
+
{flat.map((c) => (
|
|
114
|
+
<CollectionRow key={c.name} collection={c} documents={documents.filter((d) => d.collection === c.name)} />
|
|
115
|
+
))}
|
|
116
|
+
</section>
|
|
117
|
+
) : null}
|
|
118
|
+
<section className="grp" aria-label="Site-wide" data-section="site-wide">
|
|
119
|
+
<div className="grp-h">Site-wide</div>
|
|
120
|
+
{sharedCollection ? <CollectionRow collection={sharedCollection} documents={documents.filter((d) => d.collection === sharedCollection.name)} /> : null}
|
|
121
|
+
<MediaRow />
|
|
122
|
+
{settings ? <DocRow doc={settings} /> : null}
|
|
123
|
+
<SubmissionsRow />
|
|
124
|
+
</section>
|
|
125
|
+
</>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
<RecentStrip />
|
|
130
|
+
</>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function PagesSection({ collection, documents }: { collection: EditorCollection; documents: ListedDocument[] }) {
|
|
135
|
+
const editor = useEditor();
|
|
136
|
+
const open = useStore(editor.ui, (u) => u.open);
|
|
137
|
+
const rows = treeRows(collection.name, documents, open);
|
|
138
|
+
return (
|
|
139
|
+
<section className="grp" aria-label={collection.label} data-collection={collection.name}>
|
|
140
|
+
<div className="grp-h">
|
|
141
|
+
{collection.label} <span className="n">{documents.length}</span>
|
|
142
|
+
<span className="sp" />
|
|
143
|
+
<button type="button" className="linkbtn" data-act="new-page" onClick={() => openDialog(editor, { kind: 'new', collection: collection.name })}>
|
|
144
|
+
<Icon name="plus" size="xs" />
|
|
145
|
+
New page
|
|
146
|
+
</button>
|
|
147
|
+
</div>
|
|
148
|
+
{rows.map((row) => (
|
|
149
|
+
<TreeNode key={`${row.kind}:${row.path}`} row={row} collection={collection} />
|
|
150
|
+
))}
|
|
151
|
+
</section>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function TreeNode({ row, collection }: { row: TreeRow; collection: EditorCollection }) {
|
|
156
|
+
const editor = useEditor();
|
|
157
|
+
const depth = { ['--depth' as string]: row.depth };
|
|
158
|
+
if (row.kind === 'group') {
|
|
159
|
+
return (
|
|
160
|
+
<div className="tnode" data-tnode-group={row.path}>
|
|
161
|
+
<button
|
|
162
|
+
type="button"
|
|
163
|
+
className="drow t is-group"
|
|
164
|
+
style={depth}
|
|
165
|
+
data-group={row.key}
|
|
166
|
+
aria-expanded={row.open}
|
|
167
|
+
title={`${row.label} has no page of its own`}
|
|
168
|
+
onClick={() => editor.toggleGroup(row.key)}
|
|
169
|
+
>
|
|
170
|
+
<span className="tw" aria-hidden="true">
|
|
171
|
+
<Icon name="chev-r" size="xs" />
|
|
172
|
+
</span>
|
|
173
|
+
<Icon name="folder" />
|
|
174
|
+
<span className="nm">{row.label}</span>
|
|
175
|
+
<span className="meta">
|
|
176
|
+
{/* M4: presence and waiting dots inside, bubbled up while it's closed. */}
|
|
177
|
+
{editor.mode === 'live' && !row.open ? <Bubble files={row.under.map((d) => d.file)} /> : <span data-slot="bubble" hidden />}
|
|
178
|
+
</span>
|
|
179
|
+
</button>
|
|
180
|
+
<GroupMenu row={row} collection={collection} />
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return (
|
|
185
|
+
<div className="tnode">
|
|
186
|
+
<DocRow doc={row.doc} className="t" style={depth} lead={<span className="tw" aria-hidden="true" />} extra={row.parent && !row.open ? editor.mode === 'live' ? <Bubble files={row.under.map((d) => d.file)} /> : <span data-slot="bubble" hidden /> : null} />
|
|
187
|
+
{row.parent ? (
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
className="twisty"
|
|
191
|
+
style={depth}
|
|
192
|
+
data-group={row.key}
|
|
193
|
+
aria-expanded={row.open}
|
|
194
|
+
aria-label={`${row.open ? 'Hide' : 'Show'} the pages under ${row.doc.label}`}
|
|
195
|
+
onClick={() => editor.toggleGroup(row.key)}
|
|
196
|
+
>
|
|
197
|
+
<Icon name="chev-r" size="xs" />
|
|
198
|
+
</button>
|
|
199
|
+
) : null}
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* A group row's menu (M5a; the mockup's #group-menu): "Create a page at /services"
|
|
206
|
+
* (the group becomes a page, its pages stay under it), and "New page in Services".
|
|
207
|
+
*/
|
|
208
|
+
function GroupMenu({ row, collection }: { row: Extract<TreeRow, { kind: 'group' }>; collection: EditorCollection }) {
|
|
209
|
+
const editor = useEditor();
|
|
210
|
+
const [open, setOpen] = useState(false);
|
|
211
|
+
const box = useRef<HTMLDivElement>(null);
|
|
212
|
+
const button = useRef<HTMLButtonElement>(null);
|
|
213
|
+
const address = addressFor(collection, row.path) ?? `/${row.path}`;
|
|
214
|
+
const readOnly = useStore(editor.access, (a) => !!a.signedOut);
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
if (!open) return;
|
|
217
|
+
box.current?.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
|
|
218
|
+
const onDown = (event: MouseEvent) => {
|
|
219
|
+
const target = event.target as Element | null;
|
|
220
|
+
if (box.current?.contains(target) || button.current?.contains(target)) return;
|
|
221
|
+
setOpen(false);
|
|
222
|
+
};
|
|
223
|
+
document.addEventListener('mousedown', onDown);
|
|
224
|
+
return () => document.removeEventListener('mousedown', onDown);
|
|
225
|
+
}, [open]);
|
|
226
|
+
const go = (dialog: Parameters<typeof openDialog>[1]) => {
|
|
227
|
+
setOpen(false);
|
|
228
|
+
openDialog(editor, dialog);
|
|
229
|
+
};
|
|
230
|
+
const n = row.under.length;
|
|
231
|
+
return (
|
|
232
|
+
<>
|
|
233
|
+
<button
|
|
234
|
+
ref={button}
|
|
235
|
+
type="button"
|
|
236
|
+
className="ib row-more"
|
|
237
|
+
data-act="group-menu"
|
|
238
|
+
aria-label={`Actions for the ${row.label} group`}
|
|
239
|
+
aria-haspopup="menu"
|
|
240
|
+
aria-expanded={open}
|
|
241
|
+
disabled={readOnly}
|
|
242
|
+
onClick={() => setOpen((o) => !o)}
|
|
243
|
+
>
|
|
244
|
+
<Icon name="more" />
|
|
245
|
+
</button>
|
|
246
|
+
{open ? (
|
|
247
|
+
<div
|
|
248
|
+
ref={box}
|
|
249
|
+
className="menu"
|
|
250
|
+
role="menu"
|
|
251
|
+
onKeyDown={(e) => {
|
|
252
|
+
if (e.key === 'Escape') {
|
|
253
|
+
// Closes the menu only.
|
|
254
|
+
e.preventDefault();
|
|
255
|
+
setOpen(false);
|
|
256
|
+
button.current?.focus();
|
|
257
|
+
}
|
|
258
|
+
}}
|
|
259
|
+
>
|
|
260
|
+
<button type="button" role="menuitem" data-act="group-page" onClick={() => go({ kind: 'new', collection: collection.name, at: row.path })}>
|
|
261
|
+
<Icon name="page" />
|
|
262
|
+
Create a page at {address}
|
|
263
|
+
</button>
|
|
264
|
+
<div className="why">
|
|
265
|
+
{row.label} has no page of its own. Its {n} page{n === 1 ? '' : 's'} stay{n === 1 ? 's' : ''} under it.
|
|
266
|
+
</div>
|
|
267
|
+
<hr />
|
|
268
|
+
<button type="button" role="menuitem" data-act="new-in-group" onClick={() => go({ kind: 'new', collection: collection.name, inside: row.path })}>
|
|
269
|
+
<Icon name="plus" />
|
|
270
|
+
New page in {row.label}
|
|
271
|
+
</button>
|
|
272
|
+
</div>
|
|
273
|
+
) : null}
|
|
274
|
+
</>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** M5b: the library, Site-wide → Media (the mockup's #media-row): its count once it's been read, read on intent. */
|
|
279
|
+
function MediaRow() {
|
|
280
|
+
const editor = useEditor();
|
|
281
|
+
const media = mediaFor(editor);
|
|
282
|
+
const images = useStore(media.images, (l) => l.list?.length ?? null);
|
|
283
|
+
const clips = useStore(media.clips, (l) => l.list?.length ?? null);
|
|
284
|
+
// M6c G4: files count too (read with the images).
|
|
285
|
+
const files = useStore(media.files, (l) => l.list?.length ?? null);
|
|
286
|
+
const n = images === null && clips === null ? null : (images ?? 0) + (clips ?? 0) + (files ?? 0);
|
|
287
|
+
const prefetch = () => {
|
|
288
|
+
void media.loadImages();
|
|
289
|
+
void media.loadClips();
|
|
290
|
+
};
|
|
291
|
+
return (
|
|
292
|
+
<button
|
|
293
|
+
type="button"
|
|
294
|
+
className="drow coll media-row"
|
|
295
|
+
data-act="open-media"
|
|
296
|
+
aria-label={n === null ? 'Media' : `Media, ${n}`}
|
|
297
|
+
onPointerEnter={prefetch}
|
|
298
|
+
onFocus={prefetch}
|
|
299
|
+
onClick={() => {
|
|
300
|
+
prefetch();
|
|
301
|
+
editor.openMedia();
|
|
302
|
+
}}
|
|
303
|
+
>
|
|
304
|
+
<Icon name="media" />
|
|
305
|
+
<span className="nm">Media</span>
|
|
306
|
+
<span className="meta">{n === null ? null : <span className="cnt">{n}</span>}</span>
|
|
307
|
+
<Icon name="chev-r" size="sm" />
|
|
308
|
+
</button>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* M6 Decision 9: the inbox, last under Site-wide (the mockup's #submissions-row), with the
|
|
314
|
+
* unread count as a small pill only when something is unread. Its list is read on intent.
|
|
315
|
+
*/
|
|
316
|
+
function SubmissionsRow() {
|
|
317
|
+
const editor = useEditor();
|
|
318
|
+
const unread = useStore(editor.submissions, (s) => s.unread ?? 0);
|
|
319
|
+
const prefetch = () => void inboxFor(editor).load();
|
|
320
|
+
return (
|
|
321
|
+
<button
|
|
322
|
+
type="button"
|
|
323
|
+
className="drow coll subs-row"
|
|
324
|
+
data-act="open-subs"
|
|
325
|
+
aria-label={`Submissions${unread ? `, ${unread} unread` : ''}`}
|
|
326
|
+
onPointerEnter={prefetch}
|
|
327
|
+
onFocus={prefetch}
|
|
328
|
+
onClick={() => editor.openInbox(null)}
|
|
329
|
+
>
|
|
330
|
+
<Icon name="inbox" />
|
|
331
|
+
<span className="nm">Submissions</span>
|
|
332
|
+
<span className="meta">
|
|
333
|
+
{unread ? (
|
|
334
|
+
<span className="ucnt" title={`${unread} unread`}>
|
|
335
|
+
{unread}
|
|
336
|
+
</span>
|
|
337
|
+
) : null}
|
|
338
|
+
</span>
|
|
339
|
+
<Icon name="chev-r" size="sm" />
|
|
340
|
+
</button>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function CollectionRow({ collection, documents }: { collection: EditorCollection; documents: ListedDocument[] }) {
|
|
345
|
+
const editor = useEditor();
|
|
346
|
+
const n = documents.length;
|
|
347
|
+
return (
|
|
348
|
+
<button type="button" className="drow coll" data-coll={collection.name} aria-label={`${collection.label}, ${n}`} onClick={() => editor.openCollection(collection.name)}>
|
|
349
|
+
<Icon name={docIcon(collection)} />
|
|
350
|
+
<span className="nm">{collection.label}</span>
|
|
351
|
+
<span className="meta">
|
|
352
|
+
{/* M4: presence and waiting dots inside the collection, bubbled up. */}
|
|
353
|
+
{editor.mode === 'live' ? <Bubble files={documents.map((d) => d.file)} /> : <span data-slot="bubble" hidden />}
|
|
354
|
+
<span className="cnt">{n}</span>
|
|
355
|
+
</span>
|
|
356
|
+
<Icon name="chev-r" size="sm" />
|
|
357
|
+
</button>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** "Find a document": every document's title and address, grouped by collection (Decision 9). */
|
|
362
|
+
export function SearchResults({ query, documents }: { query: string; documents: ListedDocument[] }) {
|
|
363
|
+
const editor = useEditor();
|
|
364
|
+
const groups = searchDocuments(query, documents, editor.site.collections);
|
|
365
|
+
if (groups.length === 0) {
|
|
366
|
+
return (
|
|
367
|
+
<p className="no-hits" role="status">
|
|
368
|
+
<b>Nothing called “{query.trim()}”</b>
|
|
369
|
+
</p>
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
return (
|
|
373
|
+
<>
|
|
374
|
+
{groups.map((g) => (
|
|
375
|
+
<section key={g.collection.name} className="grp" aria-label={g.collection.label} data-results={g.collection.name}>
|
|
376
|
+
<div className="grp-h">
|
|
377
|
+
{g.collection.label}{' '}
|
|
378
|
+
<span className="n">{g.collection.nested || g.hits.length === g.total ? g.hits.length : `${g.hits.length} of ${g.total}`}</span>
|
|
379
|
+
</div>
|
|
380
|
+
{g.hits.slice(0, MAX_HITS).map((h) => (
|
|
381
|
+
<DocRow key={h.doc.path} doc={h.doc} crumb={h.crumb} />
|
|
382
|
+
))}
|
|
383
|
+
{g.hits.length > MAX_HITS ? <div className="sw-more">{g.hits.length - MAX_HITS} more: keep typing</div> : null}
|
|
384
|
+
</section>
|
|
385
|
+
))}
|
|
386
|
+
</>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const MAX_HITS = 12;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A file's own level in the library (M6c G4, Decision 8; the mockup's #file): what it is,
|
|
3
|
+
* its address (copyable: what a link stores, notes/2026-10-17-m6c-downloads-grid.md →
|
|
4
|
+
* Decision 3), every document that links to it (by schema, the listing's `usedIn`, each a
|
|
5
|
+
* link to the document), and Replace… (media/ReplaceDialog.tsx, Decision 9). The stage shows the
|
|
6
|
+
* file large with Open in a new tab (media/LibraryStage.tsx).
|
|
7
|
+
*
|
|
8
|
+
* Nothing is deleted from the library: Replace… moves every link to a new version, and
|
|
9
|
+
* this file stays at its address.
|
|
10
|
+
*/
|
|
11
|
+
import { useState } from 'react';
|
|
12
|
+
import { openDialog } from '../../documents/dialogs.tsx';
|
|
13
|
+
import { fileAddress, fileTypeOf } from '../../../../files.ts';
|
|
14
|
+
import { useEditor, useStore } from '../../context.tsx';
|
|
15
|
+
import { FieldIcon } from '../../fields/parts.tsx';
|
|
16
|
+
import { when } from '../../live/format.ts';
|
|
17
|
+
import { fileSize, mediaFor } from '../../media/store.ts';
|
|
18
|
+
import { Icon } from '../../shell/Icons.tsx';
|
|
19
|
+
import { CurrentHead, DocumentsTab, GlobalNotes, LevelTab, RecentStrip, Trail } from '../parts.tsx';
|
|
20
|
+
import { timeZoneOf } from './MediaLevel.tsx';
|
|
21
|
+
|
|
22
|
+
export function FileLevel({ fileKey }: { fileKey: string }) {
|
|
23
|
+
const editor = useEditor();
|
|
24
|
+
const media = mediaFor(editor);
|
|
25
|
+
const files = useStore(media.files, (l) => l.list);
|
|
26
|
+
const documents = useStore(editor.documents);
|
|
27
|
+
const [copied, setCopied] = useState(false);
|
|
28
|
+
const back = () => editor.goUp();
|
|
29
|
+
const file = files?.find((f) => f.key === fileKey) ?? null;
|
|
30
|
+
const name = file?.name ?? fileKey.slice(fileKey.lastIndexOf('/') + 1);
|
|
31
|
+
const head = (sub?: string) => (
|
|
32
|
+
<Trail>
|
|
33
|
+
<DocumentsTab />
|
|
34
|
+
<LevelTab name="Media" kind="Files" count={files ? String(files.length) : undefined} onClick={back} />
|
|
35
|
+
<CurrentHead back={{ label: 'Back to Media', onClick: back }} crumb="Media" title={name} sub={sub} />
|
|
36
|
+
</Trail>
|
|
37
|
+
);
|
|
38
|
+
if (!file) {
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
{head()}
|
|
42
|
+
<div className="side-body">
|
|
43
|
+
<GlobalNotes />
|
|
44
|
+
</div>
|
|
45
|
+
<RecentStrip />
|
|
46
|
+
</>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
const type = fileTypeOf(file.key);
|
|
50
|
+
const address = fileAddress(file.key);
|
|
51
|
+
const size = fileSize(file.bytes);
|
|
52
|
+
const zone = timeZoneOf(editor);
|
|
53
|
+
const uses = file.usedIn;
|
|
54
|
+
const iconOf = (collection: string) => (collection === 'settings' ? 'gear' : collection === 'shared' ? 'shared' : editor.site.collections.find((c) => c.name === collection)?.kind === 'fields' ? 'case' : 'page');
|
|
55
|
+
const copy = () => {
|
|
56
|
+
void navigator.clipboard?.writeText(address).then(
|
|
57
|
+
() => {
|
|
58
|
+
setCopied(true);
|
|
59
|
+
editor.announce('Address copied');
|
|
60
|
+
setTimeout(() => setCopied(false), 1600);
|
|
61
|
+
},
|
|
62
|
+
() => undefined,
|
|
63
|
+
);
|
|
64
|
+
};
|
|
65
|
+
return (
|
|
66
|
+
<>
|
|
67
|
+
{head(`${file.type}${size ? ` · ${size}` : ''}`)}
|
|
68
|
+
<div className="side-body" data-media-item={file.key} data-file-level="">
|
|
69
|
+
<GlobalNotes />
|
|
70
|
+
<dl className="kv" style={{ marginTop: 0 }}>
|
|
71
|
+
<dt>Type</dt>
|
|
72
|
+
<dd>
|
|
73
|
+
{file.type}
|
|
74
|
+
{type && ['doc', 'xls', 'ppt'].includes(type.ext) ? ` (the older .${type.ext} format)` : ''}
|
|
75
|
+
</dd>
|
|
76
|
+
{size ? (
|
|
77
|
+
<>
|
|
78
|
+
<dt>Size</dt>
|
|
79
|
+
<dd>{size}</dd>
|
|
80
|
+
</>
|
|
81
|
+
) : null}
|
|
82
|
+
<dt>Uploaded</dt>
|
|
83
|
+
<dd>
|
|
84
|
+
{file.uploadedAt ? when(file.uploadedAt, zone, new Date().toISOString()) : 'Not known'}
|
|
85
|
+
{file.by && file.by !== 'setup' ? ` by ${file.by}` : ''}
|
|
86
|
+
</dd>
|
|
87
|
+
<dt>Address</dt>
|
|
88
|
+
<dd className="addr">
|
|
89
|
+
<span className="mono" data-file-address="">
|
|
90
|
+
{address}
|
|
91
|
+
</span>
|
|
92
|
+
<button type="button" className="ib" aria-label={copied ? 'Copied' : 'Copy the address'} title={copied ? 'Copied' : 'Copy the address'} onClick={copy}>
|
|
93
|
+
<Icon name={copied ? 'check' : 'copy'} size="xs" />
|
|
94
|
+
</button>
|
|
95
|
+
</dd>
|
|
96
|
+
<dt>On the site</dt>
|
|
97
|
+
<dd>{type?.opensInTab ? 'Opens in a new tab, or downloads: each link says which' : 'Downloads'}</dd>
|
|
98
|
+
</dl>
|
|
99
|
+
<div className="file-acts">
|
|
100
|
+
<button type="button" className="btn sm" data-act="file-replace" onClick={() => openDialog(editor, { kind: 'replace', key: file.key })}>
|
|
101
|
+
<FieldIcon name="swap" size="sm" />
|
|
102
|
+
Replace…
|
|
103
|
+
</button>
|
|
104
|
+
<a className="btn sm ghost" href={address} target="_blank" rel="noopener">
|
|
105
|
+
<Icon name="ext" size="sm" />
|
|
106
|
+
Open
|
|
107
|
+
</a>
|
|
108
|
+
</div>
|
|
109
|
+
<p className="sec-h" style={{ marginTop: 'var(--space-5)' }}>
|
|
110
|
+
{uses.length ? `Linked from ${uses.length}` : 'Nothing links to it'}
|
|
111
|
+
</p>
|
|
112
|
+
{uses.length ? (
|
|
113
|
+
<div className="uses">
|
|
114
|
+
{uses.map((u) => (
|
|
115
|
+
<button key={`${u.collection}/${u.path}`} type="button" className="drow" data-use={`${u.collection}/${u.path}`} onClick={() => void editor.openDocument({ collection: u.collection, path: u.path })}>
|
|
116
|
+
<Icon name={iconOf(u.collection)} />
|
|
117
|
+
<span className="nm">{documents.find((d) => d.collection === u.collection && d.path === u.path)?.label ?? u.title}</span>
|
|
118
|
+
<span className="meta">
|
|
119
|
+
{u.links} link{u.links === 1 ? '' : 's'}
|
|
120
|
+
</span>
|
|
121
|
+
<Icon name="chev-r" size="sm" />
|
|
122
|
+
</button>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
) : (
|
|
126
|
+
<p className="field-help">Nothing links to it.</p>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
<RecentStrip />
|
|
130
|
+
</>
|
|
131
|
+
);
|
|
132
|
+
}
|