smoodly 0.0.4 → 0.0.6
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 +408 -34
- package/dist/admin/client-ops.js +2 -2
- package/dist/admin/editor/EditorView.d.ts +3 -2
- package/dist/admin/editor/EditorView.js +133 -80
- package/dist/admin/editor/PageSettings.d.ts +13 -0
- package/dist/admin/editor/PageSettings.js +40 -0
- package/dist/admin/fixed-nodes.d.ts +23 -0
- package/dist/admin/fixed-nodes.js +83 -0
- package/dist/admin/forms/FieldWidget.d.ts +2 -1
- package/dist/admin/forms/FieldWidget.js +16 -16
- package/dist/admin/index.d.ts +2 -1
- package/dist/admin/index.js +1 -0
- package/dist/admin/next/create-admin.js +7 -2
- package/dist/admin/next/op-handler.d.ts +1 -0
- package/dist/admin/next/op-handler.js +7 -2
- package/dist/admin/ops-impl.d.ts +4 -1
- package/dist/admin/ops-impl.js +190 -48
- package/dist/admin/ops.d.ts +39 -11
- package/dist/admin/serialize.d.ts +10 -1
- package/dist/admin/serialize.js +13 -2
- package/dist/admin/shell/AdminApp.js +36 -74
- package/dist/admin/shell/EntryForm.js +34 -10
- package/dist/admin/shell/ErrorPane.d.ts +4 -0
- package/dist/admin/shell/ErrorPane.js +9 -0
- package/dist/admin/shell/ListView.d.ts +8 -5
- package/dist/admin/shell/ListView.js +12 -11
- package/dist/admin/shell/LoginView.js +6 -2
- package/dist/admin/shell/PagesList.d.ts +6 -0
- package/dist/admin/shell/PagesList.js +148 -0
- package/dist/admin/shell/base.d.ts +1 -0
- package/dist/admin/shell/base.js +4 -0
- package/dist/admin/shell/pages-tree.d.ts +39 -0
- package/dist/admin/shell/pages-tree.js +65 -0
- package/dist/admin/ui/format.d.ts +1 -0
- package/dist/admin/ui/format.js +25 -0
- package/dist/admin/ui/primitives.d.ts +89 -0
- package/dist/admin/ui/primitives.js +115 -0
- package/dist/admin/ui/theme.d.ts +5 -0
- package/dist/admin/ui/theme.js +396 -0
- package/dist/collections.d.ts +23 -2
- package/dist/collections.js +19 -0
- package/dist/config.d.ts +11 -1
- package/dist/config.js +71 -13
- package/dist/entry-page.d.ts +16 -0
- package/dist/entry-page.js +6 -0
- package/dist/entry-store.d.ts +94 -7
- package/dist/entry-store.js +175 -26
- package/dist/index.d.ts +24 -8
- package/dist/index.js +14 -3
- package/dist/localize.d.ts +19 -0
- package/dist/localize.js +42 -0
- package/dist/next/index.d.ts +2 -2
- package/dist/next/index.js +1 -1
- package/dist/next/meta.d.ts +0 -6
- package/dist/next/meta.js +0 -11
- package/dist/next/page-renderer.d.ts +26 -18
- package/dist/next/page-renderer.js +79 -56
- package/dist/page.d.ts +10 -3
- package/dist/page.js +12 -2
- package/dist/pairing.d.ts +7 -6
- package/dist/path-index.d.ts +16 -0
- package/dist/path-index.js +33 -0
- package/dist/paths.d.ts +80 -0
- package/dist/paths.js +110 -0
- package/dist/render.d.ts +34 -5
- package/dist/render.js +9 -2
- package/dist/resolve.d.ts +8 -0
- package/dist/resolve.js +19 -7
- package/dist/revalidate.d.ts +10 -2
- package/dist/revalidate.js +13 -8
- package/dist/site.d.ts +85 -0
- package/dist/site.js +164 -0
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +2 -0
- package/dist/sql.d.ts +1 -1
- package/dist/sql.js +92 -15
- package/dist/store.d.ts +106 -30
- package/dist/store.js +242 -66
- package/dist/supabase-entry-store.d.ts +17 -3
- package/dist/supabase-entry-store.js +198 -22
- package/dist/supabase-path-index.d.ts +11 -0
- package/dist/supabase-path-index.js +52 -0
- package/dist/supabase-store.d.ts +46 -16
- package/dist/supabase-store.js +322 -110
- package/package.json +5 -4
- package/dist/admin/page-path.d.ts +0 -1
- package/dist/admin/page-path.js +0 -6
- package/dist/admin/shell/pages-list.d.ts +0 -22
- package/dist/admin/shell/pages-list.js +0 -31
package/dist/supabase-store.js
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
// The Supabase adapter for the PageStore contract, over pages +
|
|
2
|
-
// page_versions. Server-side only
|
|
3
|
-
//
|
|
4
|
-
// No FK links pages' version pointers to page_versions
|
|
5
|
-
// circular), so tree reads are two indexed lookups
|
|
6
|
-
|
|
2
|
+
// page_versions + paths. Server-side only (the service role in self-host,
|
|
3
|
+
// a write key in the cloud); writes flow through Smoodly's API by design
|
|
4
|
+
// (DESIGN.md §1). No FK links pages' version pointers to page_versions
|
|
5
|
+
// (they'd be circular), so tree reads are two indexed lookups. Path rows
|
|
6
|
+
// are rewritten through ONE RPC per write, so a collision rolls back.
|
|
7
|
+
import { assertPageI18n, assertPublishable, pageErrors, pageRefEdges, pathOptions, } from "./store.js";
|
|
8
|
+
import { SupabasePathIndex } from "./supabase-path-index.js";
|
|
9
|
+
import { assertDepth, assertSegment, pagePathRows } from "./paths.js";
|
|
7
10
|
const UNIQUE_VIOLATION = "23505";
|
|
11
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8
12
|
function toRecord(row) {
|
|
9
13
|
return {
|
|
10
14
|
id: row.id,
|
|
15
|
+
parentId: row.parent_id,
|
|
11
16
|
slug: row.slug,
|
|
17
|
+
title: row.title,
|
|
18
|
+
i18n: row.i18n,
|
|
12
19
|
template: row.template,
|
|
13
20
|
status: row.status,
|
|
21
|
+
sort: row.sort === null ? null : Number(row.sort),
|
|
22
|
+
locales: row.locales,
|
|
14
23
|
draftVersionId: row.draft_version_id,
|
|
15
24
|
publishedVersionId: row.published_version_id,
|
|
16
25
|
};
|
|
@@ -22,159 +31,362 @@ export class SupabasePageStore {
|
|
|
22
31
|
constructor(db, options = {}) {
|
|
23
32
|
this.db = db;
|
|
24
33
|
this.options = options;
|
|
34
|
+
this.paths = options.paths ?? new SupabasePathIndex(db);
|
|
35
|
+
this.opts = pathOptions(options);
|
|
36
|
+
}
|
|
37
|
+
fail(message) {
|
|
38
|
+
throw new Error(`smoodly: ${message}`);
|
|
39
|
+
}
|
|
40
|
+
// Callers always pass a PageRecord (camelCase), never a raw row.
|
|
41
|
+
isHome(page) {
|
|
42
|
+
return page.parentId === null && page.slug === this.opts.homePageSlug;
|
|
43
|
+
}
|
|
44
|
+
// ── rows ──
|
|
45
|
+
async row(id) {
|
|
46
|
+
if (!UUID_RE.test(id))
|
|
47
|
+
return null;
|
|
48
|
+
const res = await this.db.from("pages").select("*").eq("id", id).maybeSingle();
|
|
49
|
+
if (res.error)
|
|
50
|
+
this.fail(res.error.message);
|
|
51
|
+
return res.data ? toRecord(res.data) : null;
|
|
52
|
+
}
|
|
53
|
+
async must(id) {
|
|
54
|
+
const page = await this.row(id);
|
|
55
|
+
if (!page)
|
|
56
|
+
throw pageErrors.notFound(id);
|
|
57
|
+
return page;
|
|
58
|
+
}
|
|
59
|
+
/** A one-row existence check — the shape `deletePage` needs too. */
|
|
60
|
+
async hasChildren(id) {
|
|
61
|
+
const res = await this.db.from("pages").select("id").eq("parent_id", id).limit(1);
|
|
62
|
+
if (res.error)
|
|
63
|
+
this.fail(res.error.message);
|
|
64
|
+
return res.data.length > 0;
|
|
65
|
+
}
|
|
66
|
+
async siblings(parentId, except) {
|
|
67
|
+
let query = this.db.from("pages").select("*");
|
|
68
|
+
query = parentId === null ? query.is("parent_id", null) : query.eq("parent_id", parentId);
|
|
69
|
+
const res = await query
|
|
70
|
+
.order("sort", { ascending: true, nullsFirst: false })
|
|
71
|
+
.order("created_at", { ascending: true })
|
|
72
|
+
.order("id", { ascending: true });
|
|
73
|
+
if (res.error)
|
|
74
|
+
this.fail(res.error.message);
|
|
75
|
+
return res.data.map(toRecord).filter((p) => p.id !== except);
|
|
76
|
+
}
|
|
77
|
+
/** The node's ancestors (root first, node last), by walking parent_id. */
|
|
78
|
+
async chain(page) {
|
|
79
|
+
const chain = [page];
|
|
80
|
+
let current = page;
|
|
81
|
+
while (current.parentId !== null) {
|
|
82
|
+
const parent = await this.row(current.parentId);
|
|
83
|
+
if (!parent)
|
|
84
|
+
this.fail(`broken parent chain at "${page.id}".`);
|
|
85
|
+
chain.unshift(parent);
|
|
86
|
+
current = parent;
|
|
87
|
+
}
|
|
88
|
+
return chain;
|
|
89
|
+
}
|
|
90
|
+
/** The node and every descendant, breadth-first. */
|
|
91
|
+
async subtree(page) {
|
|
92
|
+
const out = [page];
|
|
93
|
+
let frontier = [page.id];
|
|
94
|
+
while (frontier.length > 0) {
|
|
95
|
+
const res = await this.db.from("pages").select("*").in("parent_id", frontier);
|
|
96
|
+
if (res.error)
|
|
97
|
+
this.fail(res.error.message);
|
|
98
|
+
const rows = res.data.map(toRecord);
|
|
99
|
+
out.push(...rows);
|
|
100
|
+
frontier = rows.map((r) => r.id);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
/** Rewrite path rows for a subtree: ancestors are loaded once so the
|
|
105
|
+
* chain lookup is a map, then ONE RPC replaces every target's rows. */
|
|
106
|
+
async writePaths(top) {
|
|
107
|
+
const ancestors = await this.chain(top);
|
|
108
|
+
const nodes = await this.subtree(top);
|
|
109
|
+
const byId = new Map([...ancestors, ...nodes].map((n) => [n.id, n]));
|
|
110
|
+
await this.paths.rewrite(nodes.map((n) => ({ kind: "page", id: n.id })), pagePathRows(nodes, (id) => byId.get(id), this.opts));
|
|
111
|
+
}
|
|
112
|
+
/** `touch` defaults to true (the normal edit path bumps updated_at).
|
|
113
|
+
* A rollback restoring prior column values passes `{ touch: false }`,
|
|
114
|
+
* which omits updated_at entirely — undoing a write is not an edit
|
|
115
|
+
* (same pattern as SupabaseEntryStore's `patch`). */
|
|
116
|
+
async update(id, patch, options = {}) {
|
|
117
|
+
const payload = (options.touch ?? true) ? { ...patch, updated_at: new Date().toISOString() } : { ...patch };
|
|
118
|
+
const res = await this.db
|
|
119
|
+
.from("pages")
|
|
120
|
+
.update(payload)
|
|
121
|
+
.eq("id", id)
|
|
122
|
+
.select("*")
|
|
123
|
+
.single();
|
|
124
|
+
if (res.error) {
|
|
125
|
+
if (res.error.code === UNIQUE_VIOLATION)
|
|
126
|
+
throw pageErrors.slugTaken(String(patch.slug ?? ""));
|
|
127
|
+
this.fail(res.error.message);
|
|
128
|
+
}
|
|
129
|
+
return toRecord(res.data);
|
|
130
|
+
}
|
|
131
|
+
async insertVersion(pageId, tree) {
|
|
132
|
+
const res = await this.db.from("page_versions").insert({ page_id: pageId, tree }).select("*").single();
|
|
133
|
+
if (res.error)
|
|
134
|
+
this.fail(res.error.message);
|
|
135
|
+
return res.data;
|
|
136
|
+
}
|
|
137
|
+
async versionTree(versionId) {
|
|
138
|
+
if (!versionId)
|
|
139
|
+
return null;
|
|
140
|
+
const res = await this.db.from("page_versions").select("tree").eq("id", versionId).maybeSingle();
|
|
141
|
+
if (res.error)
|
|
142
|
+
this.fail(res.error.message);
|
|
143
|
+
return res.data?.tree ?? null;
|
|
25
144
|
}
|
|
26
145
|
async writeRefs(pageId, edges) {
|
|
27
|
-
const deleted = await this.db
|
|
28
|
-
.from("refs")
|
|
29
|
-
.delete()
|
|
30
|
-
.eq("source_kind", "page")
|
|
31
|
-
.eq("source_id", pageId);
|
|
146
|
+
const deleted = await this.db.from("refs").delete().eq("source_kind", "page").eq("source_id", pageId);
|
|
32
147
|
if (deleted.error)
|
|
33
|
-
|
|
34
|
-
// dedupe: the refs PK is (source_kind, source_id, source_field, target_id)
|
|
148
|
+
this.fail(deleted.error.message);
|
|
35
149
|
const rows = new Map();
|
|
36
150
|
for (const edge of edges) {
|
|
37
151
|
rows.set(`${edge.field} ${edge.targetId}`, {
|
|
38
|
-
source_kind: "page",
|
|
39
|
-
|
|
40
|
-
source_field: edge.field,
|
|
41
|
-
target_collection: edge.targetCollection,
|
|
42
|
-
target_id: edge.targetId,
|
|
152
|
+
source_kind: "page", source_id: pageId, source_field: edge.field,
|
|
153
|
+
target_collection: edge.targetCollection, target_id: edge.targetId,
|
|
43
154
|
});
|
|
44
155
|
}
|
|
45
156
|
if (rows.size > 0) {
|
|
46
157
|
const inserted = await this.db.from("refs").insert([...rows.values()]);
|
|
47
158
|
if (inserted.error)
|
|
48
|
-
|
|
159
|
+
this.fail(inserted.error.message);
|
|
49
160
|
}
|
|
50
161
|
}
|
|
51
|
-
async
|
|
52
|
-
|
|
162
|
+
async reorder(ids) {
|
|
163
|
+
if (ids.length === 0)
|
|
164
|
+
return;
|
|
165
|
+
const res = await this.db.rpc("smoodly_reorder_pages", { p_ids: ids });
|
|
53
166
|
if (res.error)
|
|
54
|
-
|
|
55
|
-
return res.data ?? null;
|
|
167
|
+
this.fail(res.error.message);
|
|
56
168
|
}
|
|
57
|
-
async
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
169
|
+
async hardDelete(id) {
|
|
170
|
+
for (const [table, col] of [["refs", "source_id"], ["page_versions", "page_id"], ["pages", "id"]]) {
|
|
171
|
+
const res = table === "refs"
|
|
172
|
+
? await this.db.from(table).delete().eq("source_kind", "page").eq(col, id)
|
|
173
|
+
: await this.db.from(table).delete().eq(col, id);
|
|
174
|
+
if (res.error)
|
|
175
|
+
this.fail(res.error.message);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// ── contract ──
|
|
179
|
+
async createPage(input) {
|
|
180
|
+
assertSegment(input.slug, "page slug");
|
|
181
|
+
assertPageI18n(input.i18n);
|
|
182
|
+
const parentId = input.parentId ?? null;
|
|
183
|
+
if (parentId !== null) {
|
|
184
|
+
const parent = await this.must(parentId);
|
|
185
|
+
if (this.isHome(parent))
|
|
186
|
+
throw pageErrors.homeChildren();
|
|
187
|
+
assertDepth((await this.chain(parent)).length + 1, this.opts.depth, "the page");
|
|
188
|
+
}
|
|
189
|
+
const siblings = await this.siblings(parentId);
|
|
190
|
+
const inserted = await this.db
|
|
191
|
+
.from("pages")
|
|
192
|
+
.insert({
|
|
193
|
+
parent_id: parentId,
|
|
194
|
+
slug: input.slug,
|
|
195
|
+
title: input.title ?? input.slug,
|
|
196
|
+
i18n: input.i18n ?? null,
|
|
197
|
+
template: input.template,
|
|
198
|
+
sort: siblings.length + 1,
|
|
199
|
+
locales: input.locales ?? null,
|
|
200
|
+
})
|
|
61
201
|
.select("*")
|
|
62
202
|
.single();
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
203
|
+
if (inserted.error) {
|
|
204
|
+
if (inserted.error.code === UNIQUE_VIOLATION)
|
|
205
|
+
throw pageErrors.slugTaken(input.slug);
|
|
206
|
+
this.fail(inserted.error.message);
|
|
207
|
+
}
|
|
208
|
+
let page = toRecord(inserted.data);
|
|
209
|
+
try {
|
|
210
|
+
if (input.template !== null) {
|
|
211
|
+
const version = await this.insertVersion(page.id, { zones: {} });
|
|
212
|
+
page = await this.update(page.id, { draft_version_id: version.id });
|
|
213
|
+
}
|
|
214
|
+
await this.writePaths(page);
|
|
215
|
+
}
|
|
216
|
+
catch (e) {
|
|
217
|
+
await this.hardDelete(page.id);
|
|
218
|
+
throw e;
|
|
219
|
+
}
|
|
220
|
+
return page;
|
|
221
|
+
}
|
|
222
|
+
getPage(id) {
|
|
223
|
+
return this.row(id);
|
|
66
224
|
}
|
|
67
|
-
async
|
|
225
|
+
async listPages() {
|
|
68
226
|
const res = await this.db
|
|
69
227
|
.from("pages")
|
|
70
|
-
.update({ ...patch, updated_at: new Date().toISOString() })
|
|
71
|
-
.eq("id", id)
|
|
72
228
|
.select("*")
|
|
73
|
-
.
|
|
229
|
+
.order("sort", { ascending: true, nullsFirst: false })
|
|
230
|
+
.order("created_at", { ascending: true })
|
|
231
|
+
.order("id", { ascending: true });
|
|
74
232
|
if (res.error)
|
|
75
|
-
|
|
76
|
-
return res.data;
|
|
233
|
+
this.fail(res.error.message);
|
|
234
|
+
return res.data.map(toRecord);
|
|
77
235
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
236
|
+
resolvePath(locale, path) {
|
|
237
|
+
return this.paths.resolve(locale, path);
|
|
238
|
+
}
|
|
239
|
+
pathsOf(id) {
|
|
240
|
+
return UUID_RE.test(id) ? this.paths.pathsOf({ kind: "page", id }) : Promise.resolve({});
|
|
241
|
+
}
|
|
242
|
+
async rename(id, patch) {
|
|
243
|
+
const page = await this.must(id);
|
|
244
|
+
if (patch.slug !== undefined)
|
|
245
|
+
assertSegment(patch.slug, `page slug (${patch.locale})`);
|
|
246
|
+
// A rename that turns this node INTO the home node (root, default-locale
|
|
247
|
+
// slug === homePageSlug) is subject to the same invariant as createPage:
|
|
248
|
+
// the home page cannot have child pages.
|
|
249
|
+
if (patch.locale === this.opts.locales.default &&
|
|
250
|
+
patch.slug !== undefined &&
|
|
251
|
+
page.parentId === null &&
|
|
252
|
+
patch.slug === this.opts.homePageSlug &&
|
|
253
|
+
(await this.hasChildren(id))) {
|
|
254
|
+
throw pageErrors.homeChildren();
|
|
84
255
|
}
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
256
|
+
const columns = {};
|
|
257
|
+
if (patch.locale === this.opts.locales.default) {
|
|
258
|
+
if (patch.slug !== undefined)
|
|
259
|
+
columns.slug = patch.slug;
|
|
260
|
+
if (patch.title !== undefined)
|
|
261
|
+
columns.title = patch.title;
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
const i18n = { ...(page.i18n ?? {}) };
|
|
265
|
+
i18n[patch.locale] = {
|
|
266
|
+
...(i18n[patch.locale] ?? {}),
|
|
267
|
+
...(patch.slug !== undefined ? { slug: patch.slug } : {}),
|
|
268
|
+
...(patch.title !== undefined ? { title: patch.title } : {}),
|
|
269
|
+
};
|
|
270
|
+
columns.i18n = i18n;
|
|
271
|
+
}
|
|
272
|
+
const renamed = await this.update(id, columns);
|
|
273
|
+
try {
|
|
274
|
+
await this.writePaths(renamed);
|
|
275
|
+
}
|
|
276
|
+
catch (e) {
|
|
277
|
+
await this.update(id, { slug: page.slug, title: page.title, i18n: page.i18n }, { touch: false });
|
|
278
|
+
throw e;
|
|
279
|
+
}
|
|
280
|
+
return renamed;
|
|
88
281
|
}
|
|
89
|
-
async
|
|
90
|
-
const
|
|
91
|
-
|
|
282
|
+
async move(id, to) {
|
|
283
|
+
const page = await this.must(id);
|
|
284
|
+
const subtree = await this.subtree(page);
|
|
285
|
+
if (to.parentId !== null) {
|
|
286
|
+
if (to.parentId === id || subtree.some((n) => n.id === to.parentId))
|
|
287
|
+
throw pageErrors.cycle();
|
|
288
|
+
const parent = await this.must(to.parentId);
|
|
289
|
+
if (this.isHome(parent))
|
|
290
|
+
throw pageErrors.homeChildren();
|
|
291
|
+
const byId = new Map(subtree.map((n) => [n.id, n]));
|
|
292
|
+
// The subtree's own height: walk each node up to the moved node
|
|
293
|
+
// itself (not to the tree root — its parentId points OUTSIDE this
|
|
294
|
+
// subtree map, at its current, pre-move parent, so chainOf would
|
|
295
|
+
// throw "broken parent chain" there instead of stopping).
|
|
296
|
+
const heightOf = (n) => (n.id === page.id ? 1 : 1 + heightOf(byId.get(n.parentId)));
|
|
297
|
+
const height = Math.max(...subtree.map(heightOf));
|
|
298
|
+
assertDepth((await this.chain(parent)).length + height, this.opts.depth, "the page");
|
|
299
|
+
}
|
|
300
|
+
else if (page.slug === this.opts.homePageSlug && (await this.hasChildren(id))) {
|
|
301
|
+
// Moving a home-slugged node TO the root is the mirror case of the
|
|
302
|
+
// rename guard above: it would make it the home node while it still
|
|
303
|
+
// has children.
|
|
304
|
+
throw pageErrors.homeChildren();
|
|
305
|
+
}
|
|
306
|
+
const siblings = await this.siblings(to.parentId, id);
|
|
307
|
+
if (to.parentId !== page.parentId && siblings.some((s) => s.slug === page.slug))
|
|
308
|
+
throw pageErrors.slugTaken(page.slug);
|
|
309
|
+
// The destination siblings' sorts as they stood BEFORE the reorder.
|
|
310
|
+
// A rollback restores these exact values rather than re-running the
|
|
311
|
+
// reorder RPC, which renumbers 1..N and would silently close gaps
|
|
312
|
+
// left by earlier deletes and moves.
|
|
313
|
+
const previousSorts = siblings.map((s) => ({ id: s.id, sort: s.sort }));
|
|
314
|
+
const moved = await this.update(id, { parent_id: to.parentId });
|
|
315
|
+
const index = Math.min(Math.max(to.index ?? siblings.length, 0), siblings.length);
|
|
316
|
+
const ordered = siblings.map((s) => s.id);
|
|
317
|
+
ordered.splice(index, 0, id);
|
|
318
|
+
await this.reorder(ordered);
|
|
319
|
+
try {
|
|
320
|
+
await this.writePaths(moved);
|
|
321
|
+
}
|
|
322
|
+
catch (e) {
|
|
323
|
+
await this.update(id, { parent_id: page.parentId, sort: page.sort }, { touch: false });
|
|
324
|
+
for (const s of previousSorts)
|
|
325
|
+
await this.update(s.id, { sort: s.sort }, { touch: false });
|
|
326
|
+
throw e;
|
|
327
|
+
}
|
|
328
|
+
return { ...moved, sort: index + 1 };
|
|
92
329
|
}
|
|
93
|
-
async
|
|
94
|
-
const
|
|
95
|
-
if (
|
|
96
|
-
throw
|
|
97
|
-
|
|
330
|
+
async setTemplate(id, template) {
|
|
331
|
+
const page = await this.must(id);
|
|
332
|
+
if (page.template !== null)
|
|
333
|
+
throw pageErrors.hasTemplate(id);
|
|
334
|
+
const version = await this.insertVersion(id, { zones: {} });
|
|
335
|
+
return this.update(id, { template, draft_version_id: version.id });
|
|
98
336
|
}
|
|
99
|
-
async saveDraft(
|
|
100
|
-
const page = await this.
|
|
101
|
-
if (
|
|
102
|
-
throw
|
|
103
|
-
const version = await this.insertVersion(
|
|
104
|
-
await this.
|
|
337
|
+
async saveDraft(id, tree) {
|
|
338
|
+
const page = await this.must(id);
|
|
339
|
+
if (page.template === null)
|
|
340
|
+
throw pageErrors.folder(id);
|
|
341
|
+
const version = await this.insertVersion(id, tree);
|
|
342
|
+
await this.update(id, { draft_version_id: version.id });
|
|
105
343
|
if (this.options.sections) {
|
|
106
|
-
const published = await this.versionTree(page.
|
|
107
|
-
await this.writeRefs(
|
|
344
|
+
const published = await this.versionTree(page.publishedVersionId);
|
|
345
|
+
await this.writeRefs(id, pageRefEdges(tree, published, this.options.sections));
|
|
108
346
|
}
|
|
109
347
|
return toVersion(version);
|
|
110
348
|
}
|
|
111
|
-
async publish(
|
|
112
|
-
const page = await this.
|
|
113
|
-
if (!page)
|
|
114
|
-
throw
|
|
349
|
+
async publish(id) {
|
|
350
|
+
const page = await this.must(id);
|
|
351
|
+
if (page.template === null || !page.draftVersionId)
|
|
352
|
+
throw pageErrors.folder(id);
|
|
115
353
|
const { sections, entries } = this.options;
|
|
116
|
-
const draft = (await this.versionTree(page.
|
|
354
|
+
const draft = (await this.versionTree(page.draftVersionId)) ?? { zones: {} };
|
|
117
355
|
if (sections && entries)
|
|
118
|
-
await assertPublishable(slug, draft, sections, entries);
|
|
119
|
-
const record =
|
|
120
|
-
published_version_id: page.draft_version_id,
|
|
121
|
-
status: "published",
|
|
122
|
-
}));
|
|
123
|
-
// draft IS published now — one tree
|
|
356
|
+
await assertPublishable(page.slug, draft, sections, entries);
|
|
357
|
+
const record = await this.update(id, { published_version_id: page.draftVersionId, status: "published" });
|
|
124
358
|
if (sections)
|
|
125
|
-
await this.writeRefs(
|
|
359
|
+
await this.writeRefs(id, pageRefEdges(draft, null, sections));
|
|
126
360
|
return record;
|
|
127
361
|
}
|
|
128
|
-
async
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const res = await this.db.from("page_versions").select("tree").eq("id", versionId).maybeSingle();
|
|
132
|
-
if (res.error)
|
|
133
|
-
throw new Error(`smoodly: ${res.error.message}`);
|
|
134
|
-
return res.data?.tree ?? null;
|
|
135
|
-
}
|
|
136
|
-
async getDraftTree(slug) {
|
|
137
|
-
const page = await this.pageRow(slug);
|
|
138
|
-
return page ? this.versionTree(page.draft_version_id) : null;
|
|
362
|
+
async getDraftTree(id) {
|
|
363
|
+
const page = await this.row(id);
|
|
364
|
+
return page ? this.versionTree(page.draftVersionId) : null;
|
|
139
365
|
}
|
|
140
|
-
async getPublishedTree(
|
|
141
|
-
const page = await this.
|
|
142
|
-
return page ? this.versionTree(page.
|
|
366
|
+
async getPublishedTree(id) {
|
|
367
|
+
const page = await this.row(id);
|
|
368
|
+
return page ? this.versionTree(page.publishedVersionId) : null;
|
|
143
369
|
}
|
|
144
|
-
async listVersions(
|
|
145
|
-
|
|
146
|
-
if (!page)
|
|
370
|
+
async listVersions(id) {
|
|
371
|
+
if (!UUID_RE.test(id))
|
|
147
372
|
return [];
|
|
148
373
|
const res = await this.db
|
|
149
374
|
.from("page_versions")
|
|
150
375
|
.select("*")
|
|
151
|
-
.eq("page_id",
|
|
376
|
+
.eq("page_id", id)
|
|
152
377
|
.order("created_at", { ascending: false })
|
|
153
378
|
.order("id", { ascending: false });
|
|
154
379
|
if (res.error)
|
|
155
|
-
|
|
380
|
+
this.fail(res.error.message);
|
|
156
381
|
return res.data.map(toVersion);
|
|
157
382
|
}
|
|
158
|
-
async
|
|
159
|
-
const
|
|
160
|
-
if (res.error)
|
|
161
|
-
throw new Error(`smoodly: ${res.error.message}`);
|
|
162
|
-
return res.data.map(toRecord);
|
|
163
|
-
}
|
|
164
|
-
async deletePage(slug) {
|
|
165
|
-
const page = await this.pageRow(slug);
|
|
383
|
+
async deletePage(id) {
|
|
384
|
+
const page = await this.row(id);
|
|
166
385
|
if (!page)
|
|
167
386
|
return;
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
]) {
|
|
173
|
-
const res = table === "refs"
|
|
174
|
-
? await this.db.from(table).delete().eq("source_kind", "page").eq(col, val)
|
|
175
|
-
: await this.db.from(table).delete().eq(col, val);
|
|
176
|
-
if (res.error)
|
|
177
|
-
throw new Error(`smoodly: ${res.error.message}`);
|
|
178
|
-
}
|
|
387
|
+
if (await this.hasChildren(id))
|
|
388
|
+
throw pageErrors.hasChildren(id);
|
|
389
|
+
await this.paths.remove([{ kind: "page", id }]);
|
|
390
|
+
await this.hardDelete(id);
|
|
179
391
|
}
|
|
180
392
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "smoodly",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"description": "Open-source, code-first CMS for Next.js on Supabase: define a component once, it becomes a typed, editable building block.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
"test:supabase": "bash scripts/test-supabase.sh"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"
|
|
49
|
-
"
|
|
48
|
+
"next": ">=16",
|
|
49
|
+
"react": "^18 || ^19"
|
|
50
50
|
},
|
|
51
51
|
"peerDependenciesMeta": {
|
|
52
52
|
"next": {
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"vitest": "^3"
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
|
-
"@supabase/supabase-js": "^2.113.0"
|
|
67
|
+
"@supabase/supabase-js": "^2.113.0",
|
|
68
|
+
"lucide-react": "^1.41.0"
|
|
68
69
|
}
|
|
69
70
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare function pagePath(slug: string, homePageSlug: string): string;
|
package/dist/admin/page-path.js
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
// The one place the admin turns a page slug into the site URL that serves
|
|
2
|
-
// it. The root's slug is config (`homePageSlug`), not a convention the
|
|
3
|
-
// package guesses at; a registration-declared path is the logged follow-up.
|
|
4
|
-
export function pagePath(slug, homePageSlug) {
|
|
5
|
-
return slug === homePageSlug ? "/" : `/${slug}`;
|
|
6
|
-
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import type { PageRecord } from "../../store.ts";
|
|
2
|
-
import type { AdminPageTemplate } from "../serialize.ts";
|
|
3
|
-
/** Registrations editors may create pages against. A fixed-slug
|
|
4
|
-
* registration is a singleton owned by one route file, so it is never
|
|
5
|
-
* offered — and with exactly one open template left, the New Page flow
|
|
6
|
-
* needs no template prompt at all. */
|
|
7
|
-
export declare function openTemplates(templates: AdminPageTemplate[]): AdminPageTemplate[];
|
|
8
|
-
export type PagesRow = {
|
|
9
|
-
kind: "record";
|
|
10
|
-
record: PageRecord;
|
|
11
|
-
templateTitle: string;
|
|
12
|
-
fixed: boolean;
|
|
13
|
-
} | {
|
|
14
|
-
kind: "missing";
|
|
15
|
-
slug: string;
|
|
16
|
-
template: string;
|
|
17
|
-
templateTitle: string;
|
|
18
|
-
};
|
|
19
|
-
/** Fixed registrations always get a row, even before their record
|
|
20
|
-
* exists: otherwise a fresh install's "/" is a 404 the admin offers no
|
|
21
|
-
* way to fix, since New Page filters singletons out. */
|
|
22
|
-
export declare function pagesRows(records: PageRecord[], templates: AdminPageTemplate[]): PagesRow[];
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
/** Registrations editors may create pages against. A fixed-slug
|
|
2
|
-
* registration is a singleton owned by one route file, so it is never
|
|
3
|
-
* offered — and with exactly one open template left, the New Page flow
|
|
4
|
-
* needs no template prompt at all. */
|
|
5
|
-
export function openTemplates(templates) {
|
|
6
|
-
return templates.filter((t) => t.slug === undefined);
|
|
7
|
-
}
|
|
8
|
-
/** Fixed registrations always get a row, even before their record
|
|
9
|
-
* exists: otherwise a fresh install's "/" is a 404 the admin offers no
|
|
10
|
-
* way to fix, since New Page filters singletons out. */
|
|
11
|
-
export function pagesRows(records, templates) {
|
|
12
|
-
const titleOf = (name) => templates.find((t) => t.name === name)?.title ?? name;
|
|
13
|
-
const bySlug = new Map(records.map((r) => [r.slug, r]));
|
|
14
|
-
const fixedSlugs = new Set();
|
|
15
|
-
const rows = [];
|
|
16
|
-
for (const t of templates) {
|
|
17
|
-
if (t.slug === undefined)
|
|
18
|
-
continue;
|
|
19
|
-
fixedSlugs.add(t.slug);
|
|
20
|
-
const record = bySlug.get(t.slug);
|
|
21
|
-
rows.push(record
|
|
22
|
-
? { kind: "record", record, templateTitle: titleOf(record.template), fixed: true }
|
|
23
|
-
: { kind: "missing", slug: t.slug, template: t.name, templateTitle: t.title });
|
|
24
|
-
}
|
|
25
|
-
for (const record of records) {
|
|
26
|
-
if (fixedSlugs.has(record.slug))
|
|
27
|
-
continue;
|
|
28
|
-
rows.push({ kind: "record", record, templateTitle: titleOf(record.template), fixed: false });
|
|
29
|
-
}
|
|
30
|
-
return rows;
|
|
31
|
-
}
|