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
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { setAtPath } from "../field-paths.js";
|
|
2
|
+
import { createDraftAutosave } from "./autosave.js";
|
|
3
|
+
export function createSharedEdit(opts) {
|
|
4
|
+
let items = {};
|
|
5
|
+
let loadError = null;
|
|
6
|
+
let generation = 0;
|
|
7
|
+
const loading = new Set();
|
|
8
|
+
const rev = new Map();
|
|
9
|
+
const savedRev = new Map();
|
|
10
|
+
const listeners = new Set();
|
|
11
|
+
let snapshot = null;
|
|
12
|
+
const emit = () => { snapshot = null; for (const l of listeners)
|
|
13
|
+
l(); };
|
|
14
|
+
const dirty = () => Object.values(items).filter((i) => (rev.get(i.name) ?? 0) > (savedRev.get(i.name) ?? 0));
|
|
15
|
+
const autosave = createDraftAutosave({
|
|
16
|
+
read: () => { const d = dirty(); return d.length > 0 ? d : null; },
|
|
17
|
+
persist: async (list) => {
|
|
18
|
+
let failed = null;
|
|
19
|
+
for (const item of list) {
|
|
20
|
+
const at = rev.get(item.name) ?? 0;
|
|
21
|
+
let r;
|
|
22
|
+
try {
|
|
23
|
+
r = await opts.ops.shared.save(item.name, item.locale, item.fields);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
r = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
27
|
+
}
|
|
28
|
+
const current = items[item.name];
|
|
29
|
+
if (r.ok) {
|
|
30
|
+
savedRev.set(item.name, at);
|
|
31
|
+
if (current)
|
|
32
|
+
items = { ...items, [item.name]: { ...current, record: r.data, errors: {} } };
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
failed ?? (failed = r);
|
|
36
|
+
if (current) {
|
|
37
|
+
items = { ...items, [item.name]: { ...current, errors: Object.fromEntries((r.fields ?? []).map((f) => [f.field, f.message])) } };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
emit();
|
|
42
|
+
return failed ?? { ok: true, data: null };
|
|
43
|
+
},
|
|
44
|
+
onSaved: opts.onSaved,
|
|
45
|
+
debounceMs: opts.debounceMs,
|
|
46
|
+
retryBaseMs: opts.retryBaseMs,
|
|
47
|
+
retryCapMs: opts.retryCapMs,
|
|
48
|
+
});
|
|
49
|
+
autosave.subscribe(emit);
|
|
50
|
+
const getState = () => (snapshot ?? (snapshot = { items, saveState: autosave.getState(), loadError }));
|
|
51
|
+
return {
|
|
52
|
+
getState,
|
|
53
|
+
subscribe(listener) {
|
|
54
|
+
listeners.add(listener);
|
|
55
|
+
return () => { listeners.delete(listener); };
|
|
56
|
+
},
|
|
57
|
+
async load(name, locale) {
|
|
58
|
+
const key = `${name}/${locale}`;
|
|
59
|
+
if (items[name]?.locale === locale || loading.has(key))
|
|
60
|
+
return;
|
|
61
|
+
loading.add(key);
|
|
62
|
+
const gen = generation;
|
|
63
|
+
let r;
|
|
64
|
+
try {
|
|
65
|
+
r = await opts.ops.shared.get(name, locale);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
r = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
69
|
+
}
|
|
70
|
+
loading.delete(key);
|
|
71
|
+
if (gen !== generation)
|
|
72
|
+
return; // reset() ran meanwhile — this canvas is gone
|
|
73
|
+
if (!r.ok) {
|
|
74
|
+
loadError = r.message;
|
|
75
|
+
emit();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
items = { ...items, [name]: { name, locale, record: r.data.record, fields: r.data.fields, usage: r.data.usage, errors: {} } };
|
|
79
|
+
loadError = null;
|
|
80
|
+
emit();
|
|
81
|
+
},
|
|
82
|
+
patch(name, path, value) {
|
|
83
|
+
const item = items[name];
|
|
84
|
+
if (!item)
|
|
85
|
+
return;
|
|
86
|
+
items = { ...items, [name]: { ...item, fields: setAtPath(item.fields, path, value) } };
|
|
87
|
+
rev.set(name, (rev.get(name) ?? 0) + 1);
|
|
88
|
+
emit();
|
|
89
|
+
autosave.mark();
|
|
90
|
+
},
|
|
91
|
+
flush: autosave.flush,
|
|
92
|
+
async setStatus(name, status) {
|
|
93
|
+
const item = items[name];
|
|
94
|
+
if (!item)
|
|
95
|
+
return { ok: false, code: "not_found", message: "This item hasn't loaded yet." };
|
|
96
|
+
if (!(await autosave.flush())) {
|
|
97
|
+
return { ok: false, code: "internal", message: "Couldn't save your latest changes — publish cancelled." };
|
|
98
|
+
}
|
|
99
|
+
let r;
|
|
100
|
+
try {
|
|
101
|
+
r = await opts.ops.shared.setStatus(name, item.locale, status);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
r = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
105
|
+
}
|
|
106
|
+
if (r.ok && items[name]) {
|
|
107
|
+
items = { ...items, [name]: { ...items[name], record: r.data } };
|
|
108
|
+
emit();
|
|
109
|
+
}
|
|
110
|
+
return r;
|
|
111
|
+
},
|
|
112
|
+
reset() {
|
|
113
|
+
generation += 1;
|
|
114
|
+
items = {};
|
|
115
|
+
rev.clear();
|
|
116
|
+
savedRev.clear();
|
|
117
|
+
loading.clear();
|
|
118
|
+
loadError = null;
|
|
119
|
+
autosave.dispose();
|
|
120
|
+
emit();
|
|
121
|
+
},
|
|
122
|
+
dispose: autosave.dispose,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { AdminOps } from "../ops.ts";
|
|
2
|
+
import { type SharedEdit, type SharedEditState } from "./shared-edit.ts";
|
|
3
|
+
export declare function useSharedEdit({ ops, onSaved }: {
|
|
4
|
+
ops: Pick<AdminOps, "shared">;
|
|
5
|
+
onSaved?: () => void;
|
|
6
|
+
}): SharedEditState & Pick<SharedEdit, "load" | "patch" | "flush" | "setStatus" | "reset">;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// React glue for `createSharedEdit`: one controller per editor mount, the
|
|
2
|
+
// latest `onSaved` through a ref (it closes over the inline session), the
|
|
3
|
+
// state through useSyncExternalStore. On unmount a pending save is
|
|
4
|
+
// flushed first — the server call outlives the view — then the timers stop.
|
|
5
|
+
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
6
|
+
import { createSharedEdit } from "./shared-edit.js";
|
|
7
|
+
export function useSharedEdit({ ops, onSaved }) {
|
|
8
|
+
const latest = useRef(onSaved);
|
|
9
|
+
latest.current = onSaved;
|
|
10
|
+
const [controller] = useState(() => createSharedEdit({ ops, onSaved: () => latest.current?.() }));
|
|
11
|
+
useEffect(() => () => { void controller.flush(); controller.dispose(); }, [controller]);
|
|
12
|
+
const state = useSyncExternalStore(controller.subscribe, controller.getState, controller.getState);
|
|
13
|
+
return {
|
|
14
|
+
...state,
|
|
15
|
+
load: controller.load, patch: controller.patch, flush: controller.flush,
|
|
16
|
+
setStatus: controller.setStatus, reset: controller.reset,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -1,14 +1,20 @@
|
|
|
1
|
-
import type { AdminField
|
|
1
|
+
import type { AdminField } from "../serialize.ts";
|
|
2
2
|
export type TabName = "Content" | "Styles" | "Media" | "Meta";
|
|
3
3
|
export type TabEntry = {
|
|
4
4
|
key: string;
|
|
5
5
|
ns: "fields" | "styles" | "meta";
|
|
6
6
|
descriptor: AdminField;
|
|
7
7
|
};
|
|
8
|
-
|
|
8
|
+
/** Anything with namespaced descriptors: a section, or a shared item (fields + styles, no meta). */
|
|
9
|
+
export type FieldsHolder = {
|
|
10
|
+
fields: Record<string, AdminField>;
|
|
11
|
+
styles?: Record<string, AdminField>;
|
|
12
|
+
meta?: Record<string, AdminField>;
|
|
13
|
+
};
|
|
14
|
+
export declare function tabsFor(section: FieldsHolder): {
|
|
9
15
|
tab: TabName;
|
|
10
16
|
entries: TabEntry[];
|
|
11
17
|
}[];
|
|
12
18
|
/** The tab holding a content field, by its dotted path's top-level key — a canvas
|
|
13
19
|
* click on a marked image reveals it on Media, where the projection put it. */
|
|
14
|
-
export declare function tabOf(section:
|
|
20
|
+
export declare function tabOf(section: FieldsHolder, path: string): TabName | undefined;
|
|
@@ -7,7 +7,7 @@ import { draftMode } from "next/headers";
|
|
|
7
7
|
import { gateAdminOp } from "../auth.js";
|
|
8
8
|
import { createAdminOps, runAdminOp } from "../ops-impl.js";
|
|
9
9
|
export function createAdminOpHandler(deps) {
|
|
10
|
-
const
|
|
10
|
+
const base = {
|
|
11
11
|
config: deps.config,
|
|
12
12
|
pages: deps.pages,
|
|
13
13
|
entries: deps.entries,
|
|
@@ -24,6 +24,7 @@ export function createAdminOpHandler(deps) {
|
|
|
24
24
|
disable: async () => (await draftMode()).disable(),
|
|
25
25
|
},
|
|
26
26
|
},
|
|
27
|
-
}
|
|
28
|
-
|
|
27
|
+
};
|
|
28
|
+
// Ops are built per call so the verified session rides into every store write as `by` (spec 2026-09-13 history §2).
|
|
29
|
+
return (call) => gateAdminOp(deps.auth, call, (c, session) => runAdminOp(createAdminOps({ ...base, actor: session }), c));
|
|
29
30
|
}
|
package/dist/admin/ops-impl.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { EntryStore } from "../entry-store.ts";
|
|
|
3
3
|
import type { AssetStore } from "../asset-store.ts";
|
|
4
4
|
import type { PageStore } from "../store.ts";
|
|
5
5
|
import type { AdminOpCall, AdminOps, OpResult } from "./ops.ts";
|
|
6
|
+
import type { AdminSession } from "./auth.ts";
|
|
6
7
|
export type AdminEffects = {
|
|
7
8
|
/** Expire one cache tag (`pageTag(id)` / `entryTag(id)`). */
|
|
8
9
|
revalidate?(tag: string): void;
|
|
@@ -20,6 +21,8 @@ export declare function createAdminOps(deps: {
|
|
|
20
21
|
href?: (path: string, locale: string) => string;
|
|
21
22
|
/** The media library; absent = every assets op answers "no media store". */
|
|
22
23
|
assets?: AssetStore;
|
|
24
|
+
/** The verified editor; their email is every write's `by`. Absent for the seed and tests (spec 2026-09-13 history §2). */
|
|
25
|
+
actor?: AdminSession;
|
|
23
26
|
}): AdminOps;
|
|
24
27
|
/** Path-dispatch for the one-server-action seam. Own-property checks only —
|
|
25
28
|
* "constructor.constructor" and prototype paths must not resolve. */
|
package/dist/admin/ops-impl.js
CHANGED
|
@@ -5,8 +5,11 @@ import { affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag, shared
|
|
|
5
5
|
import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
|
|
6
6
|
import { ensureSharedItems } from "./shared-items.js";
|
|
7
7
|
import { validateFields } from "./validate.js";
|
|
8
|
+
import { withDefaults } from "../field-defaults.js";
|
|
8
9
|
import { elementSchemas } from "../richtext-walk.js";
|
|
9
10
|
const ok = (data) => ({ ok: true, data });
|
|
11
|
+
/** A history row without its content (spec 2026-09-13 history §6). */
|
|
12
|
+
const summary = ({ id, savedAt }) => ({ id, savedAt });
|
|
10
13
|
const err = (code, message, fields) => ({ ok: false, code, message, ...(fields ? { fields } : {}) });
|
|
11
14
|
/** Own-property, non-array plain object — same shape guard the tree checks
|
|
12
15
|
* below and `runAdminOp`'s envelope check both lean on. */
|
|
@@ -62,6 +65,8 @@ const classify = (e) => {
|
|
|
62
65
|
return err("blocked", message);
|
|
63
66
|
if (message.includes("cannot publish"))
|
|
64
67
|
return err("blocked", message);
|
|
68
|
+
if (message.includes("nothing published"))
|
|
69
|
+
return err("blocked", message);
|
|
65
70
|
if (message.includes("already taken"))
|
|
66
71
|
return err("conflict", message);
|
|
67
72
|
if (message.includes("already has a template"))
|
|
@@ -88,6 +93,7 @@ const classify = (e) => {
|
|
|
88
93
|
export function createAdminOps(deps) {
|
|
89
94
|
const { config, pages, entries, effects } = deps;
|
|
90
95
|
const href = deps.href ?? ((path) => path);
|
|
96
|
+
const by = deps.actor?.email ?? undefined;
|
|
91
97
|
const collectionFields = (name) => config.registry.collections?.find((c) => c.name === name)?.fields ?? null;
|
|
92
98
|
// Element nodes inside richtext validate against these (spec 2026-09-08 block nodes §2).
|
|
93
99
|
const elementDescs = elementSchemas((config.registry.elements ?? []).map((e) => ({ name: e.schema.name, fields: e.schema.fields ?? {}, styles: e.schema.styles })));
|
|
@@ -281,7 +287,7 @@ export function createAdminOps(deps) {
|
|
|
281
287
|
const created = await pages.createPage({
|
|
282
288
|
locale: input.locale, slug: input.slug, template: input.template, parentId: input.parentId,
|
|
283
289
|
...(input.title !== undefined ? { title: input.title } : {}),
|
|
284
|
-
});
|
|
290
|
+
}, by);
|
|
285
291
|
expireTree();
|
|
286
292
|
return ok(created);
|
|
287
293
|
}),
|
|
@@ -293,7 +299,7 @@ export function createAdminOps(deps) {
|
|
|
293
299
|
return err("validation", "smoodly: malformed page tree.");
|
|
294
300
|
if (!(await pages.getPage(id)))
|
|
295
301
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
296
|
-
return ok({ versionId: (await pages.saveDraft(id, locale, tree)).id });
|
|
302
|
+
return ok({ versionId: (await pages.saveDraft(id, locale, tree, by)).id });
|
|
297
303
|
}),
|
|
298
304
|
publish: (id, locale) => guard(async () => {
|
|
299
305
|
const unsupported = supportedLocale(locale);
|
|
@@ -330,12 +336,75 @@ export function createAdminOps(deps) {
|
|
|
330
336
|
if (fieldErrors.length > 0) {
|
|
331
337
|
return err("validation", `smoodly: cannot publish "${row.slug}" — fix the listed fields first.`, fieldErrors);
|
|
332
338
|
}
|
|
333
|
-
const published = await pages.publish(id, locale);
|
|
339
|
+
const published = await pages.publish(id, locale, by);
|
|
334
340
|
// The parent's cached unit lists its published children.
|
|
335
341
|
expirePages([published.id, published.parentId]);
|
|
336
342
|
expireTree();
|
|
337
343
|
return ok(published);
|
|
338
344
|
}),
|
|
345
|
+
unpublish: (id, locale) => guard(async () => {
|
|
346
|
+
const unsupported = supportedLocale(locale);
|
|
347
|
+
if (unsupported)
|
|
348
|
+
return unsupported;
|
|
349
|
+
const record = await pages.getPage(id);
|
|
350
|
+
if (!record)
|
|
351
|
+
return err("not_found", `smoodly: no page "${id}".`);
|
|
352
|
+
if (!record.locales[locale])
|
|
353
|
+
return err("not_found", `smoodly: page "${id}" does not exist in locale "${locale}".`);
|
|
354
|
+
if (isHomeNode(record))
|
|
355
|
+
return err("blocked", `smoodly: "${nameOf(record)}" is the site root — it cannot be unpublished.`);
|
|
356
|
+
if (fixedNodeOf(record, config))
|
|
357
|
+
return owned(record, "it stays published");
|
|
358
|
+
const off = await pages.unpublish(id, locale, by);
|
|
359
|
+
// The parent's cached unit lists its published children; the tree listing shows status.
|
|
360
|
+
expirePages([off.id, off.parentId]);
|
|
361
|
+
expireTree();
|
|
362
|
+
return ok(off);
|
|
363
|
+
}),
|
|
364
|
+
discardDraft: (id, locale) => guard(async () => {
|
|
365
|
+
const unsupported = supportedLocale(locale);
|
|
366
|
+
if (unsupported)
|
|
367
|
+
return unsupported;
|
|
368
|
+
const record = await pages.getPage(id);
|
|
369
|
+
if (!record)
|
|
370
|
+
return err("not_found", `smoodly: no page "${id}".`);
|
|
371
|
+
const row = record.locales[locale];
|
|
372
|
+
if (!row)
|
|
373
|
+
return err("not_found", `smoodly: page "${id}" does not exist in locale "${locale}".`);
|
|
374
|
+
if (!row.publishedVersionId) {
|
|
375
|
+
return err("blocked", `smoodly: "${nameOf(record)}" has nothing published in ${locale} — there is no version to restore.`);
|
|
376
|
+
}
|
|
377
|
+
const restored = await pages.discardDraft(id, locale, by);
|
|
378
|
+
expirePages([restored.id]); // the draft-mode render of this page changed
|
|
379
|
+
return ok(restored);
|
|
380
|
+
}),
|
|
381
|
+
listVersions: (id, locale) => guard(async () => {
|
|
382
|
+
const unsupported = supportedLocale(locale);
|
|
383
|
+
if (unsupported)
|
|
384
|
+
return unsupported;
|
|
385
|
+
if (!(await pages.getPage(id)))
|
|
386
|
+
return err("not_found", `smoodly: no page "${id}".`);
|
|
387
|
+
return ok((await pages.listVersions(id, locale)).map(summary));
|
|
388
|
+
}),
|
|
389
|
+
restore: (id, locale, versionId) => guard(async () => {
|
|
390
|
+
const unsupported = supportedLocale(locale);
|
|
391
|
+
if (unsupported)
|
|
392
|
+
return unsupported;
|
|
393
|
+
const record = await pages.getPage(id);
|
|
394
|
+
if (!record)
|
|
395
|
+
return err("not_found", `smoodly: no page "${id}".`);
|
|
396
|
+
if (!record.locales[locale])
|
|
397
|
+
return err("not_found", `smoodly: page "${id}" does not exist in locale "${locale}".`);
|
|
398
|
+
if (record.template === null)
|
|
399
|
+
return err("blocked", `smoodly: "${nameOf(record)}" is a folder — it has no versions.`);
|
|
400
|
+
const version = typeof versionId === "string" ? await pages.getVersion(versionId) : null;
|
|
401
|
+
if (!version || version.pageId !== id || version.locale !== locale) {
|
|
402
|
+
return err("not_found", `smoodly: no version "${String(versionId)}" of this page in ${locale}.`);
|
|
403
|
+
}
|
|
404
|
+
const restored = await pages.restore(id, locale, versionId, by);
|
|
405
|
+
expirePages([restored.id]); // the draft-mode render of this page changed
|
|
406
|
+
return ok(restored);
|
|
407
|
+
}),
|
|
339
408
|
delete: (id) => guard(async () => {
|
|
340
409
|
const record = await pages.getPage(id);
|
|
341
410
|
if (!record)
|
|
@@ -364,7 +433,7 @@ export function createAdminOps(deps) {
|
|
|
364
433
|
const unsupported = supportedLocale(patch.locale);
|
|
365
434
|
if (unsupported)
|
|
366
435
|
return unsupported;
|
|
367
|
-
const renamed = await pages.rename(id, patch);
|
|
436
|
+
const renamed = await pages.rename(id, patch, by);
|
|
368
437
|
// Every descendant's path changed with this segment; the parent
|
|
369
438
|
// lists this node's title and path.
|
|
370
439
|
expirePages([...(await subtreeIds(id)), record.parentId]);
|
|
@@ -408,7 +477,7 @@ export function createAdminOps(deps) {
|
|
|
408
477
|
if (registration.schema.slug !== undefined) {
|
|
409
478
|
return err("validation", `smoodly: page template "${template}" is fixed — Smoodly creates that page for you.`);
|
|
410
479
|
}
|
|
411
|
-
const withTemplate = await pages.setTemplate(id, template);
|
|
480
|
+
const withTemplate = await pages.setTemplate(id, template, by);
|
|
412
481
|
expireTree();
|
|
413
482
|
return ok(withTemplate);
|
|
414
483
|
}),
|
|
@@ -419,7 +488,7 @@ export function createAdminOps(deps) {
|
|
|
419
488
|
if (!(await pages.getPage(id)))
|
|
420
489
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
421
490
|
// A new locale is a draft: nothing public changes but the tree listing (spec 2026-09-11 §2).
|
|
422
|
-
const added = await pages.addLocale(id, locale, { from: options.from });
|
|
491
|
+
const added = await pages.addLocale(id, locale, { from: options.from }, by);
|
|
423
492
|
expireTree();
|
|
424
493
|
return ok(added);
|
|
425
494
|
}),
|
|
@@ -455,7 +524,9 @@ export function createAdminOps(deps) {
|
|
|
455
524
|
const parentLocales = record.parentId === null
|
|
456
525
|
? config.locales.supported
|
|
457
526
|
: Object.keys((await entries.get(collection, record.parentId))?.locales ?? {});
|
|
458
|
-
|
|
527
|
+
const paths = await entries.pathsOf(collection, id);
|
|
528
|
+
const urls = Object.fromEntries(Object.entries(paths).map(([l, p]) => [l, href(p, l)]));
|
|
529
|
+
return ok({ record, locale, fields, parentLocales, urls });
|
|
459
530
|
}),
|
|
460
531
|
create: (collection, input) => guard(async () => {
|
|
461
532
|
const unsupported = supportedLocale(input.locale);
|
|
@@ -464,10 +535,7 @@ export function createAdminOps(deps) {
|
|
|
464
535
|
const descriptors = collectionFields(collection);
|
|
465
536
|
if (!descriptors)
|
|
466
537
|
return err("not_found", `smoodly: no collection named "${collection}".`);
|
|
467
|
-
const
|
|
468
|
-
if (fieldErrors.length > 0)
|
|
469
|
-
return err("validation", "Some fields need attention.", fieldErrors);
|
|
470
|
-
const created = await entries.create(collection, input.fields, { locale: input.locale, parentId: input.parentId ?? null });
|
|
538
|
+
const created = await entries.create(collection, withDefaults(input.fields, descriptors), { locale: input.locale, parentId: input.parentId ?? null }, by);
|
|
471
539
|
expireCollection(collection);
|
|
472
540
|
return ok(created);
|
|
473
541
|
}),
|
|
@@ -478,10 +546,7 @@ export function createAdminOps(deps) {
|
|
|
478
546
|
const descriptors = collectionFields(collection);
|
|
479
547
|
if (!descriptors)
|
|
480
548
|
return err("not_found", `smoodly: no collection named "${collection}".`);
|
|
481
|
-
const
|
|
482
|
-
if (fieldErrors.length > 0)
|
|
483
|
-
return err("validation", "Some fields need attention.", fieldErrors);
|
|
484
|
-
const entry = await entries.update(collection, id, locale, fields);
|
|
549
|
+
const entry = await entries.update(collection, id, locale, fields, by);
|
|
485
550
|
await fanOut(id);
|
|
486
551
|
expireCollection(collection);
|
|
487
552
|
return ok(entry);
|
|
@@ -490,11 +555,62 @@ export function createAdminOps(deps) {
|
|
|
490
555
|
const unsupported = supportedLocale(locale);
|
|
491
556
|
if (unsupported)
|
|
492
557
|
return unsupported;
|
|
493
|
-
|
|
558
|
+
if (status === "published") {
|
|
559
|
+
// A draft may be incomplete; what goes live may not (spec 2026-09-14 §2).
|
|
560
|
+
const descriptors = collectionFields(collection);
|
|
561
|
+
if (!descriptors)
|
|
562
|
+
return err("not_found", `smoodly: no collection named "${collection}".`);
|
|
563
|
+
const record = await entries.get(collection, id);
|
|
564
|
+
if (!record)
|
|
565
|
+
return err("not_found", `smoodly: no entry "${id}" in "${collection}".`);
|
|
566
|
+
if (!record.locales[locale])
|
|
567
|
+
return err("not_found", `smoodly: entry "${id}" does not exist in locale "${locale}".`);
|
|
568
|
+
const fieldErrors = validateFields(entryFieldsIn(record, locale), descriptors, elementDescs);
|
|
569
|
+
if (fieldErrors.length > 0)
|
|
570
|
+
return err("validation", "Some fields need attention.", fieldErrors);
|
|
571
|
+
}
|
|
572
|
+
const entry = await entries.setStatus(collection, id, locale, status, by);
|
|
494
573
|
await fanOut(id);
|
|
495
574
|
expireCollection(collection);
|
|
496
575
|
return ok(entry);
|
|
497
576
|
}),
|
|
577
|
+
discardDraft: (collection, id, locale) => guard(async () => {
|
|
578
|
+
const unsupported = supportedLocale(locale);
|
|
579
|
+
if (unsupported)
|
|
580
|
+
return unsupported;
|
|
581
|
+
const restored = await entries.discardDraft(collection, id, locale, by);
|
|
582
|
+
await fanOut(id);
|
|
583
|
+
expireCollection(collection);
|
|
584
|
+
return ok(restored);
|
|
585
|
+
}),
|
|
586
|
+
listVersions: (collection, id, locale) => guard(async () => {
|
|
587
|
+
const unsupported = supportedLocale(locale);
|
|
588
|
+
if (unsupported)
|
|
589
|
+
return unsupported;
|
|
590
|
+
if (!(await entries.get(collection, id)))
|
|
591
|
+
return err("not_found", `smoodly: no entry "${id}" in "${collection}".`);
|
|
592
|
+
return ok((await entries.listVersions(collection, id, locale)).map(summary));
|
|
593
|
+
}),
|
|
594
|
+
getVersion: (collection, id, versionId) => guard(async () => {
|
|
595
|
+
const version = typeof versionId === "string" ? await entries.getVersion(collection, versionId) : null;
|
|
596
|
+
if (!version || version.entryId !== id) {
|
|
597
|
+
return err("not_found", `smoodly: no version "${String(versionId)}" of entry "${id}" in "${collection}".`);
|
|
598
|
+
}
|
|
599
|
+
return ok({ ...summary(version), fields: version.fields });
|
|
600
|
+
}),
|
|
601
|
+
restore: (collection, id, locale, versionId) => guard(async () => {
|
|
602
|
+
const unsupported = supportedLocale(locale);
|
|
603
|
+
if (unsupported)
|
|
604
|
+
return unsupported;
|
|
605
|
+
const version = typeof versionId === "string" ? await entries.getVersion(collection, versionId) : null;
|
|
606
|
+
if (!version || version.entryId !== id || version.locale !== locale) {
|
|
607
|
+
return err("not_found", `smoodly: no version "${String(versionId)}" of entry "${id}" in "${collection}", locale ${locale}.`);
|
|
608
|
+
}
|
|
609
|
+
const restored = await entries.restore(collection, id, locale, versionId, by);
|
|
610
|
+
await fanOut(id);
|
|
611
|
+
expireCollection(collection);
|
|
612
|
+
return ok(restored);
|
|
613
|
+
}),
|
|
498
614
|
// The tag expires only after the delete succeeded; a blocked delete expires nothing.
|
|
499
615
|
delete: (collection, id) => guard(async () => (await entries.delete(collection, id), await fanOut(id), expireCollection(collection), ok(null))),
|
|
500
616
|
move: (collection, id, to) => guard(async () => {
|
|
@@ -512,7 +628,7 @@ export function createAdminOps(deps) {
|
|
|
512
628
|
const unsupported = supportedLocale(locale);
|
|
513
629
|
if (unsupported)
|
|
514
630
|
return unsupported;
|
|
515
|
-
const added = await entries.addLocale(collection, id, locale, { from: options.from });
|
|
631
|
+
const added = await entries.addLocale(collection, id, locale, { from: options.from }, by);
|
|
516
632
|
expireCollection(collection);
|
|
517
633
|
return ok(added);
|
|
518
634
|
}),
|
|
@@ -552,13 +668,10 @@ export function createAdminOps(deps) {
|
|
|
552
668
|
const item = sharedItem(name);
|
|
553
669
|
if (!item)
|
|
554
670
|
return err("not_found", `smoodly: no shared item named "${name}".`);
|
|
555
|
-
const fieldErrors = validateFields(fields, item.fields, elementDescs);
|
|
556
|
-
if (fieldErrors.length > 0)
|
|
557
|
-
return err("validation", "Some fields need attention.", fieldErrors);
|
|
558
671
|
const record = await sharedRecord(name);
|
|
559
672
|
if (!record)
|
|
560
673
|
return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
|
|
561
|
-
const saved = await entries.update(name, record.id, locale, fields);
|
|
674
|
+
const saved = await entries.update(name, record.id, locale, fields, by);
|
|
562
675
|
// fanOut expires `shared:<name>` itself — this row IS the item's.
|
|
563
676
|
await fanOut(record.id);
|
|
564
677
|
return ok(saved);
|
|
@@ -567,16 +680,78 @@ export function createAdminOps(deps) {
|
|
|
567
680
|
const unsupported = supportedLocale(locale);
|
|
568
681
|
if (unsupported)
|
|
569
682
|
return unsupported;
|
|
570
|
-
|
|
683
|
+
const item = sharedItem(name);
|
|
684
|
+
if (!item)
|
|
571
685
|
return err("not_found", `smoodly: no shared item named "${name}".`);
|
|
572
686
|
const record = await sharedRecord(name);
|
|
573
687
|
if (!record)
|
|
574
688
|
return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
|
|
575
|
-
|
|
689
|
+
if (status === "published") {
|
|
690
|
+
// A draft may be incomplete; what goes live may not (spec 2026-09-14 §2).
|
|
691
|
+
if (!record.locales[locale])
|
|
692
|
+
return err("not_found", `smoodly: shared item "${name}" does not exist in locale "${locale}".`);
|
|
693
|
+
const fieldErrors = validateFields(entryFieldsIn(record, locale), item.fields, elementDescs);
|
|
694
|
+
if (fieldErrors.length > 0)
|
|
695
|
+
return err("validation", "Some fields need attention.", fieldErrors);
|
|
696
|
+
}
|
|
697
|
+
const changed = await entries.setStatus(name, record.id, locale, status, by);
|
|
576
698
|
// see save: the fan-out carries the item's own tag.
|
|
577
699
|
await fanOut(record.id);
|
|
578
700
|
return ok(changed);
|
|
579
701
|
}),
|
|
702
|
+
discardDraft: (name, locale) => guard(async () => {
|
|
703
|
+
const unsupported = supportedLocale(locale);
|
|
704
|
+
if (unsupported)
|
|
705
|
+
return unsupported;
|
|
706
|
+
if (!sharedItem(name))
|
|
707
|
+
return err("not_found", `smoodly: no shared item named "${name}".`);
|
|
708
|
+
const record = await sharedRecord(name);
|
|
709
|
+
if (!record)
|
|
710
|
+
return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
|
|
711
|
+
const restored = await entries.discardDraft(name, record.id, locale, by);
|
|
712
|
+
await fanOut(record.id);
|
|
713
|
+
return ok(restored);
|
|
714
|
+
}),
|
|
715
|
+
listVersions: (name, locale) => guard(async () => {
|
|
716
|
+
const unsupported = supportedLocale(locale);
|
|
717
|
+
if (unsupported)
|
|
718
|
+
return unsupported;
|
|
719
|
+
if (!sharedItem(name))
|
|
720
|
+
return err("not_found", `smoodly: no shared item named "${name}".`);
|
|
721
|
+
const record = await sharedRecord(name);
|
|
722
|
+
if (!record)
|
|
723
|
+
return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
|
|
724
|
+
return ok((await entries.listVersions(name, record.id, locale)).map(summary));
|
|
725
|
+
}),
|
|
726
|
+
getVersion: (name, versionId) => guard(async () => {
|
|
727
|
+
if (!sharedItem(name))
|
|
728
|
+
return err("not_found", `smoodly: no shared item named "${name}".`);
|
|
729
|
+
const record = await sharedRecord(name);
|
|
730
|
+
if (!record)
|
|
731
|
+
return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
|
|
732
|
+
const version = typeof versionId === "string" ? await entries.getVersion(name, versionId) : null;
|
|
733
|
+
if (!version || version.entryId !== record.id) {
|
|
734
|
+
return err("not_found", `smoodly: no version "${String(versionId)}" of shared item "${name}".`);
|
|
735
|
+
}
|
|
736
|
+
return ok({ ...summary(version), fields: version.fields });
|
|
737
|
+
}),
|
|
738
|
+
restore: (name, locale, versionId) => guard(async () => {
|
|
739
|
+
const unsupported = supportedLocale(locale);
|
|
740
|
+
if (unsupported)
|
|
741
|
+
return unsupported;
|
|
742
|
+
if (!sharedItem(name))
|
|
743
|
+
return err("not_found", `smoodly: no shared item named "${name}".`);
|
|
744
|
+
const record = await sharedRecord(name);
|
|
745
|
+
if (!record)
|
|
746
|
+
return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
|
|
747
|
+
const version = typeof versionId === "string" ? await entries.getVersion(name, versionId) : null;
|
|
748
|
+
if (!version || version.entryId !== record.id || version.locale !== locale) {
|
|
749
|
+
return err("not_found", `smoodly: no version "${String(versionId)}" of shared item "${name}" in ${locale}.`);
|
|
750
|
+
}
|
|
751
|
+
const restored = await entries.restore(name, record.id, locale, versionId, by);
|
|
752
|
+
await fanOut(record.id);
|
|
753
|
+
return ok(restored);
|
|
754
|
+
}),
|
|
580
755
|
},
|
|
581
756
|
assets: {
|
|
582
757
|
list: (query) => guard(async () => (assets ? ok(await assets.list(query)) : noMedia())),
|
package/dist/admin/ops.d.ts
CHANGED
|
@@ -55,6 +55,8 @@ export type EntryGetResult = {
|
|
|
55
55
|
locale: string;
|
|
56
56
|
/** That locale's merged draft fields; null when the entry has no row in `locale`. */
|
|
57
57
|
fields: Record<string, unknown> | null;
|
|
58
|
+
/** locale → site URL (`href` applied server-side) for the locales the entry has a path in; empty for a collection without a path. */
|
|
59
|
+
urls: Record<string, string>;
|
|
58
60
|
/** The locales the PARENT exists in (every supported locale for a root) — the form disables the rest with a reason. */
|
|
59
61
|
parentLocales: string[];
|
|
60
62
|
};
|
|
@@ -72,6 +74,15 @@ export type SharedGetResult = {
|
|
|
72
74
|
/** Who links it: "used on N pages". */
|
|
73
75
|
usage: EntryUsage[];
|
|
74
76
|
};
|
|
77
|
+
/** A history row: the version id and when its content last changed (spec 2026-09-13 history §5). */
|
|
78
|
+
export type VersionSummary = {
|
|
79
|
+
id: string;
|
|
80
|
+
savedAt: number;
|
|
81
|
+
};
|
|
82
|
+
/** A stored entry snapshot for the disabled form. */
|
|
83
|
+
export type EntryVersionResult = VersionSummary & {
|
|
84
|
+
fields: Record<string, unknown>;
|
|
85
|
+
};
|
|
75
86
|
/** One place an asset is used: the source row, the field, a label and an admin link. */
|
|
76
87
|
export type AssetUsageItem = {
|
|
77
88
|
sourceKind: string;
|
|
@@ -91,6 +102,10 @@ export type AdminOps = {
|
|
|
91
102
|
versionId: string;
|
|
92
103
|
}>>;
|
|
93
104
|
publish(id: string, locale: string): Promise<OpResult<PageRecord>>;
|
|
105
|
+
/** Takes the locale off the site; status draft, pointers kept. Never the site root or a fixed node (spec 2026-09-13 publish flow §5). */
|
|
106
|
+
unpublish(id: string, locale: string): Promise<OpResult<PageRecord>>;
|
|
107
|
+
/** The draft back to the published version (§4); `blocked` when nothing is published in the locale. */
|
|
108
|
+
discardDraft(id: string, locale: string): Promise<OpResult<PageRecord>>;
|
|
94
109
|
delete(id: string): Promise<OpResult<null>>;
|
|
95
110
|
rename(id: string, patch: {
|
|
96
111
|
locale: string;
|
|
@@ -109,6 +124,10 @@ export type AdminOps = {
|
|
|
109
124
|
}): Promise<OpResult<PageRecord>>;
|
|
110
125
|
/** Deletes the locale's row, versions and paths. Never on fixed nodes or the site root. */
|
|
111
126
|
removeLocale(id: string, locale: string): Promise<OpResult<PageRecord>>;
|
|
127
|
+
/** The locale's versions newest first, without their trees. */
|
|
128
|
+
listVersions(id: string, locale: string): Promise<OpResult<VersionSummary[]>>;
|
|
129
|
+
/** The version's tree becomes the draft (spec 2026-09-13 history §6); `not_found` for a version of another page or locale, `blocked` on a folder. */
|
|
130
|
+
restore(id: string, locale: string, versionId: string): Promise<OpResult<PageRecord>>;
|
|
112
131
|
};
|
|
113
132
|
entries: {
|
|
114
133
|
list(collection: string): Promise<OpResult<EntryRecord[]>>;
|
|
@@ -117,6 +136,8 @@ export type AdminOps = {
|
|
|
117
136
|
create(collection: string, input: EntryCreateInput): Promise<OpResult<EntryRecord>>;
|
|
118
137
|
save(collection: string, id: string, locale: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
|
|
119
138
|
setStatus(collection: string, id: string, locale: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
|
|
139
|
+
/** The live row back to the published snapshot (§4); `blocked` when the locale has no published version. */
|
|
140
|
+
discardDraft(collection: string, id: string, locale: string): Promise<OpResult<EntryRecord>>;
|
|
120
141
|
delete(collection: string, id: string): Promise<OpResult<null>>;
|
|
121
142
|
move(collection: string, id: string, to: {
|
|
122
143
|
parentId: string | null;
|
|
@@ -128,6 +149,12 @@ export type AdminOps = {
|
|
|
128
149
|
}): Promise<OpResult<EntryRecord>>;
|
|
129
150
|
/** Deletes the locale's row, versions and paths; fans out like a save. */
|
|
130
151
|
removeLocale(collection: string, id: string, locale: string): Promise<OpResult<EntryRecord>>;
|
|
152
|
+
/** Newest first; empty for a collection without `versions`. */
|
|
153
|
+
listVersions(collection: string, id: string, locale: string): Promise<OpResult<VersionSummary[]>>;
|
|
154
|
+
/** One snapshot's fields; `not_found` unless it belongs to `id`. */
|
|
155
|
+
getVersion(collection: string, id: string, versionId: string): Promise<OpResult<EntryVersionResult>>;
|
|
156
|
+
/** The snapshot becomes the draft (§6); `not_found` for a foreign version. Fans out like a save. */
|
|
157
|
+
restore(collection: string, id: string, locale: string, versionId: string): Promise<OpResult<EntryRecord>>;
|
|
131
158
|
};
|
|
132
159
|
shared: {
|
|
133
160
|
/** Materializes every registered item in every supported locale, then lists them in registry order. */
|
|
@@ -135,6 +162,11 @@ export type AdminOps = {
|
|
|
135
162
|
get(name: string, locale: string): Promise<OpResult<SharedGetResult>>;
|
|
136
163
|
save(name: string, locale: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
|
|
137
164
|
setStatus(name: string, locale: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
|
|
165
|
+
/** The item's live row back to its published snapshot (§4); `blocked` before the first publish. */
|
|
166
|
+
discardDraft(name: string, locale: string): Promise<OpResult<EntryRecord>>;
|
|
167
|
+
listVersions(name: string, locale: string): Promise<OpResult<VersionSummary[]>>;
|
|
168
|
+
getVersion(name: string, versionId: string): Promise<OpResult<EntryVersionResult>>;
|
|
169
|
+
restore(name: string, locale: string, versionId: string): Promise<OpResult<EntryRecord>>;
|
|
138
170
|
};
|
|
139
171
|
assets: {
|
|
140
172
|
/** Ready assets, newest first, paged by cursor; `kind` and `search` filter. */
|
|
@@ -48,7 +48,6 @@ export type AdminShared = {
|
|
|
48
48
|
title: string;
|
|
49
49
|
/** The section a visual item renders through; absent = an object item. */
|
|
50
50
|
section?: string;
|
|
51
|
-
versions: boolean;
|
|
52
51
|
fields: Record<string, AdminField>;
|
|
53
52
|
/** Resolved (the config's standard set merged in) for a visual item; empty for an object item. */
|
|
54
53
|
styles: Record<string, AdminField>;
|