smoodly 0.0.14 → 0.0.16
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 +94 -15
- package/dist/admin/auth.d.ts +7 -4
- package/dist/admin/auth.js +1 -1
- package/dist/admin/client-ops.js +3 -3
- package/dist/admin/editor/CanvasOverlay.d.ts +5 -1
- package/dist/admin/editor/CanvasOverlay.js +5 -5
- package/dist/admin/editor/EditorView.d.ts +2 -0
- package/dist/admin/editor/EditorView.js +251 -23
- package/dist/admin/editor/PageSettings.js +3 -2
- package/dist/admin/editor/SharedInspector.d.ts +23 -0
- package/dist/admin/editor/SharedInspector.js +13 -0
- package/dist/admin/editor/shared-edit.d.ts +39 -0
- package/dist/admin/editor/shared-edit.js +124 -0
- package/dist/admin/editor/useSharedEdit.d.ts +6 -0
- package/dist/admin/editor/useSharedEdit.js +18 -0
- package/dist/admin/forms/tabs.d.ts +9 -3
- package/dist/admin/next/op-handler.js +4 -3
- package/dist/admin/ops-impl.d.ts +3 -0
- package/dist/admin/ops-impl.js +198 -23
- package/dist/admin/ops.d.ts +32 -0
- package/dist/admin/serialize.d.ts +0 -1
- package/dist/admin/serialize.js +0 -1
- package/dist/admin/shared-items.js +14 -0
- package/dist/admin/shell/AdminApp.js +1 -1
- package/dist/admin/shell/EntriesList.js +19 -3
- package/dist/admin/shell/EntryForm.js +183 -77
- package/dist/admin/shell/IconRail.js +3 -3
- package/dist/admin/shell/PagesList.js +3 -2
- package/dist/admin/shell/SharedFields.d.ts +21 -0
- package/dist/admin/shell/SharedFields.js +15 -0
- package/dist/admin/shell/SharedForm.js +166 -52
- package/dist/admin/shell/SharedList.js +3 -2
- package/dist/admin/shell/entries-list.d.ts +2 -2
- package/dist/admin/shell/entries-list.js +3 -3
- package/dist/admin/supabase-auth.js +1 -1
- package/dist/admin/ui/ActionMenu.d.ts +11 -0
- package/dist/admin/ui/ActionMenu.js +25 -0
- package/dist/admin/ui/HistoryModal.d.ts +23 -0
- package/dist/admin/ui/HistoryModal.js +40 -0
- package/dist/admin/ui/Modal.d.ts +5 -1
- package/dist/admin/ui/Modal.js +3 -3
- package/dist/admin/ui/PublishControl.d.ts +31 -0
- package/dist/admin/ui/PublishControl.js +24 -0
- package/dist/admin/ui/format.d.ts +4 -0
- package/dist/admin/ui/format.js +9 -0
- package/dist/admin/ui/primitives.d.ts +13 -0
- package/dist/admin/ui/primitives.js +18 -2
- package/dist/admin/ui/status-label.d.ts +10 -0
- package/dist/admin/ui/status-label.js +14 -0
- package/dist/admin/ui/theme.js +46 -6
- package/dist/collections.d.ts +0 -6
- package/dist/collections.js +0 -1
- package/dist/entry-store.d.ts +38 -13
- package/dist/entry-store.js +90 -23
- package/dist/next/page-renderer.js +8 -4
- package/dist/shared.d.ts +8 -1
- package/dist/shared.js +10 -1
- package/dist/site.d.ts +2 -0
- package/dist/site.js +17 -7
- package/dist/sql.js +18 -15
- package/dist/store.d.ts +38 -13
- package/dist/store.js +82 -12
- package/dist/supabase-entry-store.d.ts +11 -6
- package/dist/supabase-entry-store.js +101 -25
- package/dist/supabase-store.d.ts +13 -6
- package/dist/supabase-store.js +88 -19
- package/package.json +1 -1
package/dist/admin/serialize.js
CHANGED
|
@@ -77,7 +77,6 @@ export function serializeAdminConfig(config) {
|
|
|
77
77
|
name: s.name,
|
|
78
78
|
title: s.title,
|
|
79
79
|
...(s.section !== undefined ? { section: s.section } : {}),
|
|
80
|
-
versions: s.versions === true,
|
|
81
80
|
fields: serializeMap(s.fields),
|
|
82
81
|
styles: serializeMap(s.section !== undefined ? config.resolvedStyles(s.section) : {}),
|
|
83
82
|
})),
|
|
@@ -55,6 +55,20 @@ export async function ensureSharedItems(entries, config) {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
+
// Every shared item is versioned (decided 2026-09-13). A row published before that has
|
|
59
|
+
// no pointers, so its next save would go live: publish it once more here, which snapshots
|
|
60
|
+
// what is live and points both pointers at it. Idempotent — pointed rows are skipped.
|
|
61
|
+
for (const locale of supported) {
|
|
62
|
+
const row = record.locales[locale];
|
|
63
|
+
if (!row || row.status !== "published" || row.publishedVersionId !== null)
|
|
64
|
+
continue;
|
|
65
|
+
try {
|
|
66
|
+
record = await entries.setStatus(item.name, record.id, locale, "published");
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
console.warn(`smoodly: could not version shared item "${item.name}" in "${locale}": ${e instanceof Error ? e.message : String(e)}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
58
72
|
out[item.name] = record;
|
|
59
73
|
}
|
|
60
74
|
return out;
|
|
@@ -48,7 +48,7 @@ export function AdminApp({ registry, op, auth }) {
|
|
|
48
48
|
else if (isCollection && !tail)
|
|
49
49
|
main = _jsx(EntriesList, { ops: ops, registry: registry, collection: head });
|
|
50
50
|
else if (isCollection && tail)
|
|
51
|
-
main = _jsx(EntryForm, { ops: ops, registry: registry, collection: head, id:
|
|
51
|
+
main = _jsx(EntryForm, { ops: ops, registry: registry, collection: head, id: decodeURIComponent(tail) });
|
|
52
52
|
else if (head === "shared" && !tail)
|
|
53
53
|
main = _jsx(SharedList, { ops: ops, registry: registry });
|
|
54
54
|
else if (head === "shared" && tail)
|
|
@@ -8,6 +8,7 @@ import { useCallback, useEffect, useState } from "react";
|
|
|
8
8
|
import { ListView } from "./ListView.js";
|
|
9
9
|
import { canReorder, entryRows, entrySourceLocale } from "./entries-list.js";
|
|
10
10
|
import { Button, StatusBadge } from "../ui/primitives.js";
|
|
11
|
+
import { statusLabel } from "../ui/status-label.js";
|
|
11
12
|
import { LocalePicker } from "../ui/LocaleSwitcher.js";
|
|
12
13
|
import { relativeTime } from "../ui/format.js";
|
|
13
14
|
import { useSortable } from "../ui/useSortable.js";
|
|
@@ -75,6 +76,21 @@ export function EntriesList({ ops, registry, collection }) {
|
|
|
75
76
|
const open = (id) => {
|
|
76
77
|
navigate(`${BASE}/${collection}/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`);
|
|
77
78
|
};
|
|
79
|
+
/** "+ New entry" creates an empty draft in the locale on screen and opens it (spec 2026-09-14 §4). */
|
|
80
|
+
const createEntry = async () => {
|
|
81
|
+
let result;
|
|
82
|
+
try {
|
|
83
|
+
result = await ops.entries.create(collection, { locale, fields: {} });
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
87
|
+
}
|
|
88
|
+
if (!result.ok) {
|
|
89
|
+
window.alert(result.message);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
open(result.data.id);
|
|
93
|
+
};
|
|
78
94
|
const addLocale = async (row) => {
|
|
79
95
|
const record = records?.find((r) => r.id === row.id);
|
|
80
96
|
if (!record)
|
|
@@ -95,10 +111,10 @@ export function EntriesList({ ops, registry, collection }) {
|
|
|
95
111
|
}
|
|
96
112
|
open(row.id);
|
|
97
113
|
};
|
|
98
|
-
return (_jsx(ListView, { title: meta.title, titleTrailing: multilingual && _jsx(LocalePicker, { locales: registry.locales.supported, value: locale, onChange: pickLocale }), actions: _jsx(Button, { onClick: () => {
|
|
114
|
+
return (_jsx(ListView, { title: meta.title, titleTrailing: multilingual && _jsx(LocalePicker, { locales: registry.locales.supported, value: locale, onChange: pickLocale }), actions: _jsx(Button, { onClick: () => { void createEntry(); }, children: "+ New entry" }), columns: [
|
|
99
115
|
{ label: "Title", width: "minmax(160px,1fr)" },
|
|
100
116
|
{ label: "Updated", width: "minmax(110px,160px)" },
|
|
101
|
-
{ label: "Status", width: "
|
|
117
|
+
{ label: "Status", width: "170px" },
|
|
102
118
|
], empty: records ? "Nothing here yet." : "Loading…", sortable: canReorder(meta) ? { hook: sortable, canDrag: () => true } : undefined, rows: rows.map((row) => {
|
|
103
119
|
const dim = row.present ? undefined : { opacity: 0.45 };
|
|
104
120
|
return {
|
|
@@ -110,7 +126,7 @@ export function EntriesList({ ops, registry, collection }) {
|
|
|
110
126
|
? _jsx("span", { className: "sm-cell sm-cell--muted", children: relativeTime(row.updatedAt) }, "u")
|
|
111
127
|
: _jsxs("span", { className: "sm-cell sm-cell--muted", children: ["not in ", locale, " \u2014 click to add"] }, "u"),
|
|
112
128
|
row.status
|
|
113
|
-
? _jsx(StatusBadge, { status: row.status }, "c")
|
|
129
|
+
? _jsx(StatusBadge, { status: statusLabel(row.status) }, "c")
|
|
114
130
|
: _jsx("span", { className: "sm-cell sm-cell--muted", children: "\u2014" }, "c"),
|
|
115
131
|
],
|
|
116
132
|
};
|
|
@@ -1,44 +1,73 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx,
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
// The classic field view for ONE locale of an entry (spec 2026-09-06
|
|
4
4
|
// §7): the fields in schema order, the same FieldWidget renderer as the
|
|
5
5
|
// editor sidebar, no tabs. In a multilingual project a field that is
|
|
6
6
|
// NOT .localized() (nor the slug) carries a globe-off mark after its
|
|
7
|
-
// name — every locale sees it
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
7
|
+
// name — every locale sees it. The locale switcher loads that locale's
|
|
8
|
+
// merged fields; switching to a locale the entry lacks shows the
|
|
9
|
+
// "Add <locale>" pane, which copies the locale on screen; "Remove from
|
|
10
|
+
// <locale>" sits in the sidebar behind a confirm.
|
|
11
|
+
//
|
|
12
|
+
// Since 2026-09-14 (spec collections draft/publish) the form autosaves
|
|
13
|
+
// like the page editor: no Save button, every edit persists after a
|
|
14
|
+
// debounce, the top bar carries the indicator, View live and the one
|
|
15
|
+
// publish control. A draft may be incomplete; publish validates it and
|
|
16
|
+
// lists the fields in an amber banner. The safe-delete guard's message
|
|
13
17
|
// renders verbatim — it is product surface.
|
|
14
|
-
import { useEffect, useMemo, useState } from "react";
|
|
18
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
15
19
|
import { FieldWidget } from "../forms/FieldWidget.js";
|
|
16
|
-
import {
|
|
20
|
+
import { revealField } from "../forms/reveal.js";
|
|
21
|
+
import { useDraftAutosave } from "../editor/autosave.js";
|
|
22
|
+
import { relativeTime, updatedLine } from "../ui/format.js";
|
|
17
23
|
import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
|
|
18
|
-
import { Banner, Breadcrumb, Button,
|
|
24
|
+
import { Banner, Breadcrumb, Button, FieldErrorList, InfoRow, LinkButton, SaveIndicator, SectionLabel, TopBar, UnlocalizedMark, } from "../ui/primitives.js";
|
|
25
|
+
import { PublishControl } from "../ui/PublishControl.js";
|
|
26
|
+
import { HistoryModal, historyRows } from "../ui/HistoryModal.js";
|
|
27
|
+
import { localeStatusOf } from "./pages-tree.js";
|
|
19
28
|
import { ResizeHandle } from "../ui/ResizeHandle.js";
|
|
20
29
|
import { entrySourceLocale, entryTitle, localizedFieldKeys } from "./entries-list.js";
|
|
21
30
|
import { BASE } from "./base.js";
|
|
22
31
|
import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
|
|
23
32
|
import { navigate, useLeaveGuard } from "./router.js";
|
|
24
|
-
const STATUSES = ["Draft", "Published"];
|
|
25
33
|
export function EntryForm({ ops, registry, collection, id }) {
|
|
26
34
|
const meta = registry.collections.find((c) => c.name === collection);
|
|
27
|
-
const isNew = id === "new";
|
|
28
35
|
const multilingual = registry.locales.supported.length > 1;
|
|
29
36
|
const [locale, setLocale] = useState(() => localeFromUrl(registry));
|
|
30
37
|
const [record, setRecord] = useState(null);
|
|
31
|
-
const [fields, setFields] = useState(
|
|
38
|
+
const [fields, setFields] = useState(null);
|
|
39
|
+
const [urls, setUrls] = useState({});
|
|
32
40
|
const [parentLocales, setParentLocales] = useState(registry.locales.supported);
|
|
33
41
|
const [refOptions, setRefOptions] = useState({});
|
|
34
|
-
const [
|
|
42
|
+
const [notice, setNotice] = useState(null);
|
|
35
43
|
const [fieldErrors, setFieldErrors] = useState({});
|
|
36
44
|
const [adding, setAdding] = useState(false);
|
|
37
45
|
const [reloadKey, setReloadKey] = useState(0);
|
|
46
|
+
/** An edit since the last save ack — Publish wakes on it, before the autosave runs (spec 2026-09-13 publish flow §2). */
|
|
38
47
|
const [dirty, setDirty] = useState(false);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
48
|
+
/** Any edit on this screen yet: the indicator stays hidden until the first one (§6). */
|
|
49
|
+
const [touched, setTouched] = useState(false);
|
|
50
|
+
const [busy, setBusy] = useState(null);
|
|
51
|
+
/** The moment after a publish: the button reads "Published ✓". */
|
|
52
|
+
const [published, setPublished] = useState(false);
|
|
53
|
+
const [history, setHistory] = useState(null);
|
|
54
|
+
const [snapshot, setSnapshot] = useState(null);
|
|
55
|
+
const formRef = useRef(null);
|
|
56
|
+
const fieldsRef = useRef(fields);
|
|
57
|
+
fieldsRef.current = fields;
|
|
58
|
+
// The page editor's controller: debounced, retrying, flushable (spec 2026-09-14 §3).
|
|
59
|
+
const { saveState, mark, flush } = useDraftAutosave({
|
|
60
|
+
read: () => fieldsRef.current,
|
|
61
|
+
persist: async (value) => {
|
|
62
|
+
const r = await ops.entries.save(collection, id, locale, value);
|
|
63
|
+
if (r.ok)
|
|
64
|
+
setRecord(r.data);
|
|
65
|
+
return r;
|
|
66
|
+
},
|
|
67
|
+
onSaved: () => setDirty(false),
|
|
68
|
+
});
|
|
69
|
+
// Reload and close ask only while a save is pending or in flight.
|
|
70
|
+
useLeaveGuard(dirty || saveState !== "saved");
|
|
42
71
|
const refTargets = useMemo(() => {
|
|
43
72
|
const out = {};
|
|
44
73
|
for (const [key, d] of Object.entries(meta?.fields ?? {})) {
|
|
@@ -50,26 +79,26 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
50
79
|
const localeKeys = useMemo(() => (meta ? localizedFieldKeys(meta) : []), [meta]);
|
|
51
80
|
// Load the locale's merged fields; an absent locale comes back with fields: null.
|
|
52
81
|
useEffect(() => {
|
|
53
|
-
if (isNew)
|
|
54
|
-
return;
|
|
55
82
|
let cancelled = false;
|
|
56
83
|
setFields(null);
|
|
57
84
|
setFieldErrors({});
|
|
85
|
+
setDirty(false);
|
|
86
|
+
setTouched(false);
|
|
58
87
|
ops.entries.get(collection, id, locale).then((r) => {
|
|
59
88
|
if (cancelled)
|
|
60
89
|
return;
|
|
61
90
|
if (r.ok) {
|
|
62
91
|
setRecord(r.data.record);
|
|
63
92
|
setFields(r.data.fields);
|
|
93
|
+
setUrls(r.data.urls);
|
|
64
94
|
setParentLocales(r.data.parentLocales);
|
|
65
|
-
setDirty(false);
|
|
66
95
|
}
|
|
67
96
|
else
|
|
68
|
-
|
|
97
|
+
setNotice({ tone: "error", text: r.message });
|
|
69
98
|
}).catch(() => { if (!cancelled)
|
|
70
|
-
|
|
99
|
+
setNotice({ tone: "error", text: "Couldn't reach the server." }); });
|
|
71
100
|
return () => { cancelled = true; };
|
|
72
|
-
}, [ops, collection, id,
|
|
101
|
+
}, [ops, collection, id, locale, reloadKey]);
|
|
73
102
|
// Ref options in the locale being edited: a target absent there is not offered.
|
|
74
103
|
useEffect(() => {
|
|
75
104
|
let cancelled = false;
|
|
@@ -93,64 +122,145 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
93
122
|
}, [ops, refTargets, registry, locale]);
|
|
94
123
|
if (!meta)
|
|
95
124
|
return _jsx("p", { style: { padding: 32 }, children: "No such collection." });
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
125
|
+
const row = record?.locales[locale];
|
|
126
|
+
const status = row ? localeStatusOf(row) : null;
|
|
127
|
+
/** An op's failure onto the screen: amber with the fields for a validation result, red otherwise. */
|
|
128
|
+
const fail = (r) => {
|
|
129
|
+
if (r.code === "validation" && r.fields && r.fields.length > 0) {
|
|
130
|
+
setFieldErrors(Object.fromEntries(r.fields.map((f) => [f.field, f.message])));
|
|
131
|
+
setNotice({ tone: "warn", text: r.message, fields: r.fields });
|
|
132
|
+
}
|
|
133
|
+
else
|
|
134
|
+
setNotice({ tone: "error", text: r.message });
|
|
105
135
|
};
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
navigate(`${BASE}/${collection}/${encodeURIComponent(r.data.id)}?locale=${encodeURIComponent(locale)}`, { force: true });
|
|
115
|
-
}
|
|
136
|
+
/** Publish what is on screen: the pending edit is flushed first, and a failed flush cancels the publish. */
|
|
137
|
+
const publish = async () => {
|
|
138
|
+
setNotice(null);
|
|
139
|
+
setBusy("publish");
|
|
140
|
+
if (!(await flush())) {
|
|
141
|
+
setBusy(null);
|
|
142
|
+
setNotice({ tone: "error", text: "Couldn't save your latest changes — publish cancelled." });
|
|
143
|
+
return;
|
|
116
144
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
setRecord(r.data);
|
|
121
|
-
setDirty(false);
|
|
122
|
-
}
|
|
145
|
+
let r;
|
|
146
|
+
try {
|
|
147
|
+
r = await ops.entries.setStatus(collection, id, locale, "published");
|
|
123
148
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (
|
|
149
|
+
catch {
|
|
150
|
+
r = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
151
|
+
}
|
|
152
|
+
setBusy(null);
|
|
153
|
+
if (!r.ok) {
|
|
154
|
+
fail(r);
|
|
129
155
|
return;
|
|
130
|
-
|
|
131
|
-
|
|
156
|
+
}
|
|
157
|
+
setRecord(r.data);
|
|
158
|
+
setFieldErrors({});
|
|
159
|
+
setPublished(true);
|
|
160
|
+
setTimeout(() => setPublished(false), 1600);
|
|
161
|
+
};
|
|
162
|
+
const unpublish = async () => {
|
|
163
|
+
setNotice(null);
|
|
164
|
+
setBusy("unpublish");
|
|
165
|
+
let r;
|
|
166
|
+
try {
|
|
167
|
+
r = await ops.entries.setStatus(collection, id, locale, "draft");
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
r = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
171
|
+
}
|
|
172
|
+
setBusy(null);
|
|
173
|
+
if (r.ok)
|
|
132
174
|
setRecord(r.data);
|
|
175
|
+
else
|
|
176
|
+
fail(r);
|
|
177
|
+
};
|
|
178
|
+
/** Discard (spec 2026-09-13 publish flow §4): confirm, flush so a late save cannot re-dirty the row, restore, reload. */
|
|
179
|
+
const discard = async () => {
|
|
180
|
+
if (!window.confirm("Discard your changes and restore the published version?"))
|
|
181
|
+
return;
|
|
182
|
+
setNotice(null);
|
|
183
|
+
setBusy("discard");
|
|
184
|
+
if (!(await flush())) {
|
|
185
|
+
setBusy(null);
|
|
186
|
+
setNotice({ tone: "error", text: "Couldn't save your latest changes — discard cancelled." });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
let r;
|
|
190
|
+
try {
|
|
191
|
+
r = await ops.entries.discardDraft(collection, id, locale);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
r = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
195
|
+
}
|
|
196
|
+
setBusy(null);
|
|
197
|
+
if (!r.ok) {
|
|
198
|
+
fail(r);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
setRecord(r.data);
|
|
202
|
+
setReloadKey((k) => k + 1);
|
|
203
|
+
setNotice({ tone: "ok", text: "Changes discarded." });
|
|
204
|
+
};
|
|
205
|
+
/** History (spec 2026-09-13 history §5): flush first so the list's draft row is current. */
|
|
206
|
+
const openHistory = async () => {
|
|
207
|
+
if (!row)
|
|
208
|
+
return;
|
|
209
|
+
if (!(await flush())) {
|
|
210
|
+
setNotice({ tone: "error", text: "Couldn't save your latest changes — history not opened." });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const r = await ops.entries.listVersions(collection, id, locale);
|
|
214
|
+
if (!r.ok) {
|
|
215
|
+
fail(r);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
setSnapshot(null);
|
|
219
|
+
setHistory(historyRows(r.data, row));
|
|
220
|
+
};
|
|
221
|
+
const loadSnapshot = async (versionId) => {
|
|
222
|
+
const r = await ops.entries.getVersion(collection, id, versionId);
|
|
223
|
+
if (r.ok)
|
|
224
|
+
setSnapshot({ id: r.data.id, fields: r.data.fields });
|
|
225
|
+
else
|
|
226
|
+
fail(r);
|
|
227
|
+
};
|
|
228
|
+
const restore = async (versionId) => {
|
|
229
|
+
const r = await ops.entries.restore(collection, id, locale, versionId);
|
|
230
|
+
if (!r.ok) {
|
|
231
|
+
fail(r);
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
setRecord(r.data);
|
|
235
|
+
setReloadKey((k) => k + 1);
|
|
236
|
+
setNotice({ tone: "ok", text: "Version restored." });
|
|
237
|
+
return true;
|
|
133
238
|
};
|
|
134
239
|
const goToLocale = (next) => {
|
|
135
240
|
if (next === locale)
|
|
136
241
|
return;
|
|
137
242
|
writeLocaleToUrl(next);
|
|
138
|
-
|
|
243
|
+
setNotice(null);
|
|
139
244
|
setLocale(next);
|
|
140
245
|
};
|
|
141
|
-
|
|
246
|
+
/** A switch flushes the pending edit first: nothing is ever unsaved past the debounce. */
|
|
247
|
+
const switchLocale = async (next) => {
|
|
142
248
|
if (next === locale)
|
|
143
249
|
return;
|
|
144
|
-
if (
|
|
250
|
+
if (!(await flush())) {
|
|
251
|
+
setNotice({ tone: "error", text: "Couldn't save your latest changes — stayed on this locale." });
|
|
145
252
|
return;
|
|
253
|
+
}
|
|
146
254
|
goToLocale(next);
|
|
147
255
|
};
|
|
148
256
|
/** Add a locale, copying the locale on screen (or the record's source locale when this one is absent). */
|
|
149
257
|
const doAddLocale = async (target) => {
|
|
150
258
|
if (!record || adding)
|
|
151
259
|
return;
|
|
152
|
-
if (
|
|
260
|
+
if (!(await flush())) {
|
|
261
|
+
setNotice({ tone: "error", text: "Couldn't save your latest changes — locale not added." });
|
|
153
262
|
return;
|
|
263
|
+
}
|
|
154
264
|
const from = record.locales[locale] ? locale : entrySourceLocale(record, registry);
|
|
155
265
|
setAdding(true);
|
|
156
266
|
let result;
|
|
@@ -162,7 +272,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
162
272
|
}
|
|
163
273
|
setAdding(false);
|
|
164
274
|
if (!result.ok) {
|
|
165
|
-
|
|
275
|
+
fail(result);
|
|
166
276
|
return;
|
|
167
277
|
}
|
|
168
278
|
setRecord(result.data);
|
|
@@ -177,8 +287,6 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
177
287
|
const remaining = Object.keys(record.locales).filter((l) => l !== locale);
|
|
178
288
|
if (remaining.length === 0)
|
|
179
289
|
return;
|
|
180
|
-
if (dirty && !window.confirm("Discard unsaved changes?"))
|
|
181
|
-
return;
|
|
182
290
|
if (!window.confirm(`Remove this entry from ${locale}? Its ${locale} fields and history are deleted.`))
|
|
183
291
|
return;
|
|
184
292
|
let result;
|
|
@@ -189,7 +297,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
189
297
|
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
190
298
|
}
|
|
191
299
|
if (!result.ok) {
|
|
192
|
-
|
|
300
|
+
fail(result);
|
|
193
301
|
return;
|
|
194
302
|
}
|
|
195
303
|
setRecord(result.data);
|
|
@@ -202,28 +310,26 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
202
310
|
if (r.ok)
|
|
203
311
|
navigate(`${BASE}/${collection}`, { force: true });
|
|
204
312
|
else
|
|
205
|
-
|
|
313
|
+
fail(r);
|
|
206
314
|
};
|
|
207
|
-
const heading =
|
|
315
|
+
const heading = record ? entryTitle(record, meta, registry, locale) : "…";
|
|
208
316
|
// The mount's path in the ACTIVE locale prefixes the slug; no path, no prefix.
|
|
209
317
|
const base = meta.path?.[locale];
|
|
210
318
|
const slugPrefix = base ? `/${base}/` : undefined;
|
|
211
319
|
const present = record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale];
|
|
212
|
-
const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: fieldErrors, refOptions: refTargets[key] ? refOptions[refTargets[key]] ?? [] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, mark: multilingual && !localeKeys.includes(key) ? _jsx(UnlocalizedMark, {}) : undefined, onChange: (value) => {
|
|
213
|
-
|
|
320
|
+
const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: fieldErrors, refOptions: refTargets[key] ? refOptions[refTargets[key]] ?? [] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, mark: multilingual && !localeKeys.includes(key) ? _jsx(UnlocalizedMark, {}) : undefined, onChange: (value) => {
|
|
321
|
+
setFields((f) => ({ ...(f ?? {}), [key]: value }));
|
|
322
|
+
setDirty(true);
|
|
323
|
+
setTouched(true);
|
|
324
|
+
mark();
|
|
325
|
+
} }, key));
|
|
326
|
+
const absent = record !== null && fields === null && !record.locales[locale];
|
|
214
327
|
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: `${BASE}/${collection}`, items: [
|
|
215
328
|
{
|
|
216
329
|
label: meta.title, href: `${BASE}/${collection}`,
|
|
217
|
-
trailing: multilingual && (
|
|
218
|
-
? _jsxs("span", { className: "sm-hint", children: ["\u00B7 ", locale] })
|
|
219
|
-
: _jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: switchLocale, onAdd: (l) => void doAddLocale(l) })),
|
|
330
|
+
trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => void doAddLocale(l) })),
|
|
220
331
|
},
|
|
221
332
|
{ label: heading },
|
|
222
|
-
] }) }), right:
|
|
223
|
-
}
|
|
224
|
-
function InfoRow({ label, value, mono }) {
|
|
225
|
-
return (_jsxs("div", { style: { display: "flex", justifyContent: "space-between", gap: 10, fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: [_jsx("span", { children: label }), _jsx("span", { style: {
|
|
226
|
-
color: "var(--text)", fontFamily: mono ? "var(--font-mono)" : undefined,
|
|
227
|
-
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
|
228
|
-
}, children: value })] }));
|
|
333
|
+
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), urls[locale] && (_jsx(LinkButton, { href: urls[locale], target: "_blank", rel: "noreferrer", title: "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: "View live" })), _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" }) })), _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)
|
|
334
|
+
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" })] }))] })] })] }));
|
|
229
335
|
}
|
|
@@ -18,12 +18,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
18
18
|
// ↑/↓ move between entries, a pointer down outside closes. A child being
|
|
19
19
|
// active highlights its parent tile and the entry inside the menu.
|
|
20
20
|
import { useEffect, useId, useRef, useState } from "react";
|
|
21
|
-
import { ChevronDown,
|
|
21
|
+
import { ChevronDown, Globe, Image, LibraryBig, PanelTop, Settings } from "lucide-react";
|
|
22
22
|
import { ThemeToggle } from "../ui/primitives.js";
|
|
23
23
|
import { BASE } from "./base.js";
|
|
24
24
|
import { RouteLink } from "./RouteLink.js";
|
|
25
25
|
/** Lucide at the rail's size and stroke (the tile's CSS restates both). */
|
|
26
|
-
const ICON = { size: 18, strokeWidth:
|
|
26
|
+
const ICON = { size: 18, strokeWidth: 2, "aria-hidden": true };
|
|
27
27
|
/** The app icon, inlined: a package can't ship a file into the host app's
|
|
28
28
|
* /public. Brand blue on white in both themes, so no --logo-filter. */
|
|
29
29
|
function AppIcon() {
|
|
@@ -70,5 +70,5 @@ export function IconRail({ registry, active, email, onSignOut }) {
|
|
|
70
70
|
const collectionActive = collections.some((c) => c.name === active);
|
|
71
71
|
const name = email?.split("@")[0] ?? "Editor";
|
|
72
72
|
const initials = name.slice(0, 2).toUpperCase();
|
|
73
|
-
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(
|
|
73
|
+
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 }), `${BASE}/pages`), collections.length > 0 && (_jsxs("li", { className: "sm-rail__item", children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": open === "collections", "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsxs("span", { className: "sm-rail__tile", children: [_jsx(LibraryBig, { ...ICON }), _jsx("span", { className: "sm-rail__chev", children: _jsx(ChevronDown, { ...ICON, size: 8, strokeWidth: 3 }) })] }), _jsx("span", { className: "sm-rail__label", children: "Collections" })] }), _jsxs("ul", { className: "sm-rail__menu", role: "menu", id: menuId("collections"), "data-open": open === "collections", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: "Collections" }), collections.map((c) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-rail__menu-item", href: `${BASE}/${c.name}`, "aria-current": c.name === active ? "page" : undefined, onClick: close, children: _jsx("span", { children: c.title }) }) }, c.name)))] })] })), link("media", "Media", _jsx(Image, { ...ICON }), `${BASE}/media`), registry.shared.length > 0 && link("shared", "Globals", _jsx(Globe, { ...ICON }), `${BASE}/shared`), link("settings", "Settings", _jsx(Settings, { ...ICON }), null)] }), _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" }) })] })] })] }));
|
|
74
74
|
}
|
|
@@ -11,6 +11,7 @@ import { useCallback, useEffect, useState } from "react";
|
|
|
11
11
|
import { ListView } from "./ListView.js";
|
|
12
12
|
import { canDragRow, openTemplates, pagesTreeRows, projectDrop, sourceLocaleOf, subtreeHeight } from "./pages-tree.js";
|
|
13
13
|
import { Button, StatusBadge } from "../ui/primitives.js";
|
|
14
|
+
import { statusLabel } from "../ui/status-label.js";
|
|
14
15
|
import { LocalePicker } from "../ui/LocaleSwitcher.js";
|
|
15
16
|
import { useSortable } from "../ui/useSortable.js";
|
|
16
17
|
import { BASE } from "./base.js";
|
|
@@ -207,7 +208,7 @@ export function PagesList({ ops, registry }) {
|
|
|
207
208
|
{ label: "Title", width: "minmax(160px,1fr)" },
|
|
208
209
|
{ label: "Path", width: "minmax(140px,260px)" },
|
|
209
210
|
{ label: "Template", width: "minmax(100px,160px)" },
|
|
210
|
-
{ label: "Status", width: "
|
|
211
|
+
{ label: "Status", width: "170px" },
|
|
211
212
|
{ label: "", width: "32px" },
|
|
212
213
|
], empty: pages ? "No pages yet." : "Loading…", sortable: { hook: sortable, canDrag: (i) => canDragRow(rows[i]) }, rows: rows.map((row, i) => {
|
|
213
214
|
// An absent node is still a row (structure is shared) but reads as dimmed.
|
|
@@ -225,7 +226,7 @@ export function PagesList({ ops, registry }) {
|
|
|
225
226
|
: _jsxs("span", { className: "sm-cell sm-cell--muted", children: ["not in ", locale, " \u2014 click to add"] }, "p"),
|
|
226
227
|
_jsx("span", { className: "sm-cell sm-cell--muted", style: dim, children: row.templateTitle }, "k"),
|
|
227
228
|
row.status
|
|
228
|
-
? _jsx(StatusBadge, { status: row.status }, "s")
|
|
229
|
+
? _jsx(StatusBadge, { status: statusLabel(row.status) }, "s")
|
|
229
230
|
: _jsx("span", { className: "sm-cell sm-cell--muted", children: "\u2014" }, "s"),
|
|
230
231
|
_jsx("button", { type: "button", className: "sm-rowmenu", "aria-label": "Row menu", onClick: (e) => { e.stopPropagation(); setMenu({ row, x: e.clientX, y: e.clientY }); }, onKeyDown: (e) => e.stopPropagation(), children: "\u00B7\u00B7\u00B7" }, "m"),
|
|
231
232
|
],
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AdminShared } from "../serialize.ts";
|
|
2
|
+
import type { TabEntry } from "../forms/tabs.ts";
|
|
3
|
+
export type RefOption = {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
status: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function SharedFields({ item, entries, fields, errors, refOptions, multilingual, inlinePath, onEditHere, onChange }: {
|
|
9
|
+
item: AdminShared;
|
|
10
|
+
entries: TabEntry[];
|
|
11
|
+
/** The merged draft fields; a visual item's styles under `$styles`. */
|
|
12
|
+
fields: Record<string, unknown>;
|
|
13
|
+
errors?: Record<string, string>;
|
|
14
|
+
/** Loaded ref options by target collection. */
|
|
15
|
+
refOptions: Record<string, RefOption[]>;
|
|
16
|
+
multilingual: boolean;
|
|
17
|
+
/** The content field with an active inline session: its richtext widget yields to a note. */
|
|
18
|
+
inlinePath?: string;
|
|
19
|
+
onEditHere?(): void;
|
|
20
|
+
onChange(ns: "fields" | "styles", key: string, value: unknown): void;
|
|
21
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { STYLES_KEY } from "../../shared.js";
|
|
4
|
+
import { FieldWidget } from "../forms/FieldWidget.js";
|
|
5
|
+
import { UnlocalizedMark } from "../ui/primitives.js";
|
|
6
|
+
export function SharedFields({ item, entries, fields, errors, refOptions, multilingual, inlinePath, onEditHere, onChange }) {
|
|
7
|
+
const styles = (fields[STYLES_KEY] ?? {});
|
|
8
|
+
return (_jsx(_Fragment, { children: entries.map(({ key, ns, descriptor }) => {
|
|
9
|
+
if (ns === "meta")
|
|
10
|
+
return null; // a shared item has no meta namespace
|
|
11
|
+
const target = (descriptor.type === "ref" || descriptor.type === "refList") && typeof descriptor.target === "string" ? descriptor.target : null;
|
|
12
|
+
const localized = ns === "fields" && item.fields[key]?.localized === true;
|
|
13
|
+
return (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: ns === "fields" ? fields[key] : styles[key], errors: errors, refOptions: target ? refOptions[target] ?? [] : undefined, mark: ns === "fields" && multilingual && !localized ? _jsx(UnlocalizedMark, {}) : undefined, inlinePath: ns === "fields" ? inlinePath : undefined, onEditHere: onEditHere, onChange: (value) => onChange(ns, key, value) }, `${ns}.${key}`));
|
|
14
|
+
}) }));
|
|
15
|
+
}
|