smoodly 0.0.6 → 0.0.7
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 +231 -73
- package/dist/admin/client-ops.js +2 -2
- package/dist/admin/editor/EditorView.js +167 -24
- package/dist/admin/editor/PageSettings.d.ts +8 -4
- package/dist/admin/editor/PageSettings.js +25 -25
- package/dist/admin/fixed-nodes.d.ts +16 -10
- package/dist/admin/fixed-nodes.js +55 -41
- package/dist/admin/ops-impl.js +134 -35
- package/dist/admin/ops.d.ts +51 -11
- package/dist/admin/shell/AdminApp.js +3 -32
- package/dist/admin/shell/EntriesList.d.ts +7 -0
- package/dist/admin/shell/EntriesList.js +95 -0
- package/dist/admin/shell/EntryForm.js +167 -49
- package/dist/admin/shell/PagesList.js +86 -26
- package/dist/admin/shell/entries-list.d.ts +21 -0
- package/dist/admin/shell/entries-list.js +36 -0
- package/dist/admin/shell/pages-tree.d.ts +30 -12
- package/dist/admin/shell/pages-tree.js +48 -14
- package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
- package/dist/admin/ui/LocaleSwitcher.js +15 -0
- package/dist/collections.d.ts +6 -0
- package/dist/collections.js +16 -0
- package/dist/config.js +23 -3
- package/dist/entry-store.d.ts +132 -54
- package/dist/entry-store.js +294 -86
- package/dist/index.d.ts +6 -6
- package/dist/index.js +4 -4
- package/dist/localize.d.ts +0 -11
- package/dist/localize.js +11 -29
- package/dist/paths.d.ts +46 -27
- package/dist/paths.js +62 -29
- package/dist/revalidate.js +2 -1
- package/dist/site.d.ts +6 -2
- package/dist/site.js +65 -39
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +13 -5
- package/dist/sql.js +96 -53
- package/dist/store.d.ts +66 -32
- package/dist/store.js +205 -90
- package/dist/supabase-entry-store.d.ts +29 -8
- package/dist/supabase-entry-store.js +341 -177
- package/dist/supabase-store.d.ts +21 -9
- package/dist/supabase-store.js +202 -111
- package/package.json +1 -1
package/dist/admin/ops-impl.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { entryFieldsIn } from "../entry-store.js";
|
|
1
2
|
import { affectedTargets, entryTag, pageTag } from "../revalidate.js";
|
|
2
3
|
import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
|
|
3
4
|
import { validateFields } from "./validate.js";
|
|
@@ -30,6 +31,18 @@ function isPageTree(tree) {
|
|
|
30
31
|
/** Store throws are stringly-typed; classify the known guards, default to internal. */
|
|
31
32
|
const classify = (e) => {
|
|
32
33
|
const message = e instanceof Error ? e.message : String(e);
|
|
34
|
+
if (message.includes("already exists in locale"))
|
|
35
|
+
return err("conflict", message);
|
|
36
|
+
if (message.includes("not a supported locale"))
|
|
37
|
+
return err("validation", message);
|
|
38
|
+
if (message.includes("add it there first") ||
|
|
39
|
+
message.includes("remove it from") ||
|
|
40
|
+
message.includes("at least one locale") ||
|
|
41
|
+
message.includes("cannot move under it")) {
|
|
42
|
+
return err("blocked", message);
|
|
43
|
+
}
|
|
44
|
+
if (message.includes("does not exist in locale"))
|
|
45
|
+
return err("not_found", message);
|
|
33
46
|
if (message.includes("already exists"))
|
|
34
47
|
return err("conflict", message);
|
|
35
48
|
if (message.includes("is referenced by"))
|
|
@@ -100,15 +113,22 @@ export function createAdminOps(deps) {
|
|
|
100
113
|
}
|
|
101
114
|
return out;
|
|
102
115
|
};
|
|
103
|
-
const
|
|
116
|
+
const def = config.locales.default;
|
|
117
|
+
/** The name messages use for a node: its default-locale slug, else any locale's, else the id. */
|
|
118
|
+
const nameOf = (r) => r.locales[def]?.slug ?? Object.values(r.locales)[0]?.slug ?? r.id;
|
|
119
|
+
const owned = (record, verb) => err("blocked", `smoodly: "${nameOf(record)}" is owned by code — ${verb}.`);
|
|
104
120
|
/** The record serving "/" — guarded whether or not a registration claims
|
|
105
|
-
* its slug: the config's homePageSlug is what routes it.
|
|
106
|
-
|
|
121
|
+
* its slug: the config's homePageSlug is what routes it. Judged by the
|
|
122
|
+
* DEFAULT locale's slug (paths.ts isHomeNode, the same rule). */
|
|
123
|
+
const isHomeNode = (r) => r.parentId === null && r.locales[def]?.slug === config.homePageSlug;
|
|
124
|
+
const supportedLocale = (locale) => typeof locale === "string" && config.locales.supported.includes(locale)
|
|
125
|
+
? null
|
|
126
|
+
: err("validation", `smoodly: "${String(locale)}" is not a supported locale.`);
|
|
107
127
|
const openTemplate = (name) => config.registry.pages?.find((p) => p.schema.kind === "page" && p.schema.name === name);
|
|
108
128
|
const mountGuard = (parent) => {
|
|
109
129
|
const fixed = fixedNodeOf(parent, config);
|
|
110
130
|
return fixed?.collection
|
|
111
|
-
? err("blocked", `smoodly: pages cannot be created under "${parent
|
|
131
|
+
? err("blocked", `smoodly: pages cannot be created under "${nameOf(parent)}" — the "${fixed.collection}" collection owns that URL space.`)
|
|
112
132
|
: null;
|
|
113
133
|
};
|
|
114
134
|
const guard = async (work) => {
|
|
@@ -122,16 +142,27 @@ export function createAdminOps(deps) {
|
|
|
122
142
|
return {
|
|
123
143
|
pages: {
|
|
124
144
|
list: () => guard(async () => ok(await ensureFixedNodes(pages, config))),
|
|
125
|
-
get: (id) => guard(async () => {
|
|
145
|
+
get: (id, locale) => guard(async () => {
|
|
146
|
+
const unsupported = supportedLocale(locale);
|
|
147
|
+
if (unsupported)
|
|
148
|
+
return unsupported;
|
|
126
149
|
const record = await pages.getPage(id);
|
|
127
150
|
if (!record)
|
|
128
151
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
129
|
-
const draft = record.template === null ? null : await pages.getDraftTree(id);
|
|
152
|
+
const draft = record.template === null || !record.locales[locale] ? null : await pages.getDraftTree(id, locale);
|
|
130
153
|
const paths = await pages.pathsOf(id);
|
|
131
|
-
const urls = Object.fromEntries(Object.entries(paths).map(([
|
|
132
|
-
|
|
154
|
+
const urls = Object.fromEntries(Object.entries(paths).map(([l, path]) => [l, href(path, l)]));
|
|
155
|
+
// A locale can only be added where the parent already has it; a
|
|
156
|
+
// root has no parent, so every supported locale is open.
|
|
157
|
+
const parentLocales = record.parentId === null
|
|
158
|
+
? config.locales.supported
|
|
159
|
+
: Object.keys((await pages.getPage(record.parentId))?.locales ?? {});
|
|
160
|
+
return ok({ record, locale, draft, paths, urls, parentLocales });
|
|
133
161
|
}),
|
|
134
162
|
create: (input) => guard(async () => {
|
|
163
|
+
const unsupported = supportedLocale(input.locale);
|
|
164
|
+
if (unsupported)
|
|
165
|
+
return unsupported;
|
|
135
166
|
if (input.template !== null) {
|
|
136
167
|
const registration = openTemplate(input.template);
|
|
137
168
|
if (!registration)
|
|
@@ -153,26 +184,35 @@ export function createAdminOps(deps) {
|
|
|
153
184
|
await ensureFixedNodes(pages, config);
|
|
154
185
|
}
|
|
155
186
|
return ok(await pages.createPage({
|
|
156
|
-
slug: input.slug, template: input.template, parentId: input.parentId,
|
|
187
|
+
locale: input.locale, slug: input.slug, template: input.template, parentId: input.parentId,
|
|
157
188
|
...(input.title !== undefined ? { title: input.title } : {}),
|
|
158
189
|
}));
|
|
159
190
|
}),
|
|
160
|
-
saveDraft: (id, tree) => guard(async () => {
|
|
191
|
+
saveDraft: (id, locale, tree) => guard(async () => {
|
|
192
|
+
const unsupported = supportedLocale(locale);
|
|
193
|
+
if (unsupported)
|
|
194
|
+
return unsupported;
|
|
161
195
|
if (!isPageTree(tree))
|
|
162
196
|
return err("validation", "smoodly: malformed page tree.");
|
|
163
197
|
if (!(await pages.getPage(id)))
|
|
164
198
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
165
|
-
return ok({ versionId: (await pages.saveDraft(id, tree)).id });
|
|
199
|
+
return ok({ versionId: (await pages.saveDraft(id, locale, tree)).id });
|
|
166
200
|
}),
|
|
167
|
-
publish: (id) => guard(async () => {
|
|
201
|
+
publish: (id, locale) => guard(async () => {
|
|
202
|
+
const unsupported = supportedLocale(locale);
|
|
203
|
+
if (unsupported)
|
|
204
|
+
return unsupported;
|
|
168
205
|
const record = await pages.getPage(id);
|
|
169
206
|
if (!record)
|
|
170
207
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
171
|
-
const
|
|
208
|
+
const row = record.locales[locale];
|
|
209
|
+
if (!row)
|
|
210
|
+
return err("not_found", `smoodly: page "${id}" does not exist in locale "${locale}".`);
|
|
211
|
+
const draft = record.template === null ? null : await pages.getDraftTree(id, locale);
|
|
172
212
|
if (!draft) {
|
|
173
213
|
return record.template === null
|
|
174
|
-
? err("blocked", `smoodly: "${record
|
|
175
|
-
: err("blocked", `smoodly: "${
|
|
214
|
+
? err("blocked", `smoodly: "${nameOf(record)}" is a folder — give it a template first.`)
|
|
215
|
+
: err("blocked", `smoodly: "${row.slug}" has no draft to publish.`);
|
|
176
216
|
}
|
|
177
217
|
const fieldErrors = [];
|
|
178
218
|
for (const nodes of Object.values(draft.zones)) {
|
|
@@ -186,9 +226,9 @@ export function createAdminOps(deps) {
|
|
|
186
226
|
}
|
|
187
227
|
}
|
|
188
228
|
if (fieldErrors.length > 0) {
|
|
189
|
-
return err("validation", `smoodly: cannot publish "${
|
|
229
|
+
return err("validation", `smoodly: cannot publish "${row.slug}" — fix the listed fields first.`, fieldErrors);
|
|
190
230
|
}
|
|
191
|
-
const published = await pages.publish(id);
|
|
231
|
+
const published = await pages.publish(id, locale);
|
|
192
232
|
// The parent's cached unit lists its published children.
|
|
193
233
|
expirePages([published.id, published.parentId]);
|
|
194
234
|
return ok(published);
|
|
@@ -198,10 +238,10 @@ export function createAdminOps(deps) {
|
|
|
198
238
|
if (!record)
|
|
199
239
|
return ok(null); // nothing to delete is not a failure
|
|
200
240
|
if (fixedNodeOf(record, config)) {
|
|
201
|
-
return err("blocked", `smoodly: "${record
|
|
241
|
+
return err("blocked", `smoodly: "${nameOf(record)}" is owned by code — remove the registration to remove it.`);
|
|
202
242
|
}
|
|
203
243
|
if (isHomeNode(record))
|
|
204
|
-
return err("blocked", `smoodly: "${record
|
|
244
|
+
return err("blocked", `smoodly: "${nameOf(record)}" is the site root — it cannot be deleted.`);
|
|
205
245
|
await pages.deletePage(id);
|
|
206
246
|
// The cached unit is keyed by (locale, path) but tagged by the id
|
|
207
247
|
// found at the first miss; the parent's unit lists its children.
|
|
@@ -215,11 +255,11 @@ export function createAdminOps(deps) {
|
|
|
215
255
|
if (fixedNodeOf(record, config))
|
|
216
256
|
return owned(record, "rename it in the registration");
|
|
217
257
|
if (isHomeNode(record) && patch.slug !== undefined) {
|
|
218
|
-
return err("blocked", `smoodly: "${record
|
|
219
|
-
}
|
|
220
|
-
if (!config.locales.supported.includes(patch.locale)) {
|
|
221
|
-
return err("validation", `smoodly: "${patch.locale}" is not a supported locale.`);
|
|
258
|
+
return err("blocked", `smoodly: "${nameOf(record)}" is the site root — its slug is the config's homePageSlug.`);
|
|
222
259
|
}
|
|
260
|
+
const unsupported = supportedLocale(patch.locale);
|
|
261
|
+
if (unsupported)
|
|
262
|
+
return unsupported;
|
|
223
263
|
const renamed = await pages.rename(id, patch);
|
|
224
264
|
// Every descendant's path changed with this segment; the parent
|
|
225
265
|
// lists this node's title and path.
|
|
@@ -233,7 +273,7 @@ export function createAdminOps(deps) {
|
|
|
233
273
|
if (fixedNodeOf(record, config))
|
|
234
274
|
return owned(record, "it stays at the root");
|
|
235
275
|
if (isHomeNode(record))
|
|
236
|
-
return err("blocked", `smoodly: "${record
|
|
276
|
+
return err("blocked", `smoodly: "${nameOf(record)}" is the site root — it stays at the root.`);
|
|
237
277
|
if (to.parentId !== null) {
|
|
238
278
|
const parent = await pages.getPage(to.parentId);
|
|
239
279
|
if (!parent)
|
|
@@ -252,7 +292,7 @@ export function createAdminOps(deps) {
|
|
|
252
292
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
253
293
|
const fixed = fixedNodeOf(record, config);
|
|
254
294
|
if (fixed?.collection) {
|
|
255
|
-
return err("blocked", `smoodly: "${record
|
|
295
|
+
return err("blocked", `smoodly: "${nameOf(record)}" is the "${fixed.collection}" collection's mount — its index page is a fixed registration.`);
|
|
256
296
|
}
|
|
257
297
|
const registration = openTemplate(template);
|
|
258
298
|
if (!registration)
|
|
@@ -262,35 +302,79 @@ export function createAdminOps(deps) {
|
|
|
262
302
|
}
|
|
263
303
|
return ok(await pages.setTemplate(id, template));
|
|
264
304
|
}),
|
|
305
|
+
addLocale: (id, locale, options) => guard(async () => {
|
|
306
|
+
const unsupported = supportedLocale(locale) ?? supportedLocale(options?.from);
|
|
307
|
+
if (unsupported)
|
|
308
|
+
return unsupported;
|
|
309
|
+
if (!(await pages.getPage(id)))
|
|
310
|
+
return err("not_found", `smoodly: no page "${id}".`);
|
|
311
|
+
// A new locale is a draft: nothing public changes, nothing to expire.
|
|
312
|
+
return ok(await pages.addLocale(id, locale, { from: options.from }));
|
|
313
|
+
}),
|
|
314
|
+
removeLocale: (id, locale) => guard(async () => {
|
|
315
|
+
const unsupported = supportedLocale(locale);
|
|
316
|
+
if (unsupported)
|
|
317
|
+
return unsupported;
|
|
318
|
+
const record = await pages.getPage(id);
|
|
319
|
+
if (!record)
|
|
320
|
+
return err("not_found", `smoodly: no page "${id}".`);
|
|
321
|
+
if (fixedNodeOf(record, config))
|
|
322
|
+
return owned(record, "it exists in every locale");
|
|
323
|
+
if (isHomeNode(record))
|
|
324
|
+
return err("blocked", `smoodly: "${nameOf(record)}" is the site root — it exists in every locale.`);
|
|
325
|
+
const removed = await pages.removeLocale(id, locale);
|
|
326
|
+
// The locale may have been published: its unit and the parent's listing go.
|
|
327
|
+
expirePages([id, record.parentId]);
|
|
328
|
+
return ok(removed);
|
|
329
|
+
}),
|
|
265
330
|
},
|
|
266
331
|
entries: {
|
|
267
332
|
list: (collection) => guard(async () => ok(await entries.list(collection))),
|
|
268
|
-
get: (collection, id) => guard(async () => {
|
|
269
|
-
const
|
|
270
|
-
|
|
333
|
+
get: (collection, id, locale) => guard(async () => {
|
|
334
|
+
const unsupported = supportedLocale(locale);
|
|
335
|
+
if (unsupported)
|
|
336
|
+
return unsupported;
|
|
337
|
+
const record = await entries.get(collection, id);
|
|
338
|
+
if (!record)
|
|
339
|
+
return err("not_found", `smoodly: no entry "${id}" in "${collection}".`);
|
|
340
|
+
const fields = record.locales[locale] ? entryFieldsIn(record, locale) : null;
|
|
341
|
+
// A locale can only be added where the parent already has it; a root has no parent.
|
|
342
|
+
const parentLocales = record.parentId === null
|
|
343
|
+
? config.locales.supported
|
|
344
|
+
: Object.keys((await entries.get(collection, record.parentId))?.locales ?? {});
|
|
345
|
+
return ok({ record, locale, fields, parentLocales });
|
|
271
346
|
}),
|
|
272
|
-
create: (collection,
|
|
347
|
+
create: (collection, input) => guard(async () => {
|
|
348
|
+
const unsupported = supportedLocale(input.locale);
|
|
349
|
+
if (unsupported)
|
|
350
|
+
return unsupported;
|
|
273
351
|
const descriptors = collectionFields(collection);
|
|
274
352
|
if (!descriptors)
|
|
275
353
|
return err("not_found", `smoodly: no collection named "${collection}".`);
|
|
276
|
-
const fieldErrors = validateFields(fields, descriptors);
|
|
354
|
+
const fieldErrors = validateFields(input.fields, descriptors);
|
|
277
355
|
if (fieldErrors.length > 0)
|
|
278
356
|
return err("validation", "Some fields need attention.", fieldErrors);
|
|
279
|
-
return ok(await entries.create(collection, fields));
|
|
357
|
+
return ok(await entries.create(collection, input.fields, { locale: input.locale, parentId: input.parentId ?? null }));
|
|
280
358
|
}),
|
|
281
|
-
save: (collection, id, fields) => guard(async () => {
|
|
359
|
+
save: (collection, id, locale, fields) => guard(async () => {
|
|
360
|
+
const unsupported = supportedLocale(locale);
|
|
361
|
+
if (unsupported)
|
|
362
|
+
return unsupported;
|
|
282
363
|
const descriptors = collectionFields(collection);
|
|
283
364
|
if (!descriptors)
|
|
284
365
|
return err("not_found", `smoodly: no collection named "${collection}".`);
|
|
285
366
|
const fieldErrors = validateFields(fields, descriptors);
|
|
286
367
|
if (fieldErrors.length > 0)
|
|
287
368
|
return err("validation", "Some fields need attention.", fieldErrors);
|
|
288
|
-
const entry = await entries.update(collection, id, fields);
|
|
369
|
+
const entry = await entries.update(collection, id, locale, fields);
|
|
289
370
|
await fanOut(id);
|
|
290
371
|
return ok(entry);
|
|
291
372
|
}),
|
|
292
|
-
setStatus: (collection, id, status) => guard(async () => {
|
|
293
|
-
const
|
|
373
|
+
setStatus: (collection, id, locale, status) => guard(async () => {
|
|
374
|
+
const unsupported = supportedLocale(locale);
|
|
375
|
+
if (unsupported)
|
|
376
|
+
return unsupported;
|
|
377
|
+
const entry = await entries.setStatus(collection, id, locale, status);
|
|
294
378
|
await fanOut(id);
|
|
295
379
|
return ok(entry);
|
|
296
380
|
}),
|
|
@@ -304,6 +388,21 @@ export function createAdminOps(deps) {
|
|
|
304
388
|
}
|
|
305
389
|
return ok(moved);
|
|
306
390
|
}),
|
|
391
|
+
// A new locale starts as a draft: nothing public changes, nothing to expire.
|
|
392
|
+
addLocale: (collection, id, locale, options) => guard(async () => {
|
|
393
|
+
const unsupported = supportedLocale(locale);
|
|
394
|
+
if (unsupported)
|
|
395
|
+
return unsupported;
|
|
396
|
+
return ok(await entries.addLocale(collection, id, locale, { from: options.from }));
|
|
397
|
+
}),
|
|
398
|
+
removeLocale: (collection, id, locale) => guard(async () => {
|
|
399
|
+
const unsupported = supportedLocale(locale);
|
|
400
|
+
if (unsupported)
|
|
401
|
+
return unsupported;
|
|
402
|
+
const entry = await entries.removeLocale(collection, id, locale);
|
|
403
|
+
await fanOut(id);
|
|
404
|
+
return ok(entry);
|
|
405
|
+
}),
|
|
307
406
|
},
|
|
308
407
|
preview: {
|
|
309
408
|
enable: () => guard(async () => (await effects?.draft?.enable(), ok(null))),
|
package/dist/admin/ops.d.ts
CHANGED
|
@@ -14,7 +14,9 @@ export type OpResult<T> = {
|
|
|
14
14
|
} | OpError;
|
|
15
15
|
export type PageCreateInput = {
|
|
16
16
|
parentId: string | null;
|
|
17
|
-
/** The
|
|
17
|
+
/** The one locale the page is created in; others are added with addLocale. */
|
|
18
|
+
locale: string;
|
|
19
|
+
/** The segment in that locale. */
|
|
18
20
|
slug: string;
|
|
19
21
|
/** Defaults to the slug. */
|
|
20
22
|
title?: string;
|
|
@@ -22,24 +24,49 @@ export type PageCreateInput = {
|
|
|
22
24
|
template: string | null;
|
|
23
25
|
};
|
|
24
26
|
export type PageGetResult = {
|
|
27
|
+
/** The node with ALL its locale rows — the switcher lists them. */
|
|
25
28
|
record: PageRecord;
|
|
26
|
-
/**
|
|
29
|
+
/** The locale this result was loaded for. */
|
|
30
|
+
locale: string;
|
|
31
|
+
/** That locale's draft tree; null for a folder, and null when the page has no row in `locale`. */
|
|
27
32
|
draft: PageTree | null;
|
|
28
|
-
/** locale → locale-relative CMS path (the `paths` index). */
|
|
33
|
+
/** locale → locale-relative CMS path (the `paths` index), for the locales present. */
|
|
29
34
|
paths: Record<string, string>;
|
|
30
35
|
/** locale → site URL, `href` applied server-side (the canvas and "View live" use these). */
|
|
31
36
|
urls: Record<string, string>;
|
|
37
|
+
/** The locales the PARENT exists in — a locale can only be added here
|
|
38
|
+
* once the parent has it (DESIGN.md, Localization), so the editor
|
|
39
|
+
* disables the rest with a reason. Every supported locale for a root
|
|
40
|
+
* page: a root can be added to any locale. */
|
|
41
|
+
parentLocales: string[];
|
|
42
|
+
};
|
|
43
|
+
export type EntryCreateInput = {
|
|
44
|
+
/** The one locale the entry is created in; others are added with addLocale. */
|
|
45
|
+
locale: string;
|
|
46
|
+
parentId?: string | null;
|
|
47
|
+
/** The merged form: shared fields, that locale's localized fields and its slug. */
|
|
48
|
+
fields: Record<string, unknown>;
|
|
49
|
+
};
|
|
50
|
+
export type EntryGetResult = {
|
|
51
|
+
/** The node with ALL its locale rows — the switcher lists them. */
|
|
52
|
+
record: EntryRecord;
|
|
53
|
+
locale: string;
|
|
54
|
+
/** That locale's merged draft fields; null when the entry has no row in `locale`. */
|
|
55
|
+
fields: Record<string, unknown> | null;
|
|
56
|
+
/** The locales the PARENT exists in (every supported locale for a root) — the form disables the rest with a reason. */
|
|
57
|
+
parentLocales: string[];
|
|
32
58
|
};
|
|
33
59
|
export type AdminOps = {
|
|
34
60
|
pages: {
|
|
35
|
-
/** Lists every node, materializing fixed pages and collection mounts first. */
|
|
61
|
+
/** Lists every node, materializing fixed pages and collection mounts (in every supported locale) first. */
|
|
36
62
|
list(): Promise<OpResult<PageRecord[]>>;
|
|
37
|
-
|
|
63
|
+
/** not_found only when the page is missing; an absent locale comes back with `draft: null`. */
|
|
64
|
+
get(id: string, locale: string): Promise<OpResult<PageGetResult>>;
|
|
38
65
|
create(input: PageCreateInput): Promise<OpResult<PageRecord>>;
|
|
39
|
-
saveDraft(id: string, tree: PageTree): Promise<OpResult<{
|
|
66
|
+
saveDraft(id: string, locale: string, tree: PageTree): Promise<OpResult<{
|
|
40
67
|
versionId: string;
|
|
41
68
|
}>>;
|
|
42
|
-
publish(id: string): Promise<OpResult<PageRecord>>;
|
|
69
|
+
publish(id: string, locale: string): Promise<OpResult<PageRecord>>;
|
|
43
70
|
delete(id: string): Promise<OpResult<null>>;
|
|
44
71
|
rename(id: string, patch: {
|
|
45
72
|
locale: string;
|
|
@@ -52,18 +79,31 @@ export type AdminOps = {
|
|
|
52
79
|
}): Promise<OpResult<PageRecord>>;
|
|
53
80
|
/** "Add page here": a folder becomes a page. */
|
|
54
81
|
setTemplate(id: string, template: string): Promise<OpResult<PageRecord>>;
|
|
82
|
+
/** Copies `from`'s draft tree, title and slug as the new locale's first draft (spec 2026-09-06 §4). */
|
|
83
|
+
addLocale(id: string, locale: string, options: {
|
|
84
|
+
from: string;
|
|
85
|
+
}): Promise<OpResult<PageRecord>>;
|
|
86
|
+
/** Deletes the locale's row, versions and paths. Never on fixed nodes or the site root. */
|
|
87
|
+
removeLocale(id: string, locale: string): Promise<OpResult<PageRecord>>;
|
|
55
88
|
};
|
|
56
89
|
entries: {
|
|
57
90
|
list(collection: string): Promise<OpResult<EntryRecord[]>>;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
91
|
+
/** not_found only when the entry is missing; an absent locale comes back with `fields: null`. */
|
|
92
|
+
get(collection: string, id: string, locale: string): Promise<OpResult<EntryGetResult>>;
|
|
93
|
+
create(collection: string, input: EntryCreateInput): Promise<OpResult<EntryRecord>>;
|
|
94
|
+
save(collection: string, id: string, locale: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
|
|
95
|
+
setStatus(collection: string, id: string, locale: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
|
|
62
96
|
delete(collection: string, id: string): Promise<OpResult<null>>;
|
|
63
97
|
move(collection: string, id: string, to: {
|
|
64
98
|
parentId: string | null;
|
|
65
99
|
index?: number;
|
|
66
100
|
}): Promise<OpResult<EntryRecord>>;
|
|
101
|
+
/** Copies `from`'s localized fields and slug as the new locale's draft (spec 2026-09-06 §7). Expires nothing. */
|
|
102
|
+
addLocale(collection: string, id: string, locale: string, options: {
|
|
103
|
+
from: string;
|
|
104
|
+
}): Promise<OpResult<EntryRecord>>;
|
|
105
|
+
/** Deletes the locale's row, versions and paths; fans out like a save. */
|
|
106
|
+
removeLocale(collection: string, id: string, locale: string): Promise<OpResult<EntryRecord>>;
|
|
67
107
|
};
|
|
68
108
|
preview: {
|
|
69
109
|
enable(): Promise<OpResult<null>>;
|
|
@@ -9,16 +9,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
9
9
|
// column whose views own their own 48px top bar (the editor adds the third,
|
|
10
10
|
// its 300px inspector). The shell is viewport-height; every column scrolls
|
|
11
11
|
// inside itself.
|
|
12
|
-
import {
|
|
12
|
+
import { useState } from "react";
|
|
13
13
|
import { makeClientOps } from "../client-ops.js";
|
|
14
|
-
import { ListView } from "./ListView.js";
|
|
15
14
|
import { PagesList } from "./PagesList.js";
|
|
15
|
+
import { EntriesList } from "./EntriesList.js";
|
|
16
16
|
import { EntryForm } from "./EntryForm.js";
|
|
17
17
|
import { EditorView } from "../editor/EditorView.js";
|
|
18
18
|
import { LoginView } from "./LoginView.js";
|
|
19
19
|
import { useAdminSession } from "./session.js";
|
|
20
|
-
import {
|
|
21
|
-
import { relativeTime } from "../ui/format.js";
|
|
20
|
+
import { Logo, ThemeToggle } from "../ui/primitives.js";
|
|
22
21
|
import { BASE } from "./base.js";
|
|
23
22
|
import { ErrorPane } from "./ErrorPane.js";
|
|
24
23
|
/** Nav items the registry can't produce yet: shown, disabled, so the shape
|
|
@@ -30,34 +29,6 @@ function Nav({ registry, active, email, onSignOut }) {
|
|
|
30
29
|
const initials = name.slice(0, 2).toUpperCase();
|
|
31
30
|
return (_jsxs("nav", { className: "sm-nav", children: [_jsx(Logo, {}), _jsxs("div", { className: "sm-nav__list", children: [item(`${BASE}/pages`, "Pages", "pages"), registry.collections.map((c) => item(`${BASE}/${c.name}`, c.title, c.name)), PLANNED.map((label) => item(null, label, label))] }), _jsxs("div", { className: "sm-nav__foot", children: [_jsx(ThemeToggle, {}), _jsxs("div", { className: "sm-nav__user", children: [_jsx("span", { className: "sm-avatar", children: initials }), _jsxs("span", { style: { display: "flex", flexDirection: "column", minWidth: 0 }, children: [_jsx("span", { style: { fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: name }), _jsx("span", { style: { fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: "Editor" })] })] }), _jsx("button", { className: "sm-nav-item", onClick: onSignOut, style: { color: "var(--text-muted)" }, children: "Sign out" })] })] }));
|
|
32
31
|
}
|
|
33
|
-
function EntriesList({ ops, registry, collection }) {
|
|
34
|
-
const meta = registry.collections.find((c) => c.name === collection);
|
|
35
|
-
const [rows, setRows] = useState(null);
|
|
36
|
-
const [error, setError] = useState(null);
|
|
37
|
-
useEffect(() => {
|
|
38
|
-
ops.entries.list(collection)
|
|
39
|
-
.then((r) => (r.ok ? setRows(r.data) : setError(r.message)))
|
|
40
|
-
.catch(() => setError("Couldn't reach the server."));
|
|
41
|
-
}, [ops, collection]);
|
|
42
|
-
if (!meta)
|
|
43
|
-
return _jsx(ErrorPane, { message: "No such collection." });
|
|
44
|
-
if (error)
|
|
45
|
-
return _jsx(ErrorPane, { message: error });
|
|
46
|
-
const title = (e) => String((meta.titleField && e.fields[meta.titleField]) ?? Object.values(e.fields).find((v) => typeof v === "string") ?? e.id);
|
|
47
|
-
return (_jsx(ListView, { title: meta.title, count: rows?.length, actions: _jsx(Button, { onClick: () => { window.location.href = `${BASE}/${collection}/new`; }, children: "+ New entry" }), columns: [
|
|
48
|
-
{ label: "Title", width: "minmax(160px,1fr)" },
|
|
49
|
-
{ label: "Updated", width: "minmax(110px,160px)" },
|
|
50
|
-
{ label: "Status", width: "120px" },
|
|
51
|
-
], empty: rows ? "Nothing here yet." : "Loading…", rows: (rows ?? []).map((e) => ({
|
|
52
|
-
key: e.id,
|
|
53
|
-
href: `${BASE}/${collection}/${encodeURIComponent(e.id)}`,
|
|
54
|
-
cells: [
|
|
55
|
-
_jsx("span", { className: "sm-cell sm-cell--title", children: title(e) }, "t"),
|
|
56
|
-
_jsx("span", { className: "sm-cell sm-cell--muted", children: relativeTime(e.updatedAt) }, "u"),
|
|
57
|
-
_jsx(StatusBadge, { status: e.status }, "c"),
|
|
58
|
-
],
|
|
59
|
-
})) }));
|
|
60
|
-
}
|
|
61
32
|
export function AdminApp({ registry, segments, op, auth }) {
|
|
62
33
|
const session = useAdminSession(auth);
|
|
63
34
|
const [ops] = useState(() => makeClientOps(op, session.token));
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AdminOps } from "../ops.ts";
|
|
2
|
+
import type { AdminRegistry } from "../serialize.ts";
|
|
3
|
+
export declare function EntriesList({ ops, registry, collection }: {
|
|
4
|
+
ops: AdminOps;
|
|
5
|
+
registry: AdminRegistry;
|
|
6
|
+
collection: string;
|
|
7
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// A collection's list for ONE selected locale: every record is a row;
|
|
4
|
+
// a record absent from the selected locale is dimmed and its click is
|
|
5
|
+
// "Add <locale>" (a confirm, then addLocale copying the source locale).
|
|
6
|
+
// Opening a present row opens the form in the selected locale.
|
|
7
|
+
import { useCallback, useEffect, useState } from "react";
|
|
8
|
+
import { ListView } from "./ListView.js";
|
|
9
|
+
import { entryRows, entrySourceLocale } from "./entries-list.js";
|
|
10
|
+
import { Button, Segmented, StatusBadge } from "../ui/primitives.js";
|
|
11
|
+
import { relativeTime } from "../ui/format.js";
|
|
12
|
+
import { BASE } from "./base.js";
|
|
13
|
+
import { ErrorPane } from "./ErrorPane.js";
|
|
14
|
+
const LOCALE_KEY = "smoodly.entries.locale";
|
|
15
|
+
/** The selected locale survives a reload within the session; a stored
|
|
16
|
+
* value that is no longer supported falls back to the default. */
|
|
17
|
+
function rememberedLocale(registry) {
|
|
18
|
+
try {
|
|
19
|
+
const stored = window.sessionStorage.getItem(LOCALE_KEY);
|
|
20
|
+
if (stored && registry.locales.supported.includes(stored))
|
|
21
|
+
return stored;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// storage unavailable — the default is fine
|
|
25
|
+
}
|
|
26
|
+
return registry.locales.default;
|
|
27
|
+
}
|
|
28
|
+
export function EntriesList({ ops, registry, collection }) {
|
|
29
|
+
const meta = registry.collections.find((c) => c.name === collection);
|
|
30
|
+
const [records, setRecords] = useState(null);
|
|
31
|
+
const [error, setError] = useState(null);
|
|
32
|
+
const [locale, setLocale] = useState(() => rememberedLocale(registry));
|
|
33
|
+
const multilingual = registry.locales.supported.length > 1;
|
|
34
|
+
const pickLocale = (next) => {
|
|
35
|
+
setLocale(next);
|
|
36
|
+
try {
|
|
37
|
+
window.sessionStorage.setItem(LOCALE_KEY, next);
|
|
38
|
+
}
|
|
39
|
+
catch { /* see rememberedLocale */ }
|
|
40
|
+
};
|
|
41
|
+
const load = useCallback(() => {
|
|
42
|
+
ops.entries.list(collection)
|
|
43
|
+
.then((r) => (r.ok ? setRecords(r.data) : setError(r.message)))
|
|
44
|
+
.catch(() => setError("Couldn't reach the server."));
|
|
45
|
+
}, [ops, collection]);
|
|
46
|
+
useEffect(load, [load]);
|
|
47
|
+
if (!meta)
|
|
48
|
+
return _jsx(ErrorPane, { message: "No such collection." });
|
|
49
|
+
if (error)
|
|
50
|
+
return _jsx(ErrorPane, { message: error });
|
|
51
|
+
const open = (id) => {
|
|
52
|
+
window.location.href = `${BASE}/${collection}/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`;
|
|
53
|
+
};
|
|
54
|
+
const addLocale = async (row) => {
|
|
55
|
+
const record = records?.find((r) => r.id === row.id);
|
|
56
|
+
if (!record)
|
|
57
|
+
return;
|
|
58
|
+
const from = entrySourceLocale(record, registry);
|
|
59
|
+
if (!window.confirm(`Add "${row.title}" to ${locale}? Its ${from} fields are copied as the first ${locale} draft.`))
|
|
60
|
+
return;
|
|
61
|
+
let result;
|
|
62
|
+
try {
|
|
63
|
+
result = await ops.entries.addLocale(collection, row.id, locale, { from });
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
67
|
+
}
|
|
68
|
+
if (!result.ok) {
|
|
69
|
+
window.alert(result.message);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
open(row.id);
|
|
73
|
+
};
|
|
74
|
+
const rows = records ? entryRows(records, meta, registry, locale) : [];
|
|
75
|
+
return (_jsx(ListView, { title: meta.title, count: records?.length, actions: _jsxs(_Fragment, { children: [multilingual && _jsx(Segmented, { options: registry.locales.supported, value: locale, onChange: pickLocale }), _jsx(Button, { onClick: () => { window.location.href = `${BASE}/${collection}/new?locale=${encodeURIComponent(locale)}`; }, children: "+ New entry" })] }), columns: [
|
|
76
|
+
{ label: "Title", width: "minmax(160px,1fr)" },
|
|
77
|
+
{ label: "Updated", width: "minmax(110px,160px)" },
|
|
78
|
+
{ label: "Status", width: "120px" },
|
|
79
|
+
], empty: records ? "Nothing here yet." : "Loading…", rows: rows.map((row) => {
|
|
80
|
+
const dim = row.present ? undefined : { opacity: 0.45 };
|
|
81
|
+
return {
|
|
82
|
+
key: row.id,
|
|
83
|
+
onClick: () => (row.present ? open(row.id) : void addLocale(row)),
|
|
84
|
+
cells: [
|
|
85
|
+
_jsx("span", { className: "sm-cell sm-cell--title", style: dim, children: row.title }, "t"),
|
|
86
|
+
row.present
|
|
87
|
+
? _jsx("span", { className: "sm-cell sm-cell--muted", children: relativeTime(row.updatedAt) }, "u")
|
|
88
|
+
: _jsxs("span", { className: "sm-cell sm-cell--muted", children: ["not in ", locale, " \u2014 click to add"] }, "u"),
|
|
89
|
+
row.status
|
|
90
|
+
? _jsx(StatusBadge, { status: row.status }, "c")
|
|
91
|
+
: _jsx("span", { className: "sm-cell sm-cell--muted", children: "\u2014" }, "c"),
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
}) }));
|
|
95
|
+
}
|