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.
Files changed (44) hide show
  1. package/README.md +231 -73
  2. package/dist/admin/client-ops.js +2 -2
  3. package/dist/admin/editor/EditorView.js +167 -24
  4. package/dist/admin/editor/PageSettings.d.ts +8 -4
  5. package/dist/admin/editor/PageSettings.js +25 -25
  6. package/dist/admin/fixed-nodes.d.ts +16 -10
  7. package/dist/admin/fixed-nodes.js +55 -41
  8. package/dist/admin/ops-impl.js +134 -35
  9. package/dist/admin/ops.d.ts +51 -11
  10. package/dist/admin/shell/AdminApp.js +3 -32
  11. package/dist/admin/shell/EntriesList.d.ts +7 -0
  12. package/dist/admin/shell/EntriesList.js +95 -0
  13. package/dist/admin/shell/EntryForm.js +167 -49
  14. package/dist/admin/shell/PagesList.js +86 -26
  15. package/dist/admin/shell/entries-list.d.ts +21 -0
  16. package/dist/admin/shell/entries-list.js +36 -0
  17. package/dist/admin/shell/pages-tree.d.ts +30 -12
  18. package/dist/admin/shell/pages-tree.js +48 -14
  19. package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
  20. package/dist/admin/ui/LocaleSwitcher.js +15 -0
  21. package/dist/collections.d.ts +6 -0
  22. package/dist/collections.js +16 -0
  23. package/dist/config.js +23 -3
  24. package/dist/entry-store.d.ts +132 -54
  25. package/dist/entry-store.js +294 -86
  26. package/dist/index.d.ts +6 -6
  27. package/dist/index.js +4 -4
  28. package/dist/localize.d.ts +0 -11
  29. package/dist/localize.js +11 -29
  30. package/dist/paths.d.ts +46 -27
  31. package/dist/paths.js +62 -29
  32. package/dist/revalidate.js +2 -1
  33. package/dist/site.d.ts +6 -2
  34. package/dist/site.js +65 -39
  35. package/dist/sql-space.d.ts +1 -1
  36. package/dist/sql-space.js +13 -5
  37. package/dist/sql.js +96 -53
  38. package/dist/store.d.ts +66 -32
  39. package/dist/store.js +205 -90
  40. package/dist/supabase-entry-store.d.ts +29 -8
  41. package/dist/supabase-entry-store.js +341 -177
  42. package/dist/supabase-store.d.ts +21 -9
  43. package/dist/supabase-store.js +202 -111
  44. package/package.json +1 -1
@@ -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 owned = (record, verb) => err("blocked", `smoodly: "${record.slug}" is owned by code — ${verb}.`);
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
- const isHomeNode = (r) => r.parentId === null && r.slug === config.homePageSlug;
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.slug}" — the "${fixed.collection}" collection owns that URL space.`)
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(([locale, path]) => [locale, href(path, locale)]));
132
- return ok({ record, draft, paths, urls });
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 draft = record.template === null ? null : await pages.getDraftTree(id);
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.slug}" is a folder — give it a template first.`)
175
- : err("blocked", `smoodly: "${record.slug}" has no draft to publish.`);
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 "${record.slug}" — fix the listed fields first.`, fieldErrors);
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.slug}" is owned by code — remove the registration to remove it.`);
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.slug}" is the site root — it cannot be deleted.`);
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.slug}" is the site root — its slug is the config's homePageSlug.`);
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.slug}" is the site root — it stays at the root.`);
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.slug}" is the "${fixed.collection}" collection's mount — its index page is a fixed registration.`);
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 entry = await entries.get(collection, id);
270
- return entry ? ok(entry) : err("not_found", `smoodly: no entry "${id}" in "${collection}".`);
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, fields) => guard(async () => {
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 entry = await entries.setStatus(collection, id, status);
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))),
@@ -14,7 +14,9 @@ export type OpResult<T> = {
14
14
  } | OpError;
15
15
  export type PageCreateInput = {
16
16
  parentId: string | null;
17
- /** The default-locale segment. */
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
- /** null for a folder. */
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
- get(id: string): Promise<OpResult<PageGetResult>>;
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
- get(collection: string, id: string): Promise<OpResult<EntryRecord>>;
59
- create(collection: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
60
- save(collection: string, id: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
61
- setStatus(collection: string, id: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
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 { useEffect, useState } from "react";
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 { Button, Logo, StatusBadge, ThemeToggle } from "../ui/primitives.js";
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
+ }