smoodly 0.0.60 → 0.0.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/admin/editor/CanvasOverlay.d.ts +4 -1
- package/dist/admin/editor/CanvasOverlay.js +3 -3
- package/dist/admin/editor/GlobalCanvas.d.ts +27 -0
- package/dist/admin/editor/GlobalCanvas.js +159 -0
- package/dist/admin/editor/InsertPicker.js +1 -1
- package/dist/admin/files.generated.js +1 -1
- package/dist/admin/serialize.d.ts +2 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AdminApp.js +5 -5
- package/dist/admin/shell/GlobalForm.js +80 -7
- package/dist/admin/shell/GlobalList.js +1 -1
- package/dist/admin/shell/{IconRail.d.ts → NavBar.d.ts} +3 -3
- package/dist/admin/shell/NavBar.js +120 -0
- package/dist/admin/shell/global-canvas.d.ts +40 -0
- package/dist/admin/shell/global-canvas.js +60 -0
- package/dist/admin/shell/health-list.js +1 -1
- package/dist/admin/ui/primitives.js +1 -1
- package/dist/admin/ui/theme.js +99 -91
- package/dist/api-client.js +5 -3
- package/dist/astro/index.js +3 -3
- package/dist/cli/push.js +2 -3
- package/dist/env.js +3 -2
- package/dist/push.d.ts +7 -0
- package/dist/push.js +8 -0
- package/dist/space-kind.d.ts +2 -0
- package/dist/space-kind.js +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/collections/defining.md +57 -0
- package/docs/collections/entry-pages.md +168 -0
- package/docs/collections/ordering.md +47 -0
- package/docs/collections/queries.md +173 -0
- package/docs/content-modeling/changing-schemas.md +79 -0
- package/docs/content-modeling/custom-section.md +94 -0
- package/docs/content-modeling/marks.md +97 -0
- package/docs/content-modeling/overview.md +55 -0
- package/docs/content-modeling/page-types.md +237 -0
- package/docs/content-modeling/sections.md +264 -0
- package/docs/content-modeling/styles-and-settings.md +184 -0
- package/docs/content-modeling/zones.md +80 -0
- package/docs/fields/choices.md +42 -0
- package/docs/fields/conditional.md +72 -0
- package/docs/fields/link.md +31 -0
- package/docs/fields/list-and-object.md +58 -0
- package/docs/fields/media.md +103 -0
- package/docs/fields/overview.md +56 -0
- package/docs/fields/reference-and-tags.md +62 -0
- package/docs/fields/richtext.md +168 -0
- package/docs/fields/text.md +152 -0
- package/docs/frameworks/astro.md +110 -0
- package/docs/frameworks/nextjs.md +147 -0
- package/docs/getting-started/content-from-code.md +263 -0
- package/docs/getting-started/deploying.md +131 -0
- package/docs/getting-started/export-and-import.md +73 -0
- package/docs/getting-started/first-page.md +98 -0
- package/docs/getting-started/install.md +108 -0
- package/docs/getting-started/keys.md +40 -0
- package/docs/getting-started/project-structure.md +142 -0
- package/docs/getting-started/working-together.md +42 -0
- package/docs/globals/defining.md +99 -0
- package/docs/globals/header-and-footer.md +117 -0
- package/docs/index.md +83 -0
- package/docs/languages/language-switcher.md +103 -0
- package/docs/languages/locales.md +119 -0
- package/docs/languages/localized-fields.md +61 -0
- package/docs/media/images-and-crops.md +131 -0
- package/docs/media/library.md +40 -0
- package/docs/pages/drafts-and-publishing.md +44 -0
- package/docs/pages/page-tree.md +51 -0
- package/docs/pages/paths-and-slugs.md +74 -0
- package/docs/pages/seo.md +126 -0
- package/docs/pages/version-history.md +23 -0
- package/docs/reference/upgrading.md +312 -0
- package/package.json +4 -3
- package/dist/admin/shell/IconRail.js +0 -109
|
@@ -67,6 +67,8 @@ export type AdminGlobal = {
|
|
|
67
67
|
settings: Record<string, AdminField>;
|
|
68
68
|
styles: Record<string, AdminField>;
|
|
69
69
|
css: boolean;
|
|
70
|
+
/** Every page renders it through the layout (global.ts `layout`): the item's canvas shows the home page. */
|
|
71
|
+
layout?: true;
|
|
70
72
|
};
|
|
71
73
|
/** A registered Element (spec 2026-09-08 block nodes §1): a section shape plus its contexts — no styles, no css. */
|
|
72
74
|
export type AdminElement = Omit<AdminSection, "styles" | "css"> & {
|
package/dist/admin/serialize.js
CHANGED
|
@@ -108,6 +108,7 @@ export function serializeAdminConfig(config) {
|
|
|
108
108
|
settings: serializeMap(s.section !== undefined ? sectionSettings(config, s.section) : {}),
|
|
109
109
|
styles: serializeMap(s.section !== undefined ? config.resolvedStyles(s.section) : {}),
|
|
110
110
|
css: s.section !== undefined ? sectionCss(config, s.section) : false,
|
|
111
|
+
...(s.layout ? { layout: true } : {}),
|
|
111
112
|
})),
|
|
112
113
|
custom: {
|
|
113
114
|
name: CUSTOM_NODE_TYPE,
|
|
@@ -8,9 +8,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
8
8
|
// never reloads (spec 2026-09-08 client routing). The shell is mounted by
|
|
9
9
|
// the prebuilt entry (browser/main.tsx) into the admin's one HTML page (page.ts).
|
|
10
10
|
//
|
|
11
|
-
// Layout
|
|
12
|
-
//
|
|
13
|
-
// 48px top bar (the editor adds the third, its inspector). The shell is
|
|
11
|
+
// Layout: the navigation bar across the top (shell/NavBar.tsx; the 72px
|
|
12
|
+
// icon rail until 2026-11-14), then below it the main column whose views own
|
|
13
|
+
// their own 48px top bar (the editor adds the third, its inspector). The shell is
|
|
14
14
|
// viewport-height; every column scrolls inside itself.
|
|
15
15
|
import { useEffect, useRef, useState } from "react";
|
|
16
16
|
import { makeClientOps } from "../client-ops.js";
|
|
@@ -22,7 +22,7 @@ import { GlobalForm } from "./GlobalForm.js";
|
|
|
22
22
|
import { EditorView } from "../editor/EditorView.js";
|
|
23
23
|
import { SignedOutView } from "./SignedOutView.js";
|
|
24
24
|
import { useSpaceSession } from "./session.js";
|
|
25
|
-
import {
|
|
25
|
+
import { NavBar } from "./NavBar.js";
|
|
26
26
|
import { ConfirmHost } from "../ui/ConfirmDialog.js";
|
|
27
27
|
import { notify } from "../ui/confirm.js";
|
|
28
28
|
import { useRoute } from "./router.js";
|
|
@@ -169,5 +169,5 @@ export function AdminApp({ registry, op, signIn, api, presenceConfig }) {
|
|
|
169
169
|
main = _jsx(MediaScreen, {});
|
|
170
170
|
else
|
|
171
171
|
main = _jsx(ErrorPane, { message: "Not found." });
|
|
172
|
-
return (_jsx(AdminPathProvider, { value: registry.adminPath, children: _jsx(PresenceProvider, { value: presence, children: _jsx(ElementsProvider, { elements: registry.elements, children: _jsx(TimeZoneProvider, { zone: registry.timeZone, children: _jsxs(AssetsProvider, { ops: ops, media: registry.media, upload: upload, children: [_jsxs("div", { className: "sm-shell", children: [_jsx(
|
|
172
|
+
return (_jsx(AdminPathProvider, { value: registry.adminPath, children: _jsx(PresenceProvider, { value: presence, children: _jsx(ElementsProvider, { elements: registry.elements, children: _jsx(TimeZoneProvider, { zone: registry.timeZone, children: _jsxs(AssetsProvider, { ops: ops, media: registry.media, upload: upload, children: [_jsxs("div", { className: "sm-shell", children: [_jsx(NavBar, { registry: registry, active: head, email: email, settings: role !== null, deploy: deploy ? { status: deploy, busy: deployBusy, onDeploy: () => { void onDeploy(); } } : undefined, onSignOut: () => { void session.signOut(); } }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }), _jsx(ConfirmHost, {})] }) }) }) }) }));
|
|
173
173
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx,
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
// One Globals item (global content, spec 2026-09-07 §6), one locale: the
|
|
4
4
|
// entry form's shape without what a singleton never has — no slug, no
|
|
5
5
|
// delete, no add/remove locale (every supported locale exists by
|
|
@@ -11,18 +11,25 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
11
11
|
// bar carries the indicator and the one publish control, publish
|
|
12
12
|
// validates the draft and lists the fields in an amber banner. "Used on
|
|
13
13
|
// N pages" is the refs index's answer.
|
|
14
|
+
//
|
|
15
|
+
// A visual item is edited on a canvas (spec 2026-11-14 sections and the global canvas): when a page shows it —
|
|
16
|
+
// the home page for a layout item, the first page that places it otherwise (shell/global-canvas.ts) — the main
|
|
17
|
+
// column is that page in a frame with the item selected (editor/GlobalCanvas.tsx), and the fields move to the
|
|
18
|
+
// inspector under Content | Settings tabs. A page found not to draw the item is dropped and the next one tried;
|
|
19
|
+
// with none left, and for a Fields item, the page is the form as it always was. Everything this file saves,
|
|
20
|
+
// guards and publishes is the item's: the canvas reads a page and writes nothing of it.
|
|
14
21
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
15
22
|
import { notePublished } from "../delivery-mode.js";
|
|
16
23
|
import { stillFailing, validateNode } from "../validate.js";
|
|
17
24
|
import { elementSchemas } from "../../richtext-walk.js";
|
|
18
25
|
import { CSS_KEY, SETTINGS_KEY, STYLES_KEY } from "../../global.js";
|
|
19
|
-
import { tabsFor } from "../forms/tabs.js";
|
|
26
|
+
import { tabOf, tabsFor } from "../forms/tabs.js";
|
|
20
27
|
import { revealField } from "../forms/reveal.js";
|
|
21
28
|
import { useRefOptions } from "../forms/useRefOptions.js";
|
|
22
29
|
import { useDraftAutosave } from "../editor/autosave.js";
|
|
23
30
|
import { updatedLine } from "../ui/format.js";
|
|
24
31
|
import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
|
|
25
|
-
import { Banner, Breadcrumb, FieldErrorList, InfoRow, SaveIndicator, SectionLabel, TopBar } from "../ui/primitives.js";
|
|
32
|
+
import { Banner, Breadcrumb, FieldErrorList, InfoRow, SaveIndicator, SectionLabel, Tabs, TopBar } from "../ui/primitives.js";
|
|
26
33
|
import { PublishControl } from "../ui/PublishControl.js";
|
|
27
34
|
import { HistoryModal, historyRows } from "../ui/HistoryModal.js";
|
|
28
35
|
import { localeStatusOf } from "./pages-tree.js";
|
|
@@ -39,7 +46,9 @@ import { DriftNotice } from "../ui/DriftNotice.js";
|
|
|
39
46
|
import { OrphanFields } from "../forms/OrphanFields.js";
|
|
40
47
|
import { entryDrift } from "../drift.js";
|
|
41
48
|
import { misfitErrors } from "../drift-copy.js";
|
|
42
|
-
import { unsetAtPath } from "../field-paths.js";
|
|
49
|
+
import { setAtPath, unsetAtPath } from "../field-paths.js";
|
|
50
|
+
import { GlobalCanvas } from "../editor/GlobalCanvas.js";
|
|
51
|
+
import { canvasCandidates, liveCandidates } from "./global-canvas.js";
|
|
43
52
|
import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "./presence-context.js";
|
|
44
53
|
import { editorsOf } from "./presence.js";
|
|
45
54
|
import { PresenceLine } from "../ui/PresenceLine.js";
|
|
@@ -63,6 +72,26 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
63
72
|
const [stale, setStale] = useState(null);
|
|
64
73
|
const [liveFindings, setLiveFindings] = useState([]);
|
|
65
74
|
const formRef = useRef(null);
|
|
75
|
+
// The canvas (see the header): the page list a visual item's candidates are read from (null while it loads; a
|
|
76
|
+
// failed load is an empty list — the form shows and the fields stay usable), the pages found not to draw the
|
|
77
|
+
// item, the editor's pick among the rest, the inspector's tab and the field with an open inline session.
|
|
78
|
+
const visual = item?.section !== undefined;
|
|
79
|
+
const [sitePages, setSitePages] = useState(null);
|
|
80
|
+
const [emptyPages, setEmptyPages] = useState(() => new Set());
|
|
81
|
+
const [pickedPage, setPickedPage] = useState(null);
|
|
82
|
+
const [tab, setTab] = useState("Content");
|
|
83
|
+
const [inlinePath, setInlinePath] = useState(null);
|
|
84
|
+
const canvasRef = useRef(null);
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!visual)
|
|
87
|
+
return;
|
|
88
|
+
let live = true;
|
|
89
|
+
ops.pages.list().then((r) => { if (live)
|
|
90
|
+
setSitePages(r.ok ? r.data : []); }).catch(() => { if (live)
|
|
91
|
+
setSitePages([]); });
|
|
92
|
+
return () => { live = false; };
|
|
93
|
+
}, [ops, visual]);
|
|
94
|
+
useEffect(() => { setEmptyPages(new Set()); setPickedPage(null); setInlinePath(null); }, [name, locale]);
|
|
66
95
|
const fieldsRef = useRef(fields);
|
|
67
96
|
fieldsRef.current = fields;
|
|
68
97
|
const recordRef = useRef(record);
|
|
@@ -122,6 +151,21 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
122
151
|
setNotice({ tone: "error", text: "Couldn't reach the server." }); });
|
|
123
152
|
return () => { cancelled = true; };
|
|
124
153
|
}, [ops, name, locale, reloadKey]);
|
|
154
|
+
// The frame shows the item's draft: every write of it — a save, a discard, a restore, someone else's version
|
|
155
|
+
// loaded — is a new saved time on the row, and the frame renders again. The first value is the load itself.
|
|
156
|
+
const draftSavedAt = record?.locales[locale]?.draftSavedAt;
|
|
157
|
+
const lastDraft = useRef(null);
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
const key = `${name}:${locale}`;
|
|
160
|
+
const prev = lastDraft.current;
|
|
161
|
+
lastDraft.current = { key, at: draftSavedAt };
|
|
162
|
+
if (prev && prev.key === key && prev.at !== undefined && prev.at !== draftSavedAt)
|
|
163
|
+
canvasRef.current?.refresh();
|
|
164
|
+
}, [name, locale, draftSavedAt]);
|
|
165
|
+
// The freeze (spec 2026-09-26 concurrency §4) ends an inline session with it: nothing typed after it is kept.
|
|
166
|
+
useEffect(() => { if (stale)
|
|
167
|
+
canvasRef.current?.endInline(); }, [stale]);
|
|
168
|
+
const candidates = useMemo(() => (item && sitePages ? canvasCandidates(item, usage, sitePages, locale, registry) : []), [item, usage, sitePages, locale, registry]);
|
|
125
169
|
// Drift (spec 2026-09-27 editor safety §5): the draft on screen against the registry.
|
|
126
170
|
const findings = useMemo(() => (fields && record?.locales[locale]
|
|
127
171
|
? entryDrift(registry, name, { fields, shared: record.fields, own: record.locales[locale].fields })
|
|
@@ -308,7 +352,35 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
308
352
|
setDirty(true);
|
|
309
353
|
setTouched(true);
|
|
310
354
|
mark();
|
|
355
|
+
// Typed text shows in the frame at once; the refresh follows the save.
|
|
356
|
+
if (ns === "fields" && typeof value === "string")
|
|
357
|
+
canvasRef.current?.writeText(key, value);
|
|
358
|
+
};
|
|
359
|
+
/** A value typed in the frame (an inline session), by dotted path inside the item's fields. */
|
|
360
|
+
const onCanvasPatch = (path, value) => {
|
|
361
|
+
if (stale)
|
|
362
|
+
return;
|
|
363
|
+
setFields((f) => setAtPath(f ?? {}, path, value));
|
|
364
|
+
setDirty(true);
|
|
365
|
+
setTouched(true);
|
|
366
|
+
mark();
|
|
367
|
+
};
|
|
368
|
+
// The page the canvas shows: the editor's pick while it still draws the item, else the first that may.
|
|
369
|
+
const livePages = liveCandidates(candidates, emptyPages);
|
|
370
|
+
const shown = record ? livePages.find((c) => c.pageId === pickedPage) ?? livePages[0] ?? null : null;
|
|
371
|
+
const noneShows = candidates.length > 0 && livePages.length === 0;
|
|
372
|
+
const tabNames = tabs.map((t) => t.tab);
|
|
373
|
+
const activeTab = tabNames.includes(tab) ? tab : tabNames[0] ?? "Content";
|
|
374
|
+
/** A field named by a publish error: its tab first (the inspector shows one tab at a time), then the widget. */
|
|
375
|
+
const revealInInspector = (path) => {
|
|
376
|
+
const wanted = tabOf(item, path.startsWith("settings.") || path.startsWith("styles.") ? path.split(".")[1] ?? path : path);
|
|
377
|
+
if (wanted && wanted !== activeTab)
|
|
378
|
+
setTab(wanted);
|
|
379
|
+
requestAnimationFrame(() => { if (formRef.current)
|
|
380
|
+
revealField(formRef.current, path); });
|
|
311
381
|
};
|
|
382
|
+
const usedOn = record && (_jsxs("p", { className: "sm-hint", children: [pages.size === 0 ? "Not placed on any page yet." : `Used on ${pages.size} page${pages.size === 1 ? "" : "s"}: `, [...pages].map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: adminHref(`/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`), children: id })] }, id)))] }));
|
|
383
|
+
const documentInfo = record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Kind", value: item.section !== undefined ? `Visual · ${item.section}` : "Fields" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] }));
|
|
312
384
|
// An orphan's path names a visual item's namespaces as drift does (`settings.x`); the stored keys are `$settings` / `$styles`.
|
|
313
385
|
const storedPath = (p) => p.startsWith("settings.") ? `${SETTINGS_KEY}.${p.slice(9)}` : p.startsWith("styles.") ? `${STYLES_KEY}.${p.slice(7)}` : p;
|
|
314
386
|
const clearOrphan = (path) => {
|
|
@@ -319,10 +391,11 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
319
391
|
};
|
|
320
392
|
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: adminHref("/globals"), items: [
|
|
321
393
|
{
|
|
322
|
-
label: "
|
|
394
|
+
label: "Sections", href: adminHref("/globals"),
|
|
323
395
|
trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: (l) => { void switchLocale(l); }, onAdd: () => undefined })),
|
|
324
396
|
},
|
|
325
397
|
{ label: item.title },
|
|
326
|
-
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error" || saveState === "refused") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "item" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-
|
|
327
|
-
|
|
398
|
+
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error" || saveState === "refused") && _jsx(SaveIndicator, { state: saveState, pending: dirty }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "item" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsx(DriftNotice, { draft: findings, live: liveFindings }), shown ? (_jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-globalcanvas", children: [_jsxs("div", { className: "sm-globalcanvas__bar", children: [pages.size > 0 ? usedOn : _jsx("p", { className: "sm-hint", children: item.layout ? "Shown on every page." : `Shown on ${shown.title}.` }), livePages.length > 1 && (_jsxs("label", { className: "sm-globalcanvas__pick", children: [_jsx("span", { className: "sm-hint", children: "Shown on" }), _jsx("select", { className: "sm-select", "aria-label": "Shown on", value: shown.pageId, onChange: (e) => setPickedPage(e.target.value), children: livePages.map((c) => _jsx("option", { value: c.pageId, children: c.title }, c.pageId)) })] }))] }), _jsx(GlobalCanvas, { ref: canvasRef, ops: ops, item: item, pageId: shown.pageId, locale: locale, fields: fields ?? {}, onPatch: onCanvasPatch, onInlinePath: setInlinePath, onMissing: () => setEmptyPages((prev) => new Set(prev).add(shown.pageId)) }, `${shown.pageId}:${locale}:${reloadKey}`)] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", children: [_jsxs("div", { className: "sm-card", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), revealKey: `${notice.tone}:${notice.text}`, children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: revealInInspector })] })), tabNames.length > 1 && _jsx(Tabs, { tabs: tabNames, value: activeTab, onChange: setTab }), fields !== null ? (_jsxs(_Fragment, { children: [_jsx(GlobalFields, { item: item, groups: tabs.find((t) => t.tab === activeTab)?.groups ?? [], fields: fields, errors: markerErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange, inlinePath: inlinePath ?? undefined, onEditHere: () => { const p = inlinePath; canvasRef.current?.endInline(); if (p)
|
|
399
|
+
revealInInspector(p); } }), _jsx(OrphanFields, { orphans: findings, onClear: clearOrphan })] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), documentInfo] })] })) : (_jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), revealKey: `${notice.tone}:${notice.text}`, children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
|
|
400
|
+
revealField(formRef.current, path); } })] })), usedOn, noneShows && _jsx("p", { className: "sm-hint", children: "No page shows this item in this locale." }), fields !== null ? (_jsxs(_Fragment, { children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: fields, errors: markerErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: fields, errors: markerErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange })] })), _jsx(OrphanFields, { orphans: findings, onClear: clearOrphan })] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsx("aside", { className: "sm-inspector", children: documentInfo })] }))] }));
|
|
328
401
|
}
|
|
@@ -38,7 +38,7 @@ export function GlobalList({ ops, registry }) {
|
|
|
38
38
|
if (error)
|
|
39
39
|
return _jsx(ErrorPane, { message: error });
|
|
40
40
|
const rows = items ? globalRows(items, registry, locale) : [];
|
|
41
|
-
return (_jsx(ListView, { title: "
|
|
41
|
+
return (_jsx(ListView, { title: "Sections", titleTrailing: multilingual && _jsx(LocalePicker, { locales: registry.locales.supported, value: locale, onChange: pickLocale }), columns: [
|
|
42
42
|
{ label: "Title", width: "minmax(160px,1fr)" },
|
|
43
43
|
{ label: "Kind", width: "90px" },
|
|
44
44
|
{ label: "Updated", width: "minmax(110px,160px)" },
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { DeployStatus } from "../ops.ts";
|
|
2
2
|
import type { AdminRegistry } from "../serialize.ts";
|
|
3
|
-
export declare function
|
|
3
|
+
export declare function NavBar({ registry, active, email, settings, deploy, onSignOut }: {
|
|
4
4
|
registry: AdminRegistry;
|
|
5
5
|
active: string;
|
|
6
6
|
email?: string;
|
|
7
|
-
/** Whether
|
|
7
|
+
/** Whether Settings shows — once the role is known, in the shell (every member has a Settings page). */
|
|
8
8
|
settings?: boolean;
|
|
9
|
-
/** The Deploy
|
|
9
|
+
/** The Deploy item (spec 2026-10-09 static mode §6), next to the avatar on a static site; absent on a live one. */
|
|
10
10
|
deploy?: {
|
|
11
11
|
status: DeployStatus;
|
|
12
12
|
busy: boolean;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// packages/smoodly/src/admin/shell/NavBar.tsx
|
|
4
|
+
// The admin's primary navigation: a bar across the top of the frame
|
|
5
|
+
// (2026-11-14), which replaced the 72px IconRail (itself the 200px SideNav's
|
|
6
|
+
// successor) so the visual editor gets the full width of the window. The
|
|
7
|
+
// colours, icons and the order of the items are the rail's; the bar stays put
|
|
8
|
+
// while the sheet below it scrolls, since the shell owns the viewport.
|
|
9
|
+
//
|
|
10
|
+
// Three groups. On the left the app icon, which opens the Smoodly menu. In the
|
|
11
|
+
// middle the items, registry-derived and never separately configured: Pages,
|
|
12
|
+
// then an item for each collection registered with `admin.showInNav`
|
|
13
|
+
// (2026-11-13), then Collections — one item whose menu lists every other
|
|
14
|
+
// collection, absent when none is left — then Media, then ⋮, the More menu,
|
|
15
|
+
// which holds what is opened rarely: Sections (the global content items, when
|
|
16
|
+
// the registry has any; the route is /globals) and Settings (every member has
|
|
17
|
+
// a page there, so the shell passes `settings` once the role is known); ⋮ is
|
|
18
|
+
// absent when it would be empty. On the right the Deploy item (a static site
|
|
19
|
+
// only) next to the avatar, which opens the account menu: the email, the theme
|
|
20
|
+
// toggle, "My Account" (at smoodly.io) and Sign out (spec 2026-10-16 sign-in
|
|
21
|
+
// and the dashboard §6).
|
|
22
|
+
//
|
|
23
|
+
// Menus: click toggles, Esc closes and returns focus to the trigger, ↑/↓ move
|
|
24
|
+
// between entries, a pointer down outside closes. Collections also opens on
|
|
25
|
+
// hover. A child being active highlights its parent and the entry inside the
|
|
26
|
+
// menu. Collections and More are dark, the bar's own colours, since they
|
|
27
|
+
// carry the bar's items; the others float on the sheet white.
|
|
28
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
29
|
+
import { ChevronDown, EllipsisVertical, ExternalLink, Image, Layers, LayoutPanelTop, LibraryBig, PanelTop, RadioTower, Settings, } from "lucide-react";
|
|
30
|
+
import { tileCopy } from "./deploy-tile.js";
|
|
31
|
+
import { ThemeToggle } from "../ui/primitives.js";
|
|
32
|
+
import { useAdminHref } from "./base.js";
|
|
33
|
+
import { consoleLinks } from "./console-links.js";
|
|
34
|
+
import { SMOODLY_VERSION } from "../../version.js";
|
|
35
|
+
import { RouteLink } from "./RouteLink.js";
|
|
36
|
+
/** Lucide at the bar's size and stroke (the item's CSS restates both). */
|
|
37
|
+
const ICON = { size: 18, strokeWidth: 2, "aria-hidden": true };
|
|
38
|
+
/** The app icon, inlined: a package can't ship a file into the host app's
|
|
39
|
+
* /public. The wordmark's "s" on a rounded square (chosen 2026-10-23; the
|
|
40
|
+
* same tile, green with a white s, is smoodly.io's favicon). The square takes
|
|
41
|
+
* --logo-outer and the glyph --logo-inner, so a look decides its colours. */
|
|
42
|
+
function AppIcon() {
|
|
43
|
+
return (_jsxs("svg", { className: "sm-navbar__logo", viewBox: "0 0 64 64", fill: "none", xmlns: "http://www.w3.org/2000/svg", role: "img", "aria-label": "Smoodly", children: [_jsx("rect", { width: "64", height: "64", rx: "14", style: { fill: "var(--logo-outer)" } }), _jsx("path", { style: { fill: "var(--logo-inner)" }, transform: "translate(14.382 -4.365) scale(0.7123)", d: "M30.5851 22.9763C36.7891 22.9763 45.6052 24.6681 49.3603 26.5237L46.2583 42.2415C41.6869 40.113 35.2653 38.4212 29.4422 38.4212C26.0137 38.4212 23.7825 39.1852 23.7825 40.9862C23.7825 42.1869 24.9797 42.8964 27.1021 43.333L31.5647 44.3153C40.8162 46.3346 45.5508 51.1373 45.5508 58.505C45.5508 73.4587 33.9592 79.1345 18.939 79.1345C11.4833 79.1345 4.24536 77.3335 0.109375 75.6963L3.42905 58.9416C8.97998 61.8341 16.4356 63.6351 21.2247 63.6351C24.6532 63.6351 26.9933 62.7619 26.9933 60.6334C26.9933 59.4327 26.1226 58.505 23.5648 58.0684L19.7553 57.3043C12.5173 55.9399 6.31336 50.9735 6.31336 43.0055C6.31336 30.6169 15.6737 23.0309 30.6939 23.0309L30.5851 22.9763Z" })] }));
|
|
44
|
+
}
|
|
45
|
+
/** A menu link that opens smoodly.io in a new tab, with the arrow that says so. */
|
|
46
|
+
function ExtLink({ href, onClick, children }) {
|
|
47
|
+
return (_jsxs("a", { role: "menuitem", className: "sm-navbar__menu-item", href: href, target: "_blank", rel: "noreferrer", onClick: onClick, children: [_jsx("span", { children: children }), _jsx(ExternalLink, { className: "sm-navbar__ext", size: 13, strokeWidth: 1.75, "aria-hidden": "true" })] }));
|
|
48
|
+
}
|
|
49
|
+
export function NavBar({ registry, active, email, settings = true, deploy, onSignOut }) {
|
|
50
|
+
const adminHref = useAdminHref();
|
|
51
|
+
const root = useRef(null);
|
|
52
|
+
const uid = useId();
|
|
53
|
+
const [open, setOpen] = useState(null);
|
|
54
|
+
// Collections opens on hover as well (spec 2026-09-15 rail tweaks): a hover
|
|
55
|
+
// open closes when the pointer leaves and never steals focus; a click open
|
|
56
|
+
// is sticky and moves focus into the menu for the keyboard.
|
|
57
|
+
const [hovered, setHovered] = useState(false);
|
|
58
|
+
const menuId = (key) => `sm-navbar-menu${uid}${key}`;
|
|
59
|
+
const toggle = (key) => setOpen((cur) => (cur === key ? null : key));
|
|
60
|
+
const close = () => setOpen(null);
|
|
61
|
+
const hoverLeave = () => setHovered(false);
|
|
62
|
+
// A click open moves focus into the menu; a pointer down outside closes it.
|
|
63
|
+
// Outside means outside the menu and its trigger — the rest of the bar
|
|
64
|
+
// counts too (2026-09-26); the trigger is left to its own toggle.
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!open || !root.current)
|
|
67
|
+
return;
|
|
68
|
+
if (!hovered)
|
|
69
|
+
root.current.querySelector(`#${CSS.escape(menuId(open))} [role=menuitem]`)?.focus();
|
|
70
|
+
const onPointerDown = (e) => {
|
|
71
|
+
if (!(e.target instanceof Node))
|
|
72
|
+
return;
|
|
73
|
+
const menu = root.current?.querySelector(`#${CSS.escape(menuId(open))}`);
|
|
74
|
+
const trigger = root.current?.querySelector(`[aria-controls="${menuId(open)}"]`);
|
|
75
|
+
if (menu?.contains(e.target) || trigger?.contains(e.target))
|
|
76
|
+
return;
|
|
77
|
+
close();
|
|
78
|
+
};
|
|
79
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
80
|
+
return () => document.removeEventListener("pointerdown", onPointerDown);
|
|
81
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
82
|
+
}, [open]);
|
|
83
|
+
const onKeyDown = (e) => {
|
|
84
|
+
if (!open || !root.current)
|
|
85
|
+
return;
|
|
86
|
+
if (e.key === "Escape") {
|
|
87
|
+
close();
|
|
88
|
+
root.current.querySelector(`[aria-controls="${menuId(open)}"]`)?.focus();
|
|
89
|
+
}
|
|
90
|
+
else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
91
|
+
const items = Array.from(root.current.querySelectorAll(`#${CSS.escape(menuId(open))} [role=menuitem]`));
|
|
92
|
+
if (!items.length)
|
|
93
|
+
return;
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
const i = items.indexOf(document.activeElement);
|
|
96
|
+
items[(i + (e.key === "ArrowDown" ? 1 : -1) + items.length) % items.length].focus();
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const link = (key, label, icon, href) => (_jsx("li", { className: "sm-navbar__item", children: _jsxs(RouteLink, { className: "sm-navbar__btn", href: href, "aria-current": key === active ? "page" : undefined, onClick: close, children: [icon, _jsx("span", { className: "sm-navbar__label", title: label, children: label })] }) }, key));
|
|
100
|
+
/** An entry of a dark menu: the item's icon and its label, a route link. */
|
|
101
|
+
const menuLink = (key, label, icon, href) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-navbar__menu-item", href: href, "aria-current": key === active ? "page" : undefined, onClick: () => { close(); hoverLeave(); }, children: _jsxs("span", { className: "sm-navbar__menu-label", children: [icon, _jsx("span", { children: label })] }) }) }, key));
|
|
102
|
+
// A collection with `admin.showInNav` is an item of its own; the rest stay in the Collections menu.
|
|
103
|
+
const pinned = registry.collections.filter((c) => c.inNav);
|
|
104
|
+
const collections = registry.collections.filter((c) => !c.inNav);
|
|
105
|
+
const collectionActive = collections.some((c) => c.name === active);
|
|
106
|
+
const collectionsOpen = open === "collections" || (hovered && open === null);
|
|
107
|
+
// What ⋮ holds, in the rail's order.
|
|
108
|
+
const more = [
|
|
109
|
+
...(registry.globals.length > 0 ? [{ key: "globals", label: "Sections", icon: _jsx(LayoutPanelTop, { ...ICON }), href: adminHref("/globals") }] : []),
|
|
110
|
+
...(settings ? [{ key: "settings", label: "Settings", icon: _jsx(Settings, { ...ICON }), href: adminHref("/settings") }] : []),
|
|
111
|
+
];
|
|
112
|
+
const moreActive = more.some((m) => m.key === active);
|
|
113
|
+
const name = email?.split("@")[0] ?? "Editor";
|
|
114
|
+
const initials = name.slice(0, 2).toUpperCase();
|
|
115
|
+
return (_jsxs("nav", { className: "sm-navbar", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsx("div", { className: "sm-navbar__start", children: _jsxs("div", { className: "sm-navbar__item", children: [_jsx("button", { type: "button", className: "sm-navbar__brand", title: "Smoodly", "aria-haspopup": "menu", "aria-expanded": open === "brand", "aria-controls": menuId("brand"), onClick: () => toggle("brand"), children: _jsx(AppIcon, {}) }), _jsxs("ul", { className: "sm-navbar__menu sm-navbar__menu--brand", role: "menu", id: menuId("brand"), "data-open": open === "brand", children: [_jsx("li", { role: "none", children: _jsx(ExtLink, { href: consoleLinks().project, onClick: close, children: "This site at smoodly.io" }) }), _jsx("li", { role: "none", children: _jsx(ExtLink, { href: consoleLinks().docs, onClick: close, children: "Documentation" }) }), _jsxs("li", { className: "sm-navbar__menu-version", role: "presentation", children: ["Smoodly ", SMOODLY_VERSION] })] })] }) }), _jsxs("ul", { className: "sm-navbar__list", children: [link("pages", "Pages", _jsx(PanelTop, { ...ICON }), adminHref("/pages")), pinned.map((c) => link(c.name, c.title, _jsx(Layers, { ...ICON }), adminHref(`/${c.name}`))), collections.length > 0 && (_jsxs("li", { className: "sm-navbar__item", onMouseEnter: () => setHovered(true), onMouseLeave: hoverLeave, children: [_jsxs("button", { type: "button", className: "sm-navbar__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": collectionsOpen, "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsx(LibraryBig, { ...ICON }), _jsx("span", { className: "sm-navbar__label", children: "Collections" }), _jsx(ChevronDown, { className: "sm-navbar__chevron", size: 14, strokeWidth: 2, "aria-hidden": "true" })] }), _jsx("ul", { className: "sm-navbar__menu sm-navbar__menu--dark sm-navbar__menu--collections", role: "menu", id: menuId("collections"), "data-open": collectionsOpen, children: collections.map((c) => menuLink(c.name, c.title, _jsx(Layers, { ...ICON }), adminHref(`/${c.name}`))) })] })), link("media", "Media", _jsx(Image, { ...ICON }), adminHref("/media")), more.length > 0 && (_jsxs("li", { className: "sm-navbar__item", children: [_jsx("button", { type: "button", className: "sm-navbar__btn sm-navbar__btn--icon", title: "More", "aria-label": "More", "aria-current": moreActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": open === "more", "aria-controls": menuId("more"), onClick: () => toggle("more"), children: _jsx(EllipsisVertical, { ...ICON }) }), _jsx("ul", { className: "sm-navbar__menu sm-navbar__menu--dark", role: "menu", id: menuId("more"), "data-open": open === "more", children: more.map((m) => menuLink(m.key, m.label, m.icon, m.href)) })] }))] }), _jsxs("div", { className: "sm-navbar__end", children: [deploy && (() => {
|
|
116
|
+
const copy = tileCopy(deploy.status, Date.now());
|
|
117
|
+
const id = menuId("deploy");
|
|
118
|
+
return (_jsxs("div", { className: "sm-navbar__item sm-navbar__deploy", "data-state": deploy.status.state, children: [_jsxs("button", { type: "button", className: `sm-navbar__btn${copy.lit ? " sm-navbar__btn--lit" : ""}${copy.warn ? " sm-navbar__btn--warn" : ""}`, "aria-haspopup": "menu", "aria-expanded": open === "deploy", "aria-controls": id, onClick: () => toggle("deploy"), title: copy.detail, children: [_jsxs("span", { className: "sm-navbar__icon", children: [_jsx(RadioTower, { ...ICON }), copy.badge && _jsx("span", { className: "sm-navbar__badge", "aria-label": `${copy.badge} changes waiting`, children: copy.badge })] }), _jsx("span", { className: "sm-navbar__label", children: copy.label })] }), _jsxs("ul", { className: "sm-navbar__menu sm-navbar__menu--end", role: "menu", id: id, "data-open": open === "deploy", children: [_jsx("li", { className: "sm-navbar__menu-detail", role: "presentation", children: copy.detail }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-navbar__menu-item", disabled: copy.action === null || deploy.busy, title: copy.disabledReason ?? undefined, onClick: () => { close(); deploy.onDeploy(); }, children: copy.action === "retry" ? "Deploy again" : "Deploy now" }) }), copy.disabledReason && _jsx("li", { className: "sm-navbar__menu-note", role: "presentation", children: copy.disabledReason })] })] }));
|
|
119
|
+
})(), _jsxs("div", { className: "sm-navbar__item sm-navbar__foot", children: [_jsx("button", { type: "button", className: "sm-navbar__user", title: name, "aria-haspopup": "menu", "aria-expanded": open === "user", "aria-controls": menuId("user"), onClick: () => toggle("user"), children: initials }), _jsxs("ul", { className: "sm-navbar__menu sm-navbar__menu--end", role: "menu", id: menuId("user"), "data-open": open === "user", children: [_jsx("li", { className: "sm-navbar__menu-title", role: "presentation", children: email ?? name }), _jsx("li", { role: "none", children: _jsx(ThemeToggle, {}) }), _jsx("li", { role: "none", children: _jsx(ExtLink, { href: consoleLinks().account, onClick: close, children: "My Account" }) }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-navbar__menu-item", onClick: () => { close(); onSignOut(); }, children: "Sign out" }) })] })] })] })] }));
|
|
120
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { EntryUsage } from "../../entry-store.ts";
|
|
2
|
+
import type { PageTree } from "../../page-context.ts";
|
|
3
|
+
import type { PageRecord } from "../../store.ts";
|
|
4
|
+
import type { BridgeRect } from "../editor/protocol.ts";
|
|
5
|
+
import type { AdminGlobal, AdminRegistry } from "../serialize.ts";
|
|
6
|
+
export type CanvasCandidate = {
|
|
7
|
+
pageId: string;
|
|
8
|
+
title: string;
|
|
9
|
+
};
|
|
10
|
+
type HomeRule = {
|
|
11
|
+
homePageSlug: string;
|
|
12
|
+
locales: Pick<AdminRegistry["locales"], "default">;
|
|
13
|
+
};
|
|
14
|
+
/** The pages that may show `item` in `locale`, best first. A Fields item: none. A layout item: the site root (as
|
|
15
|
+
* pages-tree.ts names it: a root record whose default-locale slug is the home slug). Any other visual item: each
|
|
16
|
+
* distinct page of `usage`, in usage order. Only a page (not a folder) with a row in `locale` counts; a usage row
|
|
17
|
+
* carries no locale, so a candidate may still not draw the item — the frame decides (`globalNodeIn`). */
|
|
18
|
+
export declare function canvasCandidates(item: AdminGlobal, usage: EntryUsage[], pages: PageRecord[], locale: string, registry: HomeRule): CanvasCandidate[];
|
|
19
|
+
/** The node that draws the item in this frame, or null: the layout id when the frame reported it; else the first
|
|
20
|
+
* placement of the item in document order (zones as the tree lists them) that the frame drew. */
|
|
21
|
+
export declare function globalNodeIn(name: string, rects: BridgeRect[], tree: PageTree | null): string | null;
|
|
22
|
+
/** Whether a frame's report may be judged: the bridge committed a render and reported geometry after it. Before
|
|
23
|
+
* that, "no node" means "not yet" — the bridge posts its geometry more than once as a page settles. */
|
|
24
|
+
export declare function settled(seen: {
|
|
25
|
+
rendered: boolean;
|
|
26
|
+
rectsAfterRendered: boolean;
|
|
27
|
+
}): boolean;
|
|
28
|
+
/** How long a settled frame's reports must have been quiet before "no node" is believed. */
|
|
29
|
+
export declare const MISS_QUIET_MS = 1500;
|
|
30
|
+
/** Whether a frame does not draw the item: it may be judged, it holds no node of the item, and nothing has been
|
|
31
|
+
* reported for `MISS_QUIET_MS`. The bridge's first report follows its first render at once, so an item that
|
|
32
|
+
* streams in after it (a footer under Suspense, an Astro server island) is in a later report: the quiet period
|
|
33
|
+
* waits for that one. */
|
|
34
|
+
export declare function missing(seen: {
|
|
35
|
+
rendered: boolean;
|
|
36
|
+
rectsAfterRendered: boolean;
|
|
37
|
+
}, nodeId: string | null, quietForMs: number): boolean;
|
|
38
|
+
/** The candidates without the pages already found not to draw the item, order kept. */
|
|
39
|
+
export declare function liveCandidates(candidates: CanvasCandidate[], empty: ReadonlySet<string>): CanvasCandidate[];
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { globalLink, globalNodeId } from "../../global.js";
|
|
2
|
+
/** The pages that may show `item` in `locale`, best first. A Fields item: none. A layout item: the site root (as
|
|
3
|
+
* pages-tree.ts names it: a root record whose default-locale slug is the home slug). Any other visual item: each
|
|
4
|
+
* distinct page of `usage`, in usage order. Only a page (not a folder) with a row in `locale` counts; a usage row
|
|
5
|
+
* carries no locale, so a candidate may still not draw the item — the frame decides (`globalNodeIn`). */
|
|
6
|
+
export function canvasCandidates(item, usage, pages, locale, registry) {
|
|
7
|
+
if (item.section === undefined)
|
|
8
|
+
return [];
|
|
9
|
+
const shows = (p) => !!p && p.template !== null && p.locales[locale] !== undefined;
|
|
10
|
+
const candidate = (p) => ({ pageId: p.id, title: p.locales[locale].title });
|
|
11
|
+
if (item.layout) {
|
|
12
|
+
const root = pages.find((p) => p.parentId === null && p.locales[registry.locales.default]?.slug === registry.homePageSlug);
|
|
13
|
+
return shows(root) ? [candidate(root)] : [];
|
|
14
|
+
}
|
|
15
|
+
const byId = new Map(pages.map((p) => [p.id, p]));
|
|
16
|
+
const out = [];
|
|
17
|
+
const seen = new Set();
|
|
18
|
+
for (const u of usage) {
|
|
19
|
+
if (u.sourceKind !== "page" || seen.has(u.sourceId))
|
|
20
|
+
continue;
|
|
21
|
+
seen.add(u.sourceId);
|
|
22
|
+
const p = byId.get(u.sourceId);
|
|
23
|
+
if (shows(p))
|
|
24
|
+
out.push(candidate(p));
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
/** The node that draws the item in this frame, or null: the layout id when the frame reported it; else the first
|
|
29
|
+
* placement of the item in document order (zones as the tree lists them) that the frame drew. */
|
|
30
|
+
export function globalNodeIn(name, rects, tree) {
|
|
31
|
+
const drawn = new Set(rects.map((r) => r.id));
|
|
32
|
+
const layoutId = globalNodeId(name);
|
|
33
|
+
if (drawn.has(layoutId))
|
|
34
|
+
return layoutId;
|
|
35
|
+
for (const nodes of Object.values(tree?.zones ?? {})) {
|
|
36
|
+
for (const node of nodes) {
|
|
37
|
+
if (globalLink(node)?.item === name && drawn.has(node.id))
|
|
38
|
+
return node.id;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
/** Whether a frame's report may be judged: the bridge committed a render and reported geometry after it. Before
|
|
44
|
+
* that, "no node" means "not yet" — the bridge posts its geometry more than once as a page settles. */
|
|
45
|
+
export function settled(seen) {
|
|
46
|
+
return seen.rendered && seen.rectsAfterRendered;
|
|
47
|
+
}
|
|
48
|
+
/** How long a settled frame's reports must have been quiet before "no node" is believed. */
|
|
49
|
+
export const MISS_QUIET_MS = 1500;
|
|
50
|
+
/** Whether a frame does not draw the item: it may be judged, it holds no node of the item, and nothing has been
|
|
51
|
+
* reported for `MISS_QUIET_MS`. The bridge's first report follows its first render at once, so an item that
|
|
52
|
+
* streams in after it (a footer under Suspense, an Astro server island) is in a later report: the quiet period
|
|
53
|
+
* waits for that one. */
|
|
54
|
+
export function missing(seen, nodeId, quietForMs) {
|
|
55
|
+
return settled(seen) && nodeId === null && quietForMs >= MISS_QUIET_MS;
|
|
56
|
+
}
|
|
57
|
+
/** The candidates without the pages already found not to draw the item, order kept. */
|
|
58
|
+
export function liveCandidates(candidates, empty) {
|
|
59
|
+
return candidates.filter((c) => !empty.has(c.pageId));
|
|
60
|
+
}
|
|
@@ -2,7 +2,7 @@ import { SEVERITY, groupKey } from "../drift.js";
|
|
|
2
2
|
import { driftHeadline } from "../drift-copy.js";
|
|
3
3
|
/** A group shows this many rows, then "Show all N". */
|
|
4
4
|
export const GROUP_PREVIEW = 10;
|
|
5
|
-
const kindLabel = (doc, registry) => doc.kind === "page" ? "Page" : doc.kind === "global" ? "
|
|
5
|
+
const kindLabel = (doc, registry) => doc.kind === "page" ? "Page" : doc.kind === "global" ? "Sections" : registry.collections.find((c) => c.name === doc.collection)?.title ?? doc.collection ?? "";
|
|
6
6
|
const href = (doc, locale) => {
|
|
7
7
|
const q = `?locale=${encodeURIComponent(locale)}`;
|
|
8
8
|
if (doc.kind === "page")
|
|
@@ -40,7 +40,7 @@ export function ThemeToggle() {
|
|
|
40
40
|
}
|
|
41
41
|
catch { /* not remembered */ }
|
|
42
42
|
};
|
|
43
|
-
return (_jsxs("button", { type: "button", role: "menuitem", className: "sm-
|
|
43
|
+
return (_jsxs("button", { type: "button", role: "menuitem", className: "sm-navbar__menu-item", onClick: toggle, children: [_jsxs("span", { className: "sm-when-light", children: [_jsx(Moon, { ...ICON }), "Dark mode"] }), _jsxs("span", { className: "sm-when-dark", children: [_jsx(Sun, { ...ICON }), "Light mode"] })] }));
|
|
44
44
|
}
|
|
45
45
|
export function Button({ variant = "primary", size = "md", className, ...rest }) {
|
|
46
46
|
return (_jsx("button", { type: "button", className: cx("sm-btn", `sm-btn--${variant}`, size === "lg" && "sm-btn--lg", size === "sm" && "sm-btn--sm", className), ...rest }));
|