smoodly 0.0.24 → 0.0.26
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 +214 -40
- package/bin/smoodly.js +6 -0
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/editor/EditorView.js +100 -22
- package/dist/admin/editor/InsertPicker.d.ts +14 -0
- package/dist/admin/editor/InsertPicker.js +37 -7
- package/dist/admin/editor/InspectorFields.js +1 -1
- package/dist/admin/editor/Outline.js +2 -2
- package/dist/admin/editor/PageSettings.js +1 -1
- package/dist/admin/editor/SeoSettings.js +1 -1
- package/dist/admin/editor/autosave.d.ts +4 -1
- package/dist/admin/editor/autosave.js +24 -1
- package/dist/admin/editor/global-edit.d.ts +5 -1
- package/dist/admin/editor/global-edit.js +15 -5
- package/dist/admin/editor/locale-url.d.ts +7 -4
- package/dist/admin/editor/locale-url.js +14 -7
- package/dist/admin/editor/useGlobalEdit.d.ts +1 -1
- package/dist/admin/editor/useGlobalEdit.js +1 -1
- package/dist/admin/editor/usePageLocale.js +9 -1
- package/dist/admin/forms/CropEditor.d.ts +4 -2
- package/dist/admin/forms/CropEditor.js +6 -3
- package/dist/admin/forms/DatePicker.d.ts +9 -0
- package/dist/admin/forms/DatePicker.js +133 -0
- package/dist/admin/forms/FieldWidget.js +11 -5
- package/dist/admin/forms/ImagePreview.d.ts +9 -0
- package/dist/admin/forms/ImagePreview.js +11 -0
- package/dist/admin/forms/MediaWidget.d.ts +8 -2
- package/dist/admin/forms/MediaWidget.js +13 -13
- package/dist/admin/forms/RichtextElementCard.js +1 -1
- package/dist/admin/forms/RichtextImageCard.js +14 -9
- package/dist/admin/forms/date-picker.d.ts +44 -0
- package/dist/admin/forms/date-picker.js +117 -0
- package/dist/admin/forms/elements-context.d.ts +3 -0
- package/dist/admin/forms/elements-context.js +7 -2
- package/dist/admin/forms/time-zone-context.d.ts +7 -0
- package/dist/admin/forms/time-zone-context.js +15 -0
- package/dist/admin/forms/useRichtextEditor.js +10 -1
- package/dist/admin/next/bridge.d.ts +2 -1
- package/dist/admin/next/bridge.js +35 -5
- package/dist/admin/next/create-admin.d.ts +4 -0
- package/dist/admin/next/create-admin.js +3 -1
- package/dist/admin/next/index.d.ts +1 -0
- package/dist/admin/ops-impl.js +74 -24
- package/dist/admin/ops.d.ts +31 -12
- package/dist/admin/serialize.d.ts +2 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AdminApp.d.ts +3 -1
- package/dist/admin/shell/AdminApp.js +42 -2
- package/dist/admin/shell/EntriesList.js +17 -22
- package/dist/admin/shell/EntryForm.js +91 -13
- package/dist/admin/shell/GlobalForm.js +68 -9
- package/dist/admin/shell/GlobalList.js +10 -20
- package/dist/admin/shell/IconRail.js +14 -5
- package/dist/admin/shell/InviteModal.js +9 -5
- package/dist/admin/shell/ListView.js +2 -2
- package/dist/admin/shell/MediaScreen.js +11 -1
- package/dist/admin/shell/MembersPage.js +33 -15
- package/dist/admin/shell/PageDialogs.d.ts +26 -0
- package/dist/admin/shell/PageDialogs.js +38 -0
- package/dist/admin/shell/PagesList.js +101 -74
- package/dist/admin/shell/RouteLink.js +1 -1
- package/dist/admin/shell/SchemaNotice.d.ts +4 -0
- package/dist/admin/shell/SchemaNotice.js +4 -0
- package/dist/admin/shell/SettingsView.js +11 -26
- package/dist/admin/shell/live-url.d.ts +4 -3
- package/dist/admin/shell/live-url.js +12 -3
- package/dist/admin/shell/locale-memory.d.ts +8 -0
- package/dist/admin/shell/locale-memory.js +23 -0
- package/dist/admin/shell/members-list.d.ts +2 -0
- package/dist/admin/shell/members-list.js +5 -0
- package/dist/admin/shell/page-dialogs.d.ts +9 -0
- package/dist/admin/shell/page-dialogs.js +19 -0
- package/dist/admin/shell/presence-context.d.ts +9 -0
- package/dist/admin/shell/presence-context.js +35 -0
- package/dist/admin/shell/presence-realtime.d.ts +3 -0
- package/dist/admin/shell/presence-realtime.js +22 -0
- package/dist/admin/shell/presence.d.ts +51 -0
- package/dist/admin/shell/presence.js +90 -0
- package/dist/admin/shell/router.d.ts +10 -7
- package/dist/admin/shell/router.js +18 -9
- package/dist/admin/shell/settings.d.ts +0 -6
- package/dist/admin/shell/settings.js +4 -5
- package/dist/admin/shell/stale.d.ts +28 -0
- package/dist/admin/shell/stale.js +17 -0
- package/dist/admin/tree-ops.js +3 -0
- package/dist/admin/ui/ActionMenu.js +26 -6
- package/dist/admin/ui/Combobox.d.ts +5 -0
- package/dist/admin/ui/Combobox.js +1 -1
- package/dist/admin/ui/ConfirmDialog.d.ts +8 -0
- package/dist/admin/ui/ConfirmDialog.js +30 -0
- package/dist/admin/ui/Dialog.d.ts +13 -0
- package/dist/admin/ui/Dialog.js +54 -0
- package/dist/admin/ui/HistoryModal.d.ts +2 -1
- package/dist/admin/ui/HistoryModal.js +7 -2
- package/dist/admin/ui/LockIcon.d.ts +4 -0
- package/dist/admin/ui/LockIcon.js +12 -0
- package/dist/admin/ui/PresenceLine.d.ts +6 -0
- package/dist/admin/ui/PresenceLine.js +11 -0
- package/dist/admin/ui/ResizeHandle.js +4 -4
- package/dist/admin/ui/StaleBanner.d.ts +5 -0
- package/dist/admin/ui/StaleBanner.js +9 -0
- package/dist/admin/ui/confirm.d.ts +35 -0
- package/dist/admin/ui/confirm.js +57 -0
- package/dist/admin/ui/primitives.d.ts +19 -4
- package/dist/admin/ui/primitives.js +12 -5
- package/dist/admin/ui/resize.d.ts +7 -4
- package/dist/admin/ui/resize.js +7 -4
- package/dist/admin/ui/theme.d.ts +1 -1
- package/dist/admin/ui/theme.js +373 -204
- package/dist/admin/validate.d.ts +4 -0
- package/dist/admin/validate.js +28 -0
- package/dist/cli/load-collections.d.ts +2 -0
- package/dist/cli/load-collections.js +26 -0
- package/dist/cli/main.d.ts +13 -0
- package/dist/cli/main.js +62 -0
- package/dist/cli/migrate.d.ts +23 -0
- package/dist/cli/migrate.js +64 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +9 -0
- package/dist/draft-token.d.ts +31 -0
- package/dist/draft-token.js +41 -0
- package/dist/entry-store.d.ts +11 -8
- package/dist/entry-store.js +24 -10
- package/dist/env.d.ts +9 -0
- package/dist/env.js +19 -0
- package/dist/fields.d.ts +2 -0
- package/dist/fields.js +2 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -2
- package/dist/marks.d.ts +3 -0
- package/dist/marks.js +3 -0
- package/dist/migrations.d.ts +37 -0
- package/dist/migrations.js +73 -0
- package/dist/schema-version.d.ts +17 -0
- package/dist/schema-version.js +32 -0
- package/dist/sql-space.js +17 -0
- package/dist/sql.js +21 -0
- package/dist/store.d.ts +14 -8
- package/dist/store.js +30 -8
- package/dist/supabase-entry-store.d.ts +15 -5
- package/dist/supabase-entry-store.js +87 -22
- package/dist/supabase-store.d.ts +18 -5
- package/dist/supabase-store.js +83 -20
- package/dist/time-zone.d.ts +6 -0
- package/dist/time-zone.js +27 -0
- package/migrations/20260926120000_smoodly_v1.sql +535 -0
- package/migrations/20260926130000_smoodly_v2.sql +26 -0
- package/package.json +13 -3
|
@@ -14,34 +14,23 @@ import { relativeTime } from "../ui/format.js";
|
|
|
14
14
|
import { useSortable } from "../ui/useSortable.js";
|
|
15
15
|
import { useAdminHref } from "./base.js";
|
|
16
16
|
import { navigate } from "./router.js";
|
|
17
|
+
import { confirm } from "../ui/confirm.js";
|
|
17
18
|
import { ErrorPane } from "./ErrorPane.js";
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
try {
|
|
23
|
-
const stored = window.sessionStorage.getItem(LOCALE_KEY);
|
|
24
|
-
if (stored && registry.locales.supported.includes(stored))
|
|
25
|
-
return stored;
|
|
26
|
-
}
|
|
27
|
-
catch {
|
|
28
|
-
// storage unavailable — the default is fine
|
|
29
|
-
}
|
|
30
|
-
return registry.locales.default;
|
|
31
|
-
}
|
|
19
|
+
import { rememberLocale, rememberedLocale } from "./locale-memory.js";
|
|
20
|
+
import { usePresenceState } from "./presence-context.js";
|
|
21
|
+
import { editorsOf } from "./presence.js";
|
|
22
|
+
import { LockIcon } from "../ui/LockIcon.js";
|
|
32
23
|
export function EntriesList({ ops, registry, collection }) {
|
|
33
24
|
const adminHref = useAdminHref();
|
|
34
25
|
const meta = registry.collections.find((c) => c.name === collection);
|
|
35
26
|
const [records, setRecords] = useState(null);
|
|
36
27
|
const [error, setError] = useState(null);
|
|
37
|
-
const [locale, setLocale] = useState(() => rememberedLocale(registry));
|
|
28
|
+
const [locale, setLocale] = useState(() => rememberedLocale(registry.locales));
|
|
29
|
+
const { others } = usePresenceState();
|
|
38
30
|
const multilingual = registry.locales.supported.length > 1;
|
|
39
31
|
const pickLocale = (next) => {
|
|
40
32
|
setLocale(next);
|
|
41
|
-
|
|
42
|
-
window.sessionStorage.setItem(LOCALE_KEY, next);
|
|
43
|
-
}
|
|
44
|
-
catch { /* see rememberedLocale */ }
|
|
33
|
+
rememberLocale(next);
|
|
45
34
|
};
|
|
46
35
|
const load = useCallback(() => ops.entries.list(collection)
|
|
47
36
|
.then((r) => (r.ok ? setRecords(r.data) : setError(r.message)))
|
|
@@ -75,7 +64,7 @@ export function EntriesList({ ops, registry, collection }) {
|
|
|
75
64
|
if (error)
|
|
76
65
|
return _jsx(ErrorPane, { message: error });
|
|
77
66
|
const open = (id) => {
|
|
78
|
-
navigate(adminHref(`/${collection}/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`));
|
|
67
|
+
void navigate(adminHref(`/${collection}/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`));
|
|
79
68
|
};
|
|
80
69
|
/** "+ New entry" creates an empty draft in the locale on screen and opens it (spec 2026-09-14 §4). */
|
|
81
70
|
const createEntry = async () => {
|
|
@@ -97,7 +86,13 @@ export function EntriesList({ ops, registry, collection }) {
|
|
|
97
86
|
if (!record)
|
|
98
87
|
return;
|
|
99
88
|
const from = entrySourceLocale(record, registry);
|
|
100
|
-
|
|
89
|
+
const ok = await confirm({
|
|
90
|
+
title: `Add this entry to ${locale}?`,
|
|
91
|
+
body: `Its ${from} fields are copied as the first ${locale} draft. Nothing is published until you publish it.`,
|
|
92
|
+
confirmLabel: `Add to ${locale}`,
|
|
93
|
+
subject: { name: row.title },
|
|
94
|
+
});
|
|
95
|
+
if (!ok)
|
|
101
96
|
return;
|
|
102
97
|
let result;
|
|
103
98
|
try {
|
|
@@ -122,7 +117,7 @@ export function EntriesList({ ops, registry, collection }) {
|
|
|
122
117
|
key: row.id,
|
|
123
118
|
onClick: () => (row.present ? open(row.id) : void addLocale(row)),
|
|
124
119
|
cells: [
|
|
125
|
-
|
|
120
|
+
_jsxs("span", { className: "sm-cell sm-cell--title", style: dim, children: [row.title, _jsx(LockIcon, { names: editorsOf(others, { kind: "entry", id: row.id, locale }) })] }, "t"),
|
|
126
121
|
row.present
|
|
127
122
|
? _jsx("span", { className: "sm-cell sm-cell--muted", children: relativeTime(row.updatedAt) }, "u")
|
|
128
123
|
: _jsxs("span", { className: "sm-cell sm-cell--muted", children: ["not in ", locale, " \u2014 click to add"] }, "u"),
|
|
@@ -15,7 +15,10 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
15
15
|
// publish control. A draft may be incomplete; publish validates it and
|
|
16
16
|
// lists the fields in an amber banner. The safe-delete guard's message
|
|
17
17
|
// renders verbatim — it is product surface.
|
|
18
|
+
import { Eye } from "lucide-react";
|
|
18
19
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
20
|
+
import { stillFailing, validateFields } from "../validate.js";
|
|
21
|
+
import { elementSchemas } from "../../richtext-walk.js";
|
|
19
22
|
import { FieldWidget } from "../forms/FieldWidget.js";
|
|
20
23
|
import { revealField } from "../forms/reveal.js";
|
|
21
24
|
import { useRefOptions } from "../forms/useRefOptions.js";
|
|
@@ -32,7 +35,14 @@ import { entrySourceLocale, entryTitle, localizedFieldKeys } from "./entries-lis
|
|
|
32
35
|
import { useAdminHref } from "./base.js";
|
|
33
36
|
import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
|
|
34
37
|
import { navigate, useLeaveGuard } from "./router.js";
|
|
35
|
-
import {
|
|
38
|
+
import { LIVE_URL_CONFIRM, liveUrlChanges } from "./live-url.js";
|
|
39
|
+
import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
|
|
40
|
+
import { draftToken } from "../../draft-token.js";
|
|
41
|
+
import { isStale, staleInfoOf } from "./stale.js";
|
|
42
|
+
import { StaleBanner } from "../ui/StaleBanner.js";
|
|
43
|
+
import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "./presence-context.js";
|
|
44
|
+
import { editorsOf } from "./presence.js";
|
|
45
|
+
import { PresenceLine } from "../ui/PresenceLine.js";
|
|
36
46
|
/** A stable empty map for the hooks that run before the "no such collection" return. */
|
|
37
47
|
const NO_FIELDS = {};
|
|
38
48
|
export function EntryForm({ ops, registry, collection, id }) {
|
|
@@ -57,6 +67,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
57
67
|
const [published, setPublished] = useState(false);
|
|
58
68
|
const [history, setHistory] = useState(null);
|
|
59
69
|
const [snapshot, setSnapshot] = useState(null);
|
|
70
|
+
const [stale, setStale] = useState(null);
|
|
60
71
|
const formRef = useRef(null);
|
|
61
72
|
const fieldsRef = useRef(fields);
|
|
62
73
|
fieldsRef.current = fields;
|
|
@@ -65,7 +76,14 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
65
76
|
/** The slug the editor already confirmed as a live-URL change, so the next autosave does not ask again. */
|
|
66
77
|
const confirmedSlug = useRef(null);
|
|
67
78
|
// The page editor's controller: debounced, retrying, flushable (spec 2026-09-14 §3).
|
|
68
|
-
|
|
79
|
+
/** The token of the row on screen — what every guarded op sends (spec 2026-09-26 concurrency §1). */
|
|
80
|
+
const token = () => { const row = recordRef.current?.locales[locale]; return row ? draftToken(row) : null; };
|
|
81
|
+
// Presence (spec 2026-09-26 concurrency §5): this tab on this row, the others on it, every ack announced.
|
|
82
|
+
const target = { kind: "entry", id, locale };
|
|
83
|
+
useTrackPresence(target);
|
|
84
|
+
const { others } = usePresenceState();
|
|
85
|
+
const presence = usePresence();
|
|
86
|
+
const { saveState, mark, flush, reset } = useDraftAutosave({
|
|
69
87
|
read: () => fieldsRef.current,
|
|
70
88
|
persist: async (value) => {
|
|
71
89
|
// A published entry's slug is a live URL: one confirm at the save, Cancel restores it (members management §5).
|
|
@@ -73,16 +91,22 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
73
91
|
const stored = row?.slug ?? null;
|
|
74
92
|
const next = typeof value.slug === "string" ? value.slug : null;
|
|
75
93
|
if (next !== confirmedSlug.current && liveUrlChanges({ status: row?.status, from: stored, to: next })) {
|
|
76
|
-
|
|
94
|
+
const current = recordRef.current;
|
|
95
|
+
const subject = current && meta ? { name: entryTitle(current, meta, registry, locale), path: slugPrefix && stored ? `${slugPrefix}${stored}` : null } : undefined;
|
|
96
|
+
if (await confirm({ ...LIVE_URL_CONFIRM, subject }))
|
|
77
97
|
confirmedSlug.current = next;
|
|
78
98
|
else {
|
|
79
99
|
value = { ...value, slug: stored };
|
|
80
100
|
setFields((f) => ({ ...(f ?? {}), slug: stored }));
|
|
81
101
|
}
|
|
82
102
|
}
|
|
83
|
-
const r = await ops.entries.save(collection, id, locale, value);
|
|
84
|
-
if (r.ok)
|
|
103
|
+
const r = await ops.entries.save(collection, id, locale, value, token());
|
|
104
|
+
if (r.ok) {
|
|
85
105
|
setRecord(r.data);
|
|
106
|
+
presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
|
|
107
|
+
}
|
|
108
|
+
else if (isStale(r))
|
|
109
|
+
setStale(staleInfoOf(r));
|
|
86
110
|
return r;
|
|
87
111
|
},
|
|
88
112
|
onSaved: () => setDirty(false),
|
|
@@ -107,6 +131,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
107
131
|
setFields(null);
|
|
108
132
|
setFieldErrors({});
|
|
109
133
|
setDirty(false);
|
|
134
|
+
setStale(null);
|
|
110
135
|
setTouched(false);
|
|
111
136
|
ops.entries.get(collection, id, locale).then((r) => {
|
|
112
137
|
if (cancelled)
|
|
@@ -127,8 +152,28 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
127
152
|
return _jsx("p", { style: { padding: 32 }, children: "No such collection." });
|
|
128
153
|
const row = record?.locales[locale];
|
|
129
154
|
const status = row ? localeStatusOf(row) : null;
|
|
155
|
+
// A refused publish's field errors clear one by one as the fields are fixed: every change revalidates
|
|
156
|
+
// (the same rules the op applies) and keeps only what still fails; the notice's list shrinks with them.
|
|
157
|
+
const elementDescs = useMemo(() => elementSchemas(registry.elements), [registry]);
|
|
158
|
+
const hasFieldErrors = Object.keys(fieldErrors).length > 0;
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (!hasFieldErrors || !fields || !meta)
|
|
161
|
+
return;
|
|
162
|
+
const fresh = validateFields(fields, meta.fields, elementDescs);
|
|
163
|
+
setFieldErrors((prev) => stillFailing(prev, fresh));
|
|
164
|
+
setNotice((n) => {
|
|
165
|
+
if (!n?.fields)
|
|
166
|
+
return n;
|
|
167
|
+
const left = n.fields.filter((f) => fresh.some((e) => e.field === f.field));
|
|
168
|
+
return left.length === 0 ? null : left.length === n.fields.length ? n : { ...n, fields: left };
|
|
169
|
+
});
|
|
170
|
+
}, [fields, hasFieldErrors, meta, elementDescs]);
|
|
130
171
|
/** An op's failure onto the screen: amber with the fields for a validation result, red otherwise. */
|
|
131
172
|
const fail = (r) => {
|
|
173
|
+
if (isStale(r)) {
|
|
174
|
+
setStale(staleInfoOf(r));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
132
177
|
if (r.code === "validation" && r.fields && r.fields.length > 0) {
|
|
133
178
|
setFieldErrors(Object.fromEntries(r.fields.map((f) => [f.field, f.message])));
|
|
134
179
|
setNotice({ tone: "warn", text: r.message, fields: r.fields });
|
|
@@ -136,6 +181,17 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
136
181
|
else
|
|
137
182
|
setNotice({ tone: "error", text: r.message });
|
|
138
183
|
};
|
|
184
|
+
/** Load their version (spec 2026-09-26 concurrency §4): drop the frozen state and re-get through the ops seam. */
|
|
185
|
+
const loadTheirs = () => { setStale(null); reset(); setReloadKey((k) => k + 1); };
|
|
186
|
+
// Someone else saved this row (§5): clean → their version silently; unsaved edits → the freeze at once.
|
|
187
|
+
useSavedElsewhere(target, (event) => {
|
|
188
|
+
if (stale)
|
|
189
|
+
return;
|
|
190
|
+
if (dirty || saveState !== "saved")
|
|
191
|
+
setStale({ by: event.email, at: Date.now() });
|
|
192
|
+
else
|
|
193
|
+
setReloadKey((k) => k + 1);
|
|
194
|
+
});
|
|
139
195
|
/** Publish what is on screen: the pending edit is flushed first, and a failed flush cancels the publish. */
|
|
140
196
|
const publish = async () => {
|
|
141
197
|
setNotice(null);
|
|
@@ -147,7 +203,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
147
203
|
}
|
|
148
204
|
let r;
|
|
149
205
|
try {
|
|
150
|
-
r = await ops.entries.setStatus(collection, id, locale, "published");
|
|
206
|
+
r = await ops.entries.setStatus(collection, id, locale, "published", token());
|
|
151
207
|
}
|
|
152
208
|
catch {
|
|
153
209
|
r = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
@@ -158,6 +214,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
158
214
|
return;
|
|
159
215
|
}
|
|
160
216
|
setRecord(r.data);
|
|
217
|
+
presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
|
|
161
218
|
setFieldErrors({});
|
|
162
219
|
setPublished(true);
|
|
163
220
|
setTimeout(() => setPublished(false), 1600);
|
|
@@ -180,7 +237,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
180
237
|
};
|
|
181
238
|
/** Discard (spec 2026-09-13 publish flow §4): confirm, flush so a late save cannot re-dirty the row, restore, reload. */
|
|
182
239
|
const discard = async () => {
|
|
183
|
-
if (!
|
|
240
|
+
if (!(await confirm({ ...DISCARD_CONFIRM, subject: entrySubject() })))
|
|
184
241
|
return;
|
|
185
242
|
setNotice(null);
|
|
186
243
|
setBusy("discard");
|
|
@@ -191,7 +248,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
191
248
|
}
|
|
192
249
|
let r;
|
|
193
250
|
try {
|
|
194
|
-
r = await ops.entries.discardDraft(collection, id, locale);
|
|
251
|
+
r = await ops.entries.discardDraft(collection, id, locale, token());
|
|
195
252
|
}
|
|
196
253
|
catch {
|
|
197
254
|
r = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
@@ -202,6 +259,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
202
259
|
return;
|
|
203
260
|
}
|
|
204
261
|
setRecord(r.data);
|
|
262
|
+
presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
|
|
205
263
|
setReloadKey((k) => k + 1);
|
|
206
264
|
setNotice({ tone: "ok", text: "Changes discarded." });
|
|
207
265
|
};
|
|
@@ -229,12 +287,13 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
229
287
|
fail(r);
|
|
230
288
|
};
|
|
231
289
|
const restore = async (versionId) => {
|
|
232
|
-
const r = await ops.entries.restore(collection, id, locale, versionId);
|
|
290
|
+
const r = await ops.entries.restore(collection, id, locale, versionId, token());
|
|
233
291
|
if (!r.ok) {
|
|
234
292
|
fail(r);
|
|
235
293
|
return false;
|
|
236
294
|
}
|
|
237
295
|
setRecord(r.data);
|
|
296
|
+
presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
|
|
238
297
|
setReloadKey((k) => k + 1);
|
|
239
298
|
setNotice({ tone: "ok", text: "Version restored." });
|
|
240
299
|
return true;
|
|
@@ -290,7 +349,14 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
290
349
|
const remaining = Object.keys(record.locales).filter((l) => l !== locale);
|
|
291
350
|
if (remaining.length === 0)
|
|
292
351
|
return;
|
|
293
|
-
|
|
352
|
+
const ok = await confirm({
|
|
353
|
+
title: `Remove this entry from ${locale}?`,
|
|
354
|
+
body: `Its ${locale} fields and history are deleted. Other languages are not affected.`,
|
|
355
|
+
confirmLabel: `Remove from ${locale}`,
|
|
356
|
+
destructive: true,
|
|
357
|
+
subject: entrySubject(),
|
|
358
|
+
});
|
|
359
|
+
if (!ok)
|
|
294
360
|
return;
|
|
295
361
|
let result;
|
|
296
362
|
try {
|
|
@@ -307,11 +373,18 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
307
373
|
goToLocale(remaining.includes(registry.locales.default) ? registry.locales.default : remaining[0]);
|
|
308
374
|
};
|
|
309
375
|
const destroy = async () => {
|
|
310
|
-
|
|
376
|
+
const ok = await confirm({
|
|
377
|
+
title: "Delete this entry?",
|
|
378
|
+
body: `The entry${multilingual ? " in every language" : ""} and its history are removed. Anything referencing it loses the reference. This cannot be undone.`,
|
|
379
|
+
confirmLabel: "Delete entry",
|
|
380
|
+
destructive: true,
|
|
381
|
+
subject: entrySubject(),
|
|
382
|
+
});
|
|
383
|
+
if (!ok)
|
|
311
384
|
return;
|
|
312
385
|
const r = await ops.entries.delete(collection, id);
|
|
313
386
|
if (r.ok)
|
|
314
|
-
navigate(adminHref(`/${collection}`), { force: true });
|
|
387
|
+
void navigate(adminHref(`/${collection}`), { force: true });
|
|
315
388
|
else
|
|
316
389
|
fail(r);
|
|
317
390
|
};
|
|
@@ -319,6 +392,11 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
319
392
|
// The mount's path in the ACTIVE locale prefixes the slug; no path, no prefix.
|
|
320
393
|
const base = meta.path?.[locale];
|
|
321
394
|
const slugPrefix = base ? `/${base}/` : undefined;
|
|
395
|
+
/** The confirm dialogs' context chip: the entry's title and, on a routed collection, its URL. */
|
|
396
|
+
const entrySubject = () => {
|
|
397
|
+
const slug = record?.locales[locale]?.slug;
|
|
398
|
+
return record ? { name: heading, path: slugPrefix && slug ? `${slugPrefix}${slug}` : null } : undefined;
|
|
399
|
+
};
|
|
322
400
|
const present = record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale];
|
|
323
401
|
const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: fieldErrors, refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, suggestions: tagSuggestions[key], prefix: d.type === "slug" ? slugPrefix : undefined, mark: multilingual && !localeKeys.includes(key) ? _jsx(UnlocalizedMark, {}) : undefined, onChange: (value) => {
|
|
324
402
|
setFields((f) => ({ ...(f ?? {}), [key]: value }));
|
|
@@ -333,6 +411,6 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
333
411
|
trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => void doAddLocale(l) })),
|
|
334
412
|
},
|
|
335
413
|
{ label: heading },
|
|
336
|
-
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), urls[locale] && (_jsx(LinkButton, { href: urls[locale], target: "_blank", rel: "noreferrer", title: "
|
|
414
|
+
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), urls[locale] && (_jsx(LinkButton, { icon: true, href: urls[locale], target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _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 ? (_jsx("fieldset", { disabled: true, className: "sm-history__form", children: Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: snapshot.fields[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: () => { } }, key))) })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "entry" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _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), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
|
|
337
415
|
revealField(formRef.current, path); } })] })), absent ? (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsxs("p", { children: ["This entry doesn't exist in ", locale, " yet. Adding it copies the ", entrySourceLocale(record, registry), " fields as the first ", locale, " draft."] }), _jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsxs(Button, { onClick: () => void doAddLocale(locale), disabled: adding || !parentLocales.includes(locale), title: parentLocales.includes(locale) ? undefined : `add ${locale} to the parent first`, children: ["Add ", locale] }), _jsxs(Button, { variant: "secondary", onClick: () => { void switchLocale(entrySourceLocale(record, registry)); }, children: ["Back to ", entrySourceLocale(record, registry)] })] })] })) : fields !== null ? (Object.entries(meta.fields).map(widget)) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", children: [record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Created", value: relativeTime(record.createdAt) }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), multilingual && _jsx(InfoRow, { label: "Languages", value: Object.keys(record.locales).join(", ") }), _jsx(InfoRow, { label: "ID", value: id, mono: true })] })), record && (_jsxs("div", { className: "sm-card", children: [multilingual && row && Object.keys(record.locales).length > 1 && (_jsxs(Button, { variant: "secondary", onClick: () => void doRemoveLocale(), children: ["Remove from ", locale] })), _jsx(Button, { variant: "danger", onClick: destroy, children: "Delete" })] }))] })] })] }));
|
|
338
416
|
}
|
|
@@ -12,6 +12,8 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
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
14
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
15
|
+
import { stillFailing, validateNode } from "../validate.js";
|
|
16
|
+
import { elementSchemas } from "../../richtext-walk.js";
|
|
15
17
|
import { CSS_KEY, SETTINGS_KEY, STYLES_KEY } from "../../global.js";
|
|
16
18
|
import { tabsFor } from "../forms/tabs.js";
|
|
17
19
|
import { revealField } from "../forms/reveal.js";
|
|
@@ -28,6 +30,13 @@ import { useAdminHref } from "./base.js";
|
|
|
28
30
|
import { GlobalFields } from "./GlobalFields.js";
|
|
29
31
|
import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
|
|
30
32
|
import { useLeaveGuard } from "./router.js";
|
|
33
|
+
import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
|
|
34
|
+
import { draftToken } from "../../draft-token.js";
|
|
35
|
+
import { isStale, staleInfoOf } from "./stale.js";
|
|
36
|
+
import { StaleBanner } from "../ui/StaleBanner.js";
|
|
37
|
+
import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "./presence-context.js";
|
|
38
|
+
import { editorsOf } from "./presence.js";
|
|
39
|
+
import { PresenceLine } from "../ui/PresenceLine.js";
|
|
31
40
|
export function GlobalForm({ ops, registry, name }) {
|
|
32
41
|
const adminHref = useAdminHref();
|
|
33
42
|
const item = registry.globals.find((s) => s.name === name);
|
|
@@ -45,15 +54,29 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
45
54
|
const [reloadKey, setReloadKey] = useState(0);
|
|
46
55
|
const [history, setHistory] = useState(null);
|
|
47
56
|
const [snapshot, setSnapshot] = useState(null);
|
|
57
|
+
const [stale, setStale] = useState(null);
|
|
48
58
|
const formRef = useRef(null);
|
|
49
59
|
const fieldsRef = useRef(fields);
|
|
50
60
|
fieldsRef.current = fields;
|
|
51
|
-
const
|
|
61
|
+
const recordRef = useRef(record);
|
|
62
|
+
recordRef.current = record;
|
|
63
|
+
/** The token of the row on screen — what every guarded op sends (spec 2026-09-26 concurrency §1). */
|
|
64
|
+
const token = () => { const row = recordRef.current?.locales[locale]; return row ? draftToken(row) : null; };
|
|
65
|
+
// Presence (spec 2026-09-26 concurrency §5): this tab on this item, the others on it, every ack announced.
|
|
66
|
+
const target = { kind: "global", id: name, locale };
|
|
67
|
+
useTrackPresence(target);
|
|
68
|
+
const { others } = usePresenceState();
|
|
69
|
+
const presence = usePresence();
|
|
70
|
+
const { saveState, mark, flush, reset } = useDraftAutosave({
|
|
52
71
|
read: () => fieldsRef.current,
|
|
53
72
|
persist: async (value) => {
|
|
54
|
-
const r = await ops.globals.save(name, locale, value);
|
|
55
|
-
if (r.ok)
|
|
73
|
+
const r = await ops.globals.save(name, locale, value, token());
|
|
74
|
+
if (r.ok) {
|
|
56
75
|
setRecord(r.data);
|
|
76
|
+
presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
|
|
77
|
+
}
|
|
78
|
+
else if (isStale(r))
|
|
79
|
+
setStale(staleInfoOf(r));
|
|
57
80
|
return r;
|
|
58
81
|
},
|
|
59
82
|
onSaved: () => setDirty(false),
|
|
@@ -74,6 +97,7 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
74
97
|
setFields(null);
|
|
75
98
|
setFieldErrors({});
|
|
76
99
|
setDirty(false);
|
|
100
|
+
setStale(null);
|
|
77
101
|
setTouched(false);
|
|
78
102
|
ops.globals.get(name, locale).then((r) => {
|
|
79
103
|
if (cancelled)
|
|
@@ -94,6 +118,10 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
94
118
|
const row = record?.locales[locale];
|
|
95
119
|
const status = row ? localeStatusOf(row) : null;
|
|
96
120
|
const fail = (r) => {
|
|
121
|
+
if (isStale(r)) {
|
|
122
|
+
setStale(staleInfoOf(r));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
97
125
|
if (r.code === "validation" && r.fields && r.fields.length > 0) {
|
|
98
126
|
setFieldErrors(Object.fromEntries(r.fields.map((f) => [f.field, f.message])));
|
|
99
127
|
setNotice({ tone: "warn", text: r.message, fields: r.fields });
|
|
@@ -101,6 +129,23 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
101
129
|
else
|
|
102
130
|
setNotice({ tone: "error", text: r.message });
|
|
103
131
|
};
|
|
132
|
+
// A refused publish's field errors clear as the fields are fixed: every change revalidates the way the
|
|
133
|
+
// op does (a visual item like a node: fields, `$settings` under `settings.`, `$css`) and keeps what still fails.
|
|
134
|
+
const elementDescs = useMemo(() => elementSchemas(registry.elements), [registry]);
|
|
135
|
+
const hasFieldErrors = Object.keys(fieldErrors).length > 0;
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (!hasFieldErrors || !fields || !item)
|
|
138
|
+
return;
|
|
139
|
+
const { [SETTINGS_KEY]: settings, [CSS_KEY]: css, ...values } = fields;
|
|
140
|
+
const fresh = validateNode({ fields: values, settings: settings !== null && typeof settings === "object" ? settings : {}, css }, { fields: item.fields, settings: item.section !== undefined ? item.settings : undefined }, elementDescs);
|
|
141
|
+
setFieldErrors((prev) => stillFailing(prev, fresh));
|
|
142
|
+
setNotice((n) => {
|
|
143
|
+
if (!n?.fields)
|
|
144
|
+
return n;
|
|
145
|
+
const left = n.fields.filter((f) => fresh.some((e) => e.field === f.field));
|
|
146
|
+
return left.length === 0 ? null : left.length === n.fields.length ? n : { ...n, fields: left };
|
|
147
|
+
});
|
|
148
|
+
}, [fields, hasFieldErrors, item, elementDescs]);
|
|
104
149
|
const publish = async () => {
|
|
105
150
|
setNotice(null);
|
|
106
151
|
setBusy("publish");
|
|
@@ -111,7 +156,7 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
111
156
|
}
|
|
112
157
|
let r;
|
|
113
158
|
try {
|
|
114
|
-
r = await ops.globals.setStatus(name, locale, "published");
|
|
159
|
+
r = await ops.globals.setStatus(name, locale, "published", token());
|
|
115
160
|
}
|
|
116
161
|
catch {
|
|
117
162
|
r = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
@@ -122,6 +167,7 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
122
167
|
return;
|
|
123
168
|
}
|
|
124
169
|
setRecord(r.data);
|
|
170
|
+
presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
|
|
125
171
|
setFieldErrors({});
|
|
126
172
|
setPublished(true);
|
|
127
173
|
setTimeout(() => setPublished(false), 1600);
|
|
@@ -143,7 +189,7 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
143
189
|
fail(r);
|
|
144
190
|
};
|
|
145
191
|
const discard = async () => {
|
|
146
|
-
if (!
|
|
192
|
+
if (!(await confirm({ ...DISCARD_CONFIRM, subject: { name: item.title } })))
|
|
147
193
|
return;
|
|
148
194
|
setNotice(null);
|
|
149
195
|
setBusy("discard");
|
|
@@ -154,7 +200,7 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
154
200
|
}
|
|
155
201
|
let r;
|
|
156
202
|
try {
|
|
157
|
-
r = await ops.globals.discardDraft(name, locale);
|
|
203
|
+
r = await ops.globals.discardDraft(name, locale, token());
|
|
158
204
|
}
|
|
159
205
|
catch {
|
|
160
206
|
r = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
@@ -165,6 +211,7 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
165
211
|
return;
|
|
166
212
|
}
|
|
167
213
|
setRecord(r.data);
|
|
214
|
+
presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
|
|
168
215
|
setReloadKey((k) => k + 1);
|
|
169
216
|
setNotice({ tone: "ok", text: "Changes discarded." });
|
|
170
217
|
};
|
|
@@ -190,13 +237,25 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
190
237
|
else
|
|
191
238
|
fail(r);
|
|
192
239
|
};
|
|
240
|
+
/** Load their version (spec 2026-09-26 concurrency §4): drop the frozen state and re-get through the ops seam. */
|
|
241
|
+
const loadTheirs = () => { setStale(null); reset(); setReloadKey((k) => k + 1); };
|
|
242
|
+
// Someone else saved this item (§5): clean → their version silently; unsaved edits → the freeze at once.
|
|
243
|
+
useSavedElsewhere(target, (event) => {
|
|
244
|
+
if (stale)
|
|
245
|
+
return;
|
|
246
|
+
if (dirty || saveState !== "saved")
|
|
247
|
+
setStale({ by: event.email, at: Date.now() });
|
|
248
|
+
else
|
|
249
|
+
setReloadKey((k) => k + 1);
|
|
250
|
+
});
|
|
193
251
|
const restore = async (versionId) => {
|
|
194
|
-
const r = await ops.globals.restore(name, locale, versionId);
|
|
252
|
+
const r = await ops.globals.restore(name, locale, versionId, token());
|
|
195
253
|
if (!r.ok) {
|
|
196
254
|
fail(r);
|
|
197
255
|
return false;
|
|
198
256
|
}
|
|
199
257
|
setRecord(r.data);
|
|
258
|
+
presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
|
|
200
259
|
setReloadKey((k) => k + 1);
|
|
201
260
|
setNotice({ tone: "ok", text: "Version restored." });
|
|
202
261
|
return true;
|
|
@@ -235,10 +294,10 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
235
294
|
};
|
|
236
295
|
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: adminHref("/globals"), items: [
|
|
237
296
|
{
|
|
238
|
-
label: "
|
|
297
|
+
label: "Site", href: adminHref("/globals"),
|
|
239
298
|
trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: (l) => { void switchLocale(l); }, onAdd: () => undefined })),
|
|
240
299
|
},
|
|
241
300
|
{ label: item.title },
|
|
242
|
-
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), _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" }) })), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
|
|
301
|
+
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), _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 }), _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), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
|
|
243
302
|
revealField(formRef.current, path); } })] })), 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)))] })), fields !== null ? (_jsxs(_Fragment, { children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: fields, errors: fieldErrors, 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: fieldErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange })] }))] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsx("aside", { className: "sm-inspector", children: 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 })] })) })] })] }));
|
|
244
303
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
// The Globals list (global content, spec 2026-09-07 §6; renamed in the
|
|
4
4
|
// UI 2026-09-09, the route is /globals): every registered item is
|
|
5
5
|
// a row, edited on its own page. Nothing here is created or deleted by
|
|
@@ -14,30 +14,20 @@ import { LocalePicker } from "../ui/LocaleSwitcher.js";
|
|
|
14
14
|
import { relativeTime } from "../ui/format.js";
|
|
15
15
|
import { useAdminHref } from "./base.js";
|
|
16
16
|
import { ErrorPane } from "./ErrorPane.js";
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (stored && registry.locales.supported.includes(stored))
|
|
22
|
-
return stored;
|
|
23
|
-
}
|
|
24
|
-
catch {
|
|
25
|
-
// storage unavailable — the default is fine
|
|
26
|
-
}
|
|
27
|
-
return registry.locales.default;
|
|
28
|
-
}
|
|
17
|
+
import { rememberLocale, rememberedLocale } from "./locale-memory.js";
|
|
18
|
+
import { usePresenceState } from "./presence-context.js";
|
|
19
|
+
import { editorsOf } from "./presence.js";
|
|
20
|
+
import { LockIcon } from "../ui/LockIcon.js";
|
|
29
21
|
export function GlobalList({ ops, registry }) {
|
|
30
22
|
const adminHref = useAdminHref();
|
|
31
23
|
const [items, setItems] = useState(null);
|
|
32
24
|
const [error, setError] = useState(null);
|
|
33
|
-
const [locale, setLocale] = useState(() => rememberedLocale(registry));
|
|
25
|
+
const [locale, setLocale] = useState(() => rememberedLocale(registry.locales));
|
|
26
|
+
const { others } = usePresenceState();
|
|
34
27
|
const multilingual = registry.locales.supported.length > 1;
|
|
35
28
|
const pickLocale = (next) => {
|
|
36
29
|
setLocale(next);
|
|
37
|
-
|
|
38
|
-
window.sessionStorage.setItem(LOCALE_KEY, next);
|
|
39
|
-
}
|
|
40
|
-
catch { /* see rememberedLocale */ }
|
|
30
|
+
rememberLocale(next);
|
|
41
31
|
};
|
|
42
32
|
const load = useCallback(() => {
|
|
43
33
|
ops.globals.list()
|
|
@@ -48,7 +38,7 @@ export function GlobalList({ ops, registry }) {
|
|
|
48
38
|
if (error)
|
|
49
39
|
return _jsx(ErrorPane, { message: error });
|
|
50
40
|
const rows = items ? globalRows(items, registry, locale) : [];
|
|
51
|
-
return (_jsx(ListView, { title: "
|
|
41
|
+
return (_jsx(ListView, { title: "Site", titleTrailing: multilingual && _jsx(LocalePicker, { locales: registry.locales.supported, value: locale, onChange: pickLocale }), columns: [
|
|
52
42
|
{ label: "Title", width: "minmax(160px,1fr)" },
|
|
53
43
|
{ label: "Kind", width: "90px" },
|
|
54
44
|
{ label: "Updated", width: "minmax(110px,160px)" },
|
|
@@ -57,7 +47,7 @@ export function GlobalList({ ops, registry }) {
|
|
|
57
47
|
key: row.name,
|
|
58
48
|
href: adminHref(`/globals/${encodeURIComponent(row.name)}?locale=${encodeURIComponent(locale)}`),
|
|
59
49
|
cells: [
|
|
60
|
-
|
|
50
|
+
_jsxs("span", { className: "sm-cell sm-cell--title", children: [row.title, _jsx(LockIcon, { names: editorsOf(others, { kind: "global", id: row.name, locale }) })] }, "t"),
|
|
61
51
|
_jsx("span", { className: "sm-cell sm-cell--muted", children: row.flavour }, "k"),
|
|
62
52
|
_jsx("span", { className: "sm-cell sm-cell--muted", children: relativeTime(row.updatedAt) }, "u"),
|
|
63
53
|
_jsx(StatusBadge, { status: statusLabel(row.status) }, "s"),
|
|
@@ -26,9 +26,11 @@ import { RouteLink } from "./RouteLink.js";
|
|
|
26
26
|
/** Lucide at the rail's size and stroke (the tile's CSS restates both). */
|
|
27
27
|
const ICON = { size: 18, strokeWidth: 2, "aria-hidden": true };
|
|
28
28
|
/** The app icon, inlined: a package can't ship a file into the host app's
|
|
29
|
-
* /public.
|
|
29
|
+
* /public. design_handoff_smoodly_neutral/assets/smoodly-logo.svg — the
|
|
30
|
+
* outer and inner shapes take --logo-outer / --logo-inner (white over
|
|
31
|
+
* navy on the frame), so a look decides its colours. */
|
|
30
32
|
function AppIcon() {
|
|
31
|
-
return (_jsxs("svg", { className: "sm-rail__logo", viewBox: "0 0
|
|
33
|
+
return (_jsxs("svg", { className: "sm-rail__logo", viewBox: "0 0 28 27", fill: "none", xmlns: "http://www.w3.org/2000/svg", role: "img", "aria-label": "Smoodly", children: [_jsx("path", { style: { fill: "var(--logo-outer)" }, d: "M0 16.1859C0 7.6335 7.88106 0 16.5324 0H16.6037C23.429 0 28 4.59428 28 10.8141C28 19.3666 20.2618 27 11.3279 27C4.43425 27 0 22.2645 0 16.1859Z" }), _jsx("path", { style: { fill: "var(--logo-inner)" }, d: "M7 14.7932C7 10.6754 10.6591 7 14.6757 7H14.7089C17.8777 7 20 9.21206 20 12.2068C20 16.3247 16.4072 20 12.2594 20C9.05876 20 7 17.7199 7 14.7932Z" })] }));
|
|
32
34
|
}
|
|
33
35
|
export function IconRail({ registry, active, email, settings = true, onSignOut }) {
|
|
34
36
|
const adminHref = useAdminHref();
|
|
@@ -44,14 +46,21 @@ export function IconRail({ registry, active, email, settings = true, onSignOut }
|
|
|
44
46
|
const close = () => setOpen(null);
|
|
45
47
|
const hoverLeave = () => setHovered(false);
|
|
46
48
|
// A click open moves focus into the menu; a pointer down outside closes it.
|
|
49
|
+
// Outside means outside the menu and its trigger — the rest of the rail
|
|
50
|
+
// counts too (2026-09-26); the trigger is left to its own toggle.
|
|
47
51
|
useEffect(() => {
|
|
48
52
|
if (!open || !root.current)
|
|
49
53
|
return;
|
|
50
54
|
if (!hovered)
|
|
51
55
|
root.current.querySelector(`#${CSS.escape(menuId(open))} [role=menuitem]`)?.focus();
|
|
52
56
|
const onPointerDown = (e) => {
|
|
53
|
-
if (e.target instanceof Node
|
|
54
|
-
|
|
57
|
+
if (!(e.target instanceof Node))
|
|
58
|
+
return;
|
|
59
|
+
const menu = root.current?.querySelector(`#${CSS.escape(menuId(open))}`);
|
|
60
|
+
const trigger = root.current?.querySelector(`[aria-controls="${menuId(open)}"]`);
|
|
61
|
+
if (menu?.contains(e.target) || trigger?.contains(e.target))
|
|
62
|
+
return;
|
|
63
|
+
close();
|
|
55
64
|
};
|
|
56
65
|
document.addEventListener("pointerdown", onPointerDown);
|
|
57
66
|
return () => document.removeEventListener("pointerdown", onPointerDown);
|
|
@@ -79,5 +88,5 @@ export function IconRail({ registry, active, email, settings = true, onSignOut }
|
|
|
79
88
|
const collectionsOpen = open === "collections" || (hovered && open === null);
|
|
80
89
|
const name = email?.split("@")[0] ?? "Editor";
|
|
81
90
|
const initials = name.slice(0, 2).toUpperCase();
|
|
82
|
-
return (_jsxs("nav", { className: "sm-rail", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsx(AppIcon, {}), _jsxs("ul", { className: "sm-rail__list", children: [link("pages", "Pages", _jsx(PanelTop, { ...ICON }), adminHref("/pages")), collections.length > 0 && (_jsxs("li", { className: "sm-rail__item", onMouseEnter: () => setHovered(true), onMouseLeave: hoverLeave, children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": collectionsOpen, "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsx("span", { className: "sm-rail__tile", children: _jsx(LibraryBig, { ...ICON }) }), _jsx("span", { className: "sm-rail__label", children: "Collections" })] }), _jsx("ul", { className: "sm-rail__menu sm-rail__menu--collections", role: "menu", id: menuId("collections"), "data-open": collectionsOpen, children: collections.map((c) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-rail__menu-item", href: adminHref(`/${c.name}`), "aria-current": c.name === active ? "page" : undefined, onClick: () => { close(); hoverLeave(); }, children: _jsx("span", { children: c.title }) }) }, c.name))) })] })), link("media", "Media", _jsx(Image, { ...ICON }), adminHref("/media")), registry.globals.length > 0 && link("globals", "
|
|
91
|
+
return (_jsxs("nav", { className: "sm-rail", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsx(AppIcon, {}), _jsxs("ul", { className: "sm-rail__list", children: [link("pages", "Pages", _jsx(PanelTop, { ...ICON }), adminHref("/pages")), collections.length > 0 && (_jsxs("li", { className: "sm-rail__item", onMouseEnter: () => setHovered(true), onMouseLeave: hoverLeave, children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": collectionsOpen, "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsx("span", { className: "sm-rail__tile", children: _jsx(LibraryBig, { ...ICON }) }), _jsx("span", { className: "sm-rail__label", children: "Collections" })] }), _jsx("ul", { className: "sm-rail__menu sm-rail__menu--collections", role: "menu", id: menuId("collections"), "data-open": collectionsOpen, children: collections.map((c) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-rail__menu-item", href: adminHref(`/${c.name}`), "aria-current": c.name === active ? "page" : undefined, onClick: () => { close(); hoverLeave(); }, children: _jsx("span", { children: c.title }) }) }, c.name))) })] })), link("media", "Media", _jsx(Image, { ...ICON }), adminHref("/media")), registry.globals.length > 0 && link("globals", "Site", _jsx(Globe, { ...ICON }), adminHref("/globals")), settings && link("settings", "Settings", _jsx(Settings, { ...ICON }), adminHref("/settings"))] }), _jsxs("div", { className: "sm-rail__item sm-rail__foot", children: [_jsx("button", { type: "button", className: "sm-rail__user", title: name, "aria-haspopup": "menu", "aria-expanded": open === "user", "aria-controls": menuId("user"), onClick: () => toggle("user"), children: initials }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: menuId("user"), "data-open": open === "user", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: name }), _jsx("li", { role: "none", children: _jsx(ThemeToggle, {}) }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: () => { close(); onSignOut(); }, children: "Sign out" }) })] })] })] }));
|
|
83
92
|
}
|