smoodly 0.0.6 → 0.0.8
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 +318 -74
- package/dist/admin/client-ops.js +3 -2
- package/dist/admin/editor/EditorView.js +229 -30
- 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/index.d.ts +3 -2
- package/dist/admin/index.js +1 -0
- package/dist/admin/ops-impl.js +218 -36
- package/dist/admin/ops.d.ts +73 -12
- package/dist/admin/serialize.d.ts +11 -0
- package/dist/admin/serialize.js +8 -0
- package/dist/admin/shared-items.d.ts +9 -0
- package/dist/admin/shared-items.js +61 -0
- package/dist/admin/shell/AdminApp.js +11 -34
- 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/SharedForm.d.ts +7 -0
- package/dist/admin/shell/SharedForm.js +146 -0
- package/dist/admin/shell/SharedList.d.ts +6 -0
- package/dist/admin/shell/SharedList.js +62 -0
- 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/shell/shared-list.d.ts +14 -0
- package/dist/admin/shell/shared-list.js +17 -0
- package/dist/admin/tree-ops.d.ts +12 -5
- package/dist/admin/tree-ops.js +39 -8
- package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
- package/dist/admin/ui/LocaleSwitcher.js +15 -0
- package/dist/collections.d.ts +10 -1
- package/dist/collections.js +16 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +58 -4
- package/dist/entry-store.d.ts +141 -55
- package/dist/entry-store.js +317 -87
- package/dist/index.d.ts +11 -7
- package/dist/index.js +8 -5
- package/dist/localize.d.ts +0 -11
- package/dist/localize.js +11 -29
- package/dist/next/page-renderer.d.ts +4 -0
- package/dist/next/page-renderer.js +14 -1
- package/dist/page.js +5 -1
- package/dist/paths.d.ts +46 -27
- package/dist/paths.js +62 -29
- package/dist/refs.js +8 -0
- package/dist/resolve.d.ts +3 -0
- package/dist/resolve.js +38 -0
- package/dist/revalidate.d.ts +4 -0
- package/dist/revalidate.js +6 -1
- package/dist/shared.d.ts +46 -0
- package/dist/shared.js +36 -0
- package/dist/site.d.ts +16 -2
- package/dist/site.js +92 -39
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +16 -13
- package/dist/sql.js +95 -70
- package/dist/store.d.ts +66 -32
- package/dist/store.js +205 -90
- package/dist/supabase-entry-store.d.ts +30 -9
- package/dist/supabase-entry-store.js +360 -178
- package/dist/supabase-store.d.ts +21 -9
- package/dist/supabase-store.js +202 -111
- package/dist/zones.d.ts +6 -2
- package/dist/zones.js +8 -2
- package/package.json +1 -1
package/dist/admin/ops.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PageRecord } from "../store.ts";
|
|
2
2
|
import type { PageTree } from "../render.tsx";
|
|
3
|
-
import type { EntryRecord } from "../entry-store.ts";
|
|
3
|
+
import type { EntryRecord, EntryUsage } from "../entry-store.ts";
|
|
4
4
|
import type { FieldError } from "./validate.ts";
|
|
5
5
|
export type OpError = {
|
|
6
6
|
ok: false;
|
|
@@ -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,63 @@ 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[];
|
|
58
|
+
};
|
|
59
|
+
export type SharedListItem = {
|
|
60
|
+
name: string;
|
|
61
|
+
record: EntryRecord;
|
|
62
|
+
};
|
|
63
|
+
export type SharedGetResult = {
|
|
64
|
+
name: string;
|
|
65
|
+
/** The node with ALL its locale rows (every supported one, by construction). */
|
|
66
|
+
record: EntryRecord;
|
|
67
|
+
locale: string;
|
|
68
|
+
/** That locale's merged draft fields — a visual item's styles under `$styles`. */
|
|
69
|
+
fields: Record<string, unknown>;
|
|
70
|
+
/** Who links it: "used on N pages". */
|
|
71
|
+
usage: EntryUsage[];
|
|
32
72
|
};
|
|
33
73
|
export type AdminOps = {
|
|
34
74
|
pages: {
|
|
35
|
-
/** Lists every node, materializing fixed pages and collection mounts first. */
|
|
75
|
+
/** Lists every node, materializing fixed pages and collection mounts (in every supported locale) first. */
|
|
36
76
|
list(): Promise<OpResult<PageRecord[]>>;
|
|
37
|
-
|
|
77
|
+
/** not_found only when the page is missing; an absent locale comes back with `draft: null`. */
|
|
78
|
+
get(id: string, locale: string): Promise<OpResult<PageGetResult>>;
|
|
38
79
|
create(input: PageCreateInput): Promise<OpResult<PageRecord>>;
|
|
39
|
-
saveDraft(id: string, tree: PageTree): Promise<OpResult<{
|
|
80
|
+
saveDraft(id: string, locale: string, tree: PageTree): Promise<OpResult<{
|
|
40
81
|
versionId: string;
|
|
41
82
|
}>>;
|
|
42
|
-
publish(id: string): Promise<OpResult<PageRecord>>;
|
|
83
|
+
publish(id: string, locale: string): Promise<OpResult<PageRecord>>;
|
|
43
84
|
delete(id: string): Promise<OpResult<null>>;
|
|
44
85
|
rename(id: string, patch: {
|
|
45
86
|
locale: string;
|
|
@@ -52,18 +93,38 @@ export type AdminOps = {
|
|
|
52
93
|
}): Promise<OpResult<PageRecord>>;
|
|
53
94
|
/** "Add page here": a folder becomes a page. */
|
|
54
95
|
setTemplate(id: string, template: string): Promise<OpResult<PageRecord>>;
|
|
96
|
+
/** Copies `from`'s draft tree, title and slug as the new locale's first draft (spec 2026-09-06 §4). */
|
|
97
|
+
addLocale(id: string, locale: string, options: {
|
|
98
|
+
from: string;
|
|
99
|
+
}): Promise<OpResult<PageRecord>>;
|
|
100
|
+
/** Deletes the locale's row, versions and paths. Never on fixed nodes or the site root. */
|
|
101
|
+
removeLocale(id: string, locale: string): Promise<OpResult<PageRecord>>;
|
|
55
102
|
};
|
|
56
103
|
entries: {
|
|
57
104
|
list(collection: string): Promise<OpResult<EntryRecord[]>>;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
105
|
+
/** not_found only when the entry is missing; an absent locale comes back with `fields: null`. */
|
|
106
|
+
get(collection: string, id: string, locale: string): Promise<OpResult<EntryGetResult>>;
|
|
107
|
+
create(collection: string, input: EntryCreateInput): Promise<OpResult<EntryRecord>>;
|
|
108
|
+
save(collection: string, id: string, locale: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
|
|
109
|
+
setStatus(collection: string, id: string, locale: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
|
|
62
110
|
delete(collection: string, id: string): Promise<OpResult<null>>;
|
|
63
111
|
move(collection: string, id: string, to: {
|
|
64
112
|
parentId: string | null;
|
|
65
113
|
index?: number;
|
|
66
114
|
}): Promise<OpResult<EntryRecord>>;
|
|
115
|
+
/** Copies `from`'s localized fields and slug as the new locale's draft (spec 2026-09-06 §7). Expires nothing. */
|
|
116
|
+
addLocale(collection: string, id: string, locale: string, options: {
|
|
117
|
+
from: string;
|
|
118
|
+
}): Promise<OpResult<EntryRecord>>;
|
|
119
|
+
/** Deletes the locale's row, versions and paths; fans out like a save. */
|
|
120
|
+
removeLocale(collection: string, id: string, locale: string): Promise<OpResult<EntryRecord>>;
|
|
121
|
+
};
|
|
122
|
+
shared: {
|
|
123
|
+
/** Materializes every registered item in every supported locale, then lists them in registry order. */
|
|
124
|
+
list(): Promise<OpResult<SharedListItem[]>>;
|
|
125
|
+
get(name: string, locale: string): Promise<OpResult<SharedGetResult>>;
|
|
126
|
+
save(name: string, locale: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
|
|
127
|
+
setStatus(name: string, locale: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
|
|
67
128
|
};
|
|
68
129
|
preview: {
|
|
69
130
|
enable(): Promise<OpResult<null>>;
|
|
@@ -33,10 +33,21 @@ export type AdminPageTemplate = {
|
|
|
33
33
|
slugs?: Record<string, string>;
|
|
34
34
|
zones: Record<string, ZonePolicy>;
|
|
35
35
|
};
|
|
36
|
+
export type AdminShared = {
|
|
37
|
+
name: string;
|
|
38
|
+
title: string;
|
|
39
|
+
/** The section a visual item renders through; absent = an object item. */
|
|
40
|
+
section?: string;
|
|
41
|
+
versions: boolean;
|
|
42
|
+
fields: Record<string, AdminField>;
|
|
43
|
+
/** Resolved (the config's standard set merged in) for a visual item; empty for an object item. */
|
|
44
|
+
styles: Record<string, AdminField>;
|
|
45
|
+
};
|
|
36
46
|
export type AdminRegistry = {
|
|
37
47
|
sections: AdminSection[];
|
|
38
48
|
collections: AdminCollection[];
|
|
39
49
|
pages: AdminPageTemplate[];
|
|
50
|
+
shared: AdminShared[];
|
|
40
51
|
locales: {
|
|
41
52
|
default: string;
|
|
42
53
|
supported: string[];
|
package/dist/admin/serialize.js
CHANGED
|
@@ -53,6 +53,14 @@ export function serializeAdminConfig(config) {
|
|
|
53
53
|
zones: p.zones ?? {},
|
|
54
54
|
};
|
|
55
55
|
}),
|
|
56
|
+
shared: (config.registry.shared ?? []).map((s) => ({
|
|
57
|
+
name: s.name,
|
|
58
|
+
title: s.title,
|
|
59
|
+
...(s.section !== undefined ? { section: s.section } : {}),
|
|
60
|
+
versions: s.versions === true,
|
|
61
|
+
fields: serializeMap(s.fields),
|
|
62
|
+
styles: serializeMap(s.section !== undefined ? config.resolvedStyles(s.section) : {}),
|
|
63
|
+
})),
|
|
56
64
|
locales: config.locales,
|
|
57
65
|
homePageSlug: config.homePageSlug,
|
|
58
66
|
pageDepth: config.pageDepth,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "../config.ts";
|
|
2
|
+
import type { Descriptor } from "../fields.ts";
|
|
3
|
+
import type { EntryRecord, EntryStore } from "../entry-store.ts";
|
|
4
|
+
import { type SharedSchema } from "../shared.ts";
|
|
5
|
+
/** The merged fields a fresh item starts from: sampled field values, and
|
|
6
|
+
* for a visual item `$styles` = the resolved style defaults with the
|
|
7
|
+
* sample's style values on top. Unsampled fields stay absent. */
|
|
8
|
+
export declare function initialSharedFields(item: SharedSchema, resolvedStyles: Record<string, Descriptor>): Record<string, unknown>;
|
|
9
|
+
export declare function ensureSharedItems(entries: EntryStore, config: ResolvedConfig): Promise<Record<string, EntryRecord>>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { STYLES_KEY } from "../shared.js";
|
|
2
|
+
/** The merged fields a fresh item starts from: sampled field values, and
|
|
3
|
+
* for a visual item `$styles` = the resolved style defaults with the
|
|
4
|
+
* sample's style values on top. Unsampled fields stay absent. */
|
|
5
|
+
export function initialSharedFields(item, resolvedStyles) {
|
|
6
|
+
const out = {};
|
|
7
|
+
for (const key of Object.keys(item.fields)) {
|
|
8
|
+
const sampled = item.sample?.[key];
|
|
9
|
+
if (sampled !== undefined)
|
|
10
|
+
out[key] = sampled;
|
|
11
|
+
}
|
|
12
|
+
if (item.section !== undefined) {
|
|
13
|
+
const styles = {};
|
|
14
|
+
for (const [key, d] of Object.entries(resolvedStyles)) {
|
|
15
|
+
const value = item.sample?.[key] ?? d.default;
|
|
16
|
+
if (value !== undefined)
|
|
17
|
+
styles[key] = value;
|
|
18
|
+
}
|
|
19
|
+
out[STYLES_KEY] = styles;
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
export async function ensureSharedItems(entries, config) {
|
|
24
|
+
const { default: def, supported } = config.locales;
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const item of config.registry.shared ?? []) {
|
|
27
|
+
let [record] = await entries.list(item.name);
|
|
28
|
+
if (!record) {
|
|
29
|
+
try {
|
|
30
|
+
const styles = item.section !== undefined ? config.resolvedStyles(item.section) : {};
|
|
31
|
+
record = await entries.create(item.name, initialSharedFields(item, styles), { locale: def });
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
35
|
+
// Another admin load won the create: read theirs. Anything else is
|
|
36
|
+
// worth saying out loud, and must not take the other items down.
|
|
37
|
+
if (message.includes("it is a singleton"))
|
|
38
|
+
[record] = await entries.list(item.name);
|
|
39
|
+
else
|
|
40
|
+
console.warn(`smoodly: could not materialize shared item "${item.name}": ${message}`);
|
|
41
|
+
if (!record)
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const locale of supported) {
|
|
46
|
+
if (record.locales[locale])
|
|
47
|
+
continue;
|
|
48
|
+
try {
|
|
49
|
+
record = await entries.addLocale(item.name, record.id, locale, { from: def });
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
53
|
+
if (!message.includes("already exists in locale")) {
|
|
54
|
+
console.warn(`smoodly: could not materialize shared item "${item.name}" in "${locale}": ${message}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
out[item.name] = record;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
@@ -9,54 +9,27 @@ 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
|
+
import { SharedList } from "./SharedList.js";
|
|
18
|
+
import { SharedForm } from "./SharedForm.js";
|
|
17
19
|
import { EditorView } from "../editor/EditorView.js";
|
|
18
20
|
import { LoginView } from "./LoginView.js";
|
|
19
21
|
import { useAdminSession } from "./session.js";
|
|
20
|
-
import {
|
|
21
|
-
import { relativeTime } from "../ui/format.js";
|
|
22
|
+
import { Logo, ThemeToggle } from "../ui/primitives.js";
|
|
22
23
|
import { BASE } from "./base.js";
|
|
23
24
|
import { ErrorPane } from "./ErrorPane.js";
|
|
24
25
|
/** Nav items the registry can't produce yet: shown, disabled, so the shape
|
|
25
26
|
* of the product is visible — the same set the design system's SideNav has. */
|
|
26
|
-
const PLANNED = ["
|
|
27
|
+
const PLANNED = ["Media", "Settings"];
|
|
27
28
|
function Nav({ registry, active, email, onSignOut }) {
|
|
28
29
|
const item = (href, label, key) => (_jsx("a", { className: "sm-nav-item", href: href ?? undefined, "aria-disabled": href ? undefined : true, "aria-current": href && key === active ? "page" : undefined, children: label }, key));
|
|
29
30
|
const name = email?.split("@")[0] ?? "Editor";
|
|
30
31
|
const initials = name.slice(0, 2).toUpperCase();
|
|
31
|
-
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
|
-
}
|
|
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
|
-
})) }));
|
|
32
|
+
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)), registry.shared.length > 0 && item(`${BASE}/shared`, "Shared content", "shared"), 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" })] })] }));
|
|
60
33
|
}
|
|
61
34
|
export function AdminApp({ registry, segments, op, auth }) {
|
|
62
35
|
const session = useAdminSession(auth);
|
|
@@ -78,6 +51,10 @@ export function AdminApp({ registry, segments, op, auth }) {
|
|
|
78
51
|
main = _jsx(EntriesList, { ops: ops, registry: registry, collection: head });
|
|
79
52
|
else if (isCollection && tail)
|
|
80
53
|
main = _jsx(EntryForm, { ops: ops, registry: registry, collection: head, id: tail === "new" ? "new" : decodeURIComponent(tail) });
|
|
54
|
+
else if (head === "shared" && !tail)
|
|
55
|
+
main = _jsx(SharedList, { ops: ops, registry: registry });
|
|
56
|
+
else if (head === "shared" && tail)
|
|
57
|
+
main = _jsx(SharedForm, { ops: ops, registry: registry, name: decodeURIComponent(tail) });
|
|
81
58
|
else
|
|
82
59
|
main = _jsx(ErrorPane, { message: "Not found." });
|
|
83
60
|
return (_jsxs("div", { className: "sm-shell", children: [_jsx(Nav, { registry: registry, active: head, email: email, onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main })] }));
|
|
@@ -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
|
+
}
|