smoodly 0.0.10 → 0.0.12
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 +108 -8
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/forms/CropEditor.d.ts +10 -0
- package/dist/admin/forms/CropEditor.js +61 -0
- package/dist/admin/forms/FieldWidget.js +4 -4
- package/dist/admin/forms/MediaGrid.d.ts +11 -0
- package/dist/admin/forms/MediaGrid.js +16 -0
- package/dist/admin/forms/MediaPicker.d.ts +8 -0
- package/dist/admin/forms/MediaPicker.js +44 -0
- package/dist/admin/forms/MediaWidget.d.ts +9 -0
- package/dist/admin/forms/MediaWidget.js +83 -0
- package/dist/admin/forms/assets-context.d.ts +19 -0
- package/dist/admin/forms/assets-context.js +35 -0
- package/dist/admin/forms/crop-editor.d.ts +13 -0
- package/dist/admin/forms/crop-editor.js +72 -0
- package/dist/admin/forms/measure.d.ts +2 -0
- package/dist/admin/forms/measure.js +39 -0
- package/dist/admin/forms/upload.d.ts +17 -0
- package/dist/admin/forms/upload.js +33 -0
- package/dist/admin/index.d.ts +1 -1
- package/dist/admin/next/op-handler.d.ts +3 -0
- package/dist/admin/next/op-handler.js +1 -0
- package/dist/admin/ops-impl.d.ts +3 -0
- package/dist/admin/ops-impl.js +83 -2
- package/dist/admin/ops.d.ts +27 -0
- package/dist/admin/serialize.d.ts +5 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AdminApp.d.ts +0 -6
- package/dist/admin/shell/AdminApp.js +10 -17
- package/dist/admin/shell/IconRail.d.ts +7 -0
- package/dist/admin/shell/IconRail.js +73 -0
- package/dist/admin/shell/MediaScreen.d.ts +8 -0
- package/dist/admin/shell/MediaScreen.js +86 -0
- package/dist/admin/shell/media-list.d.ts +8 -0
- package/dist/admin/shell/media-list.js +14 -0
- package/dist/admin/ui/Modal.d.ts +8 -0
- package/dist/admin/ui/Modal.js +15 -0
- package/dist/admin/ui/primitives.d.ts +1 -1
- package/dist/admin/ui/primitives.js +3 -3
- package/dist/admin/ui/theme.d.ts +1 -1
- package/dist/admin/ui/theme.js +107 -24
- package/dist/admin/validate.js +6 -0
- package/dist/asset-store.d.ts +86 -0
- package/dist/asset-store.js +141 -0
- package/dist/collections.js +2 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +17 -5
- package/dist/fields.d.ts +8 -10
- package/dist/fields.js +14 -1
- package/dist/image/SmoodlyImage.d.ts +18 -0
- package/dist/image/SmoodlyImage.js +16 -0
- package/dist/image/SmoodlyVideo.d.ts +7 -0
- package/dist/image/SmoodlyVideo.js +5 -0
- package/dist/image/crop-math.d.ts +14 -0
- package/dist/image/crop-math.js +38 -0
- package/dist/image/index.d.ts +6 -0
- package/dist/image/index.js +5 -0
- package/dist/image/loaders.d.ts +15 -0
- package/dist/image/loaders.js +5 -0
- package/dist/image/next-config.d.ts +15 -0
- package/dist/image/next-config.js +27 -0
- package/dist/image-dimensions.d.ts +4 -0
- package/dist/image-dimensions.js +25 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.js +5 -1
- package/dist/media.d.ts +108 -0
- package/dist/media.js +129 -0
- package/dist/refs.d.ts +2 -0
- package/dist/refs.js +25 -4
- package/dist/resolve.d.ts +19 -4
- package/dist/resolve.js +55 -2
- package/dist/site.d.ts +2 -0
- package/dist/site.js +21 -3
- package/dist/sql-space.d.ts +8 -1
- package/dist/sql-space.js +28 -0
- package/dist/sql.js +30 -0
- package/dist/store.js +3 -0
- package/dist/supabase-asset-store.d.ts +21 -0
- package/dist/supabase-asset-store.js +170 -0
- package/package.json +9 -1
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AdminOps, OpResult } from "../ops.ts";
|
|
2
|
+
import { type AssetRecord } from "../../media.ts";
|
|
3
|
+
export type Measured = {
|
|
4
|
+
width?: number;
|
|
5
|
+
height?: number;
|
|
6
|
+
duration?: number;
|
|
7
|
+
poster?: Blob;
|
|
8
|
+
};
|
|
9
|
+
export type UploadDeps = {
|
|
10
|
+
put(url: string, file: Blob, mime: string): Promise<void>;
|
|
11
|
+
measure(file: Blob, mime: string): Promise<Measured>;
|
|
12
|
+
};
|
|
13
|
+
export declare function uploadFile(ops: AdminOps["assets"], file: File, deps: UploadDeps): Promise<OpResult<AssetRecord>>;
|
|
14
|
+
/** A raw PUT to the signed URL: the body is the file, the type its mime,
|
|
15
|
+
* and the year-long cache-control Storage serves back (without the
|
|
16
|
+
* header a signed upload lands as `no-cache`). */
|
|
17
|
+
export declare function putSigned(url: string, file: Blob, mime: string): Promise<void>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { MEDIA_CACHE_SECONDS } from "../../media.js";
|
|
2
|
+
const stem = (name) => name.replace(/\.[^.]+$/, "");
|
|
3
|
+
export async function uploadFile(ops, file, deps) {
|
|
4
|
+
const begun = await ops.beginUpload({ name: file.name, mime: file.type, size: file.size });
|
|
5
|
+
if (!begun.ok)
|
|
6
|
+
return begun;
|
|
7
|
+
try {
|
|
8
|
+
await deps.put(begun.data.uploadUrl, file, file.type);
|
|
9
|
+
}
|
|
10
|
+
catch (e) {
|
|
11
|
+
return { ok: false, code: "internal", message: `smoodly: upload failed — ${e instanceof Error ? e.message : String(e)}` };
|
|
12
|
+
}
|
|
13
|
+
const m = await deps.measure(file, file.type);
|
|
14
|
+
let posterId;
|
|
15
|
+
if (m.poster) {
|
|
16
|
+
const poster = await uploadFile(ops, new File([m.poster], `${stem(file.name)}-poster.jpg`, { type: "image/jpeg" }), deps);
|
|
17
|
+
if (poster.ok)
|
|
18
|
+
posterId = poster.data.id;
|
|
19
|
+
}
|
|
20
|
+
return ops.finishUpload(begun.data.id, { width: m.width, height: m.height, duration: m.duration, posterId });
|
|
21
|
+
}
|
|
22
|
+
/** A raw PUT to the signed URL: the body is the file, the type its mime,
|
|
23
|
+
* and the year-long cache-control Storage serves back (without the
|
|
24
|
+
* header a signed upload lands as `no-cache`). */
|
|
25
|
+
export async function putSigned(url, file, mime) {
|
|
26
|
+
const res = await fetch(url, {
|
|
27
|
+
method: "PUT",
|
|
28
|
+
headers: { "content-type": mime, "x-upsert": "false", "cache-control": `max-age=${MEDIA_CACHE_SECONDS}` },
|
|
29
|
+
body: file,
|
|
30
|
+
});
|
|
31
|
+
if (!res.ok)
|
|
32
|
+
throw new Error(`${res.status} ${(await res.text()).slice(0, 200)}`);
|
|
33
|
+
}
|
package/dist/admin/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult, SharedListItem, SharedGetResult } from "./ops.ts";
|
|
1
|
+
export type { AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult, SharedListItem, SharedGetResult, AssetUsageItem } from "./ops.ts";
|
|
2
2
|
export { createAdminOps, runAdminOp, type AdminEffects } from "./ops-impl.ts";
|
|
3
3
|
export { fixedNodeSpecs, fixedNodeOf, ensureFixedNodes, type FixedNode } from "./fixed-nodes.ts";
|
|
4
4
|
export { ensureSharedItems, initialSharedFields } from "./shared-items.ts";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ResolvedConfig } from "../../config.ts";
|
|
2
2
|
import type { EntryStore } from "../../entry-store.ts";
|
|
3
3
|
import type { PageStore } from "../../store.ts";
|
|
4
|
+
import type { AssetStore } from "../../asset-store.ts";
|
|
4
5
|
import { type AdminAuth } from "../auth.ts";
|
|
5
6
|
import type { AdminOpAction } from "../ops.ts";
|
|
6
7
|
export declare function createAdminOpHandler(deps: {
|
|
@@ -9,4 +10,6 @@ export declare function createAdminOpHandler(deps: {
|
|
|
9
10
|
entries: EntryStore;
|
|
10
11
|
auth: AdminAuth;
|
|
11
12
|
href?: (path: string, locale: string) => string;
|
|
13
|
+
/** The media library; without it the assets ops answer "no media store". */
|
|
14
|
+
assets?: AssetStore;
|
|
12
15
|
}): AdminOpAction;
|
|
@@ -12,6 +12,7 @@ export function createAdminOpHandler(deps) {
|
|
|
12
12
|
pages: deps.pages,
|
|
13
13
|
entries: deps.entries,
|
|
14
14
|
href: deps.href,
|
|
15
|
+
assets: deps.assets,
|
|
15
16
|
effects: {
|
|
16
17
|
// updateTag, not revalidateTag(tag, profile): the profile form is
|
|
17
18
|
// stale-while-revalidate, so the first request after publish still
|
package/dist/admin/ops-impl.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ResolvedConfig } from "../config.ts";
|
|
2
2
|
import type { EntryStore } from "../entry-store.ts";
|
|
3
|
+
import type { AssetStore } from "../asset-store.ts";
|
|
3
4
|
import type { PageStore } from "../store.ts";
|
|
4
5
|
import type { AdminOpCall, AdminOps, OpResult } from "./ops.ts";
|
|
5
6
|
export type AdminEffects = {
|
|
@@ -17,6 +18,8 @@ export declare function createAdminOps(deps: {
|
|
|
17
18
|
effects?: AdminEffects;
|
|
18
19
|
/** Locale-relative CMS path → site URL (the renderer's seam, applied here for the admin). Default: identity. */
|
|
19
20
|
href?: (path: string, locale: string) => string;
|
|
21
|
+
/** The media library; absent = every assets op answers "no media store". */
|
|
22
|
+
assets?: AssetStore;
|
|
20
23
|
}): AdminOps;
|
|
21
24
|
/** Path-dispatch for the one-server-action seam. Own-property checks only —
|
|
22
25
|
* "constructor.constructor" and prototype paths must not resolve. */
|
package/dist/admin/ops-impl.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { entryFieldsIn } from "../entry-store.js";
|
|
1
|
+
import { entryFieldsIn, entryTitleIn } from "../entry-store.js";
|
|
2
|
+
import { ASSET_COLLECTION } from "../media.js";
|
|
3
|
+
import { BASE } from "./shell/base.js";
|
|
2
4
|
import { affectedTargets, entryTag, pageTag, sharedTag } from "../revalidate.js";
|
|
3
5
|
import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
|
|
4
6
|
import { ensureSharedItems } from "./shared-items.js";
|
|
@@ -76,8 +78,10 @@ const classify = (e) => {
|
|
|
76
78
|
return err("validation", message);
|
|
77
79
|
if (message.includes("not a valid slug"))
|
|
78
80
|
return err("validation", message);
|
|
79
|
-
if (message.includes("no page") || message.includes("no entry") || message.includes("no collection"))
|
|
81
|
+
if (message.includes("no page") || message.includes("no entry") || message.includes("no collection") || message.includes("no asset"))
|
|
80
82
|
return err("not_found", message);
|
|
83
|
+
if (message.includes("not pending"))
|
|
84
|
+
return err("conflict", message);
|
|
81
85
|
return err("internal", message);
|
|
82
86
|
};
|
|
83
87
|
export function createAdminOps(deps) {
|
|
@@ -147,6 +151,29 @@ export function createAdminOps(deps) {
|
|
|
147
151
|
const def = config.locales.default;
|
|
148
152
|
/** The name messages use for a node: its default-locale slug, else any locale's, else the id. */
|
|
149
153
|
const nameOf = (r) => r.locales[def]?.slug ?? Object.values(r.locales)[0]?.slug ?? r.id;
|
|
154
|
+
const assets = deps.assets;
|
|
155
|
+
const noMedia = () => err("internal", "smoodly: no media store is configured — pass `assets` to createAdminOpHandler.");
|
|
156
|
+
const mb = (bytes) => `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MB`;
|
|
157
|
+
const isDim = (v) => v === undefined || (typeof v === "number" && Number.isFinite(v) && v >= 0);
|
|
158
|
+
/** What an asset's usage row points at, labelled for the Media screen. */
|
|
159
|
+
const describeSource = async (sourceKind, sourceId) => {
|
|
160
|
+
if (sourceKind === "page") {
|
|
161
|
+
const record = await pages.getPage(sourceId);
|
|
162
|
+
if (!record)
|
|
163
|
+
return { title: sourceId, href: null };
|
|
164
|
+
return { title: record.locales[def]?.title ?? Object.values(record.locales)[0]?.title ?? sourceId, href: `${BASE}/pages/${sourceId}` };
|
|
165
|
+
}
|
|
166
|
+
for (const item of config.registry.shared ?? []) {
|
|
167
|
+
if (await entries.get(item.name, sourceId))
|
|
168
|
+
return { title: item.title, href: `${BASE}/shared/${encodeURIComponent(item.name)}` };
|
|
169
|
+
}
|
|
170
|
+
for (const c of config.registry.collections ?? []) {
|
|
171
|
+
const record = await entries.get(c.name, sourceId);
|
|
172
|
+
if (record)
|
|
173
|
+
return { title: entryTitleIn(record, def, c), href: `${BASE}/${c.name}/${sourceId}` };
|
|
174
|
+
}
|
|
175
|
+
return { title: sourceId, href: null };
|
|
176
|
+
};
|
|
150
177
|
const owned = (record, verb) => err("blocked", `smoodly: "${nameOf(record)}" is owned by code — ${verb}.`);
|
|
151
178
|
/** The record serving "/" — guarded whether or not a registration claims
|
|
152
179
|
* its slug: the config's homePageSlug is what routes it. Judged by the
|
|
@@ -487,6 +514,60 @@ export function createAdminOps(deps) {
|
|
|
487
514
|
return ok(changed);
|
|
488
515
|
}),
|
|
489
516
|
},
|
|
517
|
+
assets: {
|
|
518
|
+
list: (query) => guard(async () => (assets ? ok(await assets.list(query)) : noMedia())),
|
|
519
|
+
get: (id) => guard(async () => {
|
|
520
|
+
if (!assets)
|
|
521
|
+
return noMedia();
|
|
522
|
+
const record = await assets.get(id);
|
|
523
|
+
return record ? ok(record) : err("not_found", `smoodly: no asset "${id}".`);
|
|
524
|
+
}),
|
|
525
|
+
beginUpload: (input) => guard(async () => {
|
|
526
|
+
if (!assets)
|
|
527
|
+
return noMedia();
|
|
528
|
+
if (typeof input?.name !== "string" || typeof input.mime !== "string" || typeof input.size !== "number") {
|
|
529
|
+
return err("validation", "smoodly: an upload needs a name, a mime type and a size.");
|
|
530
|
+
}
|
|
531
|
+
if (!config.media.accept.includes(input.mime))
|
|
532
|
+
return err("validation", `smoodly: "${input.mime}" is not an accepted file type.`);
|
|
533
|
+
if (input.size > config.media.maxSize)
|
|
534
|
+
return err("validation", `smoodly: the file is larger than ${mb(config.media.maxSize)}.`);
|
|
535
|
+
return ok(await assets.beginUpload({ filename: input.name, mime: input.mime, size: input.size }));
|
|
536
|
+
}),
|
|
537
|
+
finishUpload: (id, dims) => guard(async () => {
|
|
538
|
+
if (!assets)
|
|
539
|
+
return noMedia();
|
|
540
|
+
const d = dims ?? {};
|
|
541
|
+
if (!isDim(d.width) || !isDim(d.height) || !isDim(d.duration) || (d.posterId !== undefined && typeof d.posterId !== "string")) {
|
|
542
|
+
return err("validation", "smoodly: malformed dimensions.");
|
|
543
|
+
}
|
|
544
|
+
return ok(await assets.finishUpload(id, d));
|
|
545
|
+
}),
|
|
546
|
+
update: (id, meta) => guard(async () => {
|
|
547
|
+
if (!assets)
|
|
548
|
+
return noMedia();
|
|
549
|
+
const patch = {};
|
|
550
|
+
for (const key of ["title", "alt", "caption", "credit"]) {
|
|
551
|
+
const v = (meta ?? {})[key];
|
|
552
|
+
if (v === undefined)
|
|
553
|
+
continue;
|
|
554
|
+
if (v !== null && typeof v !== "string")
|
|
555
|
+
return err("validation", `smoodly: ${key} must be text.`);
|
|
556
|
+
patch[key] = v;
|
|
557
|
+
}
|
|
558
|
+
return ok(await assets.update(id, patch));
|
|
559
|
+
}),
|
|
560
|
+
delete: (id) => guard(async () => (assets ? (await assets.delete(id), ok(null)) : noMedia())),
|
|
561
|
+
usage: (id) => guard(async () => {
|
|
562
|
+
if (!assets)
|
|
563
|
+
return noMedia();
|
|
564
|
+
const rows = await entries.referencesTo(ASSET_COLLECTION, id);
|
|
565
|
+
const out = [];
|
|
566
|
+
for (const r of rows)
|
|
567
|
+
out.push({ ...r, ...(await describeSource(r.sourceKind, r.sourceId)) });
|
|
568
|
+
return ok(out);
|
|
569
|
+
}),
|
|
570
|
+
},
|
|
490
571
|
preview: {
|
|
491
572
|
enable: () => guard(async () => (await effects?.draft?.enable(), ok(null))),
|
|
492
573
|
disable: () => guard(async () => (await effects?.draft?.disable(), ok(null))),
|
package/dist/admin/ops.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { PageRecord } from "../store.ts";
|
|
2
2
|
import type { PageTree } from "../render.tsx";
|
|
3
3
|
import type { EntryRecord, EntryUsage } from "../entry-store.ts";
|
|
4
|
+
import type { AssetRecord, AssetMeta } from "../media.ts";
|
|
5
|
+
import type { AssetPage, AssetQuery, FinishInput, UploadTicket } from "../asset-store.ts";
|
|
4
6
|
import type { FieldError } from "./validate.ts";
|
|
5
7
|
export type OpError = {
|
|
6
8
|
ok: false;
|
|
@@ -70,6 +72,14 @@ export type SharedGetResult = {
|
|
|
70
72
|
/** Who links it: "used on N pages". */
|
|
71
73
|
usage: EntryUsage[];
|
|
72
74
|
};
|
|
75
|
+
/** One place an asset is used: the source row, the field, a label and an admin link. */
|
|
76
|
+
export type AssetUsageItem = {
|
|
77
|
+
sourceKind: string;
|
|
78
|
+
sourceId: string;
|
|
79
|
+
sourceField: string;
|
|
80
|
+
title: string;
|
|
81
|
+
href: string | null;
|
|
82
|
+
};
|
|
73
83
|
export type AdminOps = {
|
|
74
84
|
pages: {
|
|
75
85
|
/** Lists every node, materializing fixed pages and collection mounts (in every supported locale) first. */
|
|
@@ -126,6 +136,23 @@ export type AdminOps = {
|
|
|
126
136
|
save(name: string, locale: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
|
|
127
137
|
setStatus(name: string, locale: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
|
|
128
138
|
};
|
|
139
|
+
assets: {
|
|
140
|
+
/** Ready assets, newest first, paged by cursor; `kind` and `search` filter. */
|
|
141
|
+
list(query?: AssetQuery): Promise<OpResult<AssetPage>>;
|
|
142
|
+
get(id: string): Promise<OpResult<AssetRecord>>;
|
|
143
|
+
/** Validates type and size against config.media, inserts a pending row, mints the signed upload URL (spec 2026-09-08 §6). */
|
|
144
|
+
beginUpload(input: {
|
|
145
|
+
name: string;
|
|
146
|
+
mime: string;
|
|
147
|
+
size: number;
|
|
148
|
+
}): Promise<OpResult<UploadTicket>>;
|
|
149
|
+
/** Marks the row ready with what the browser measured. */
|
|
150
|
+
finishUpload(id: string, dims: FinishInput): Promise<OpResult<AssetRecord>>;
|
|
151
|
+
update(id: string, meta: AssetMeta): Promise<OpResult<AssetRecord>>;
|
|
152
|
+
/** Blocked while any draft or published version references the asset. */
|
|
153
|
+
delete(id: string): Promise<OpResult<null>>;
|
|
154
|
+
usage(id: string): Promise<OpResult<AssetUsageItem[]>>;
|
|
155
|
+
};
|
|
129
156
|
preview: {
|
|
130
157
|
enable(): Promise<OpResult<null>>;
|
|
131
158
|
disable(): Promise<OpResult<null>>;
|
|
@@ -60,5 +60,10 @@ export type AdminRegistry = {
|
|
|
60
60
|
homePageSlug: string;
|
|
61
61
|
/** How deep editors may nest pages; 1 = flat. */
|
|
62
62
|
pageDepth: number;
|
|
63
|
+
/** Upload limits, so a widget can refuse a file before the round trip. */
|
|
64
|
+
media: {
|
|
65
|
+
maxSize: number;
|
|
66
|
+
accept: string[];
|
|
67
|
+
};
|
|
63
68
|
};
|
|
64
69
|
export declare function serializeAdminConfig(config: ResolvedConfig): AdminRegistry;
|
package/dist/admin/serialize.js
CHANGED
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
import type { AdminOpAction } from "../ops.ts";
|
|
2
2
|
import type { AdminRegistry } from "../serialize.ts";
|
|
3
3
|
import { type AuthConfig } from "./session.ts";
|
|
4
|
-
export declare function Nav({ registry, active, email, onSignOut }: {
|
|
5
|
-
registry: AdminRegistry;
|
|
6
|
-
active: string;
|
|
7
|
-
email?: string;
|
|
8
|
-
onSignOut: () => void;
|
|
9
|
-
}): import("react").JSX.Element;
|
|
10
4
|
export declare function AdminApp({ registry, op, auth }: {
|
|
11
5
|
registry: AdminRegistry;
|
|
12
6
|
op: AdminOpAction;
|
|
@@ -8,10 +8,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
8
8
|
// never reloads (spec 2026-09-08 client routing). The shell mounts from the
|
|
9
9
|
// admin LAYOUT (next/create-admin.tsx), never from the catch-all page.
|
|
10
10
|
//
|
|
11
|
-
// Layout is the design system's three columns:
|
|
12
|
-
// column whose views own their own
|
|
13
|
-
// its
|
|
14
|
-
// inside itself.
|
|
11
|
+
// Layout is the design system's three columns: the 72px icon rail
|
|
12
|
+
// (shell/IconRail.tsx), then the main column whose views own their own
|
|
13
|
+
// 48px top bar (the editor adds the third, its inspector). The shell is
|
|
14
|
+
// viewport-height; every column scrolls inside itself.
|
|
15
15
|
import { useState } from "react";
|
|
16
16
|
import { makeClientOps } from "../client-ops.js";
|
|
17
17
|
import { PagesList } from "./PagesList.js";
|
|
@@ -22,20 +22,11 @@ import { SharedForm } from "./SharedForm.js";
|
|
|
22
22
|
import { EditorView } from "../editor/EditorView.js";
|
|
23
23
|
import { LoginView } from "./LoginView.js";
|
|
24
24
|
import { useAdminSession } from "./session.js";
|
|
25
|
-
import {
|
|
26
|
-
import { BASE } from "./base.js";
|
|
27
|
-
import { RouteLink } from "./RouteLink.js";
|
|
25
|
+
import { IconRail } from "./IconRail.js";
|
|
28
26
|
import { useRoute } from "./router.js";
|
|
29
27
|
import { ErrorPane } from "./ErrorPane.js";
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const PLANNED = ["Media", "Settings"];
|
|
33
|
-
export function Nav({ registry, active, email, onSignOut }) {
|
|
34
|
-
const item = (href, label, key) => href ? (_jsx(RouteLink, { className: "sm-nav-item", href: href, "aria-current": key === active ? "page" : undefined, children: label }, key)) : (_jsx("a", { className: "sm-nav-item", "aria-disabled": true, children: label }, key));
|
|
35
|
-
const name = email?.split("@")[0] ?? "Editor";
|
|
36
|
-
const initials = name.slice(0, 2).toUpperCase();
|
|
37
|
-
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" })] })] }));
|
|
38
|
-
}
|
|
28
|
+
import { MediaScreen } from "./MediaScreen.js";
|
|
29
|
+
import { AssetsProvider } from "../forms/assets-context.js";
|
|
39
30
|
export function AdminApp({ registry, op, auth }) {
|
|
40
31
|
const session = useAdminSession(auth);
|
|
41
32
|
const [ops] = useState(() => makeClientOps(op, session.token));
|
|
@@ -61,7 +52,9 @@ export function AdminApp({ registry, op, auth }) {
|
|
|
61
52
|
main = _jsx(SharedList, { ops: ops, registry: registry });
|
|
62
53
|
else if (head === "shared" && tail)
|
|
63
54
|
main = _jsx(SharedForm, { ops: ops, registry: registry, name: decodeURIComponent(tail) });
|
|
55
|
+
else if (head === "media")
|
|
56
|
+
main = _jsx(MediaScreen, {});
|
|
64
57
|
else
|
|
65
58
|
main = _jsx(ErrorPane, { message: "Not found." });
|
|
66
|
-
return (_jsxs("div", { className: "sm-shell", children: [_jsx(
|
|
59
|
+
return (_jsx(AssetsProvider, { ops: ops, media: registry.media, children: _jsxs("div", { className: "sm-shell", children: [_jsx(IconRail, { registry: registry, active: head, email: email, onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }) }));
|
|
67
60
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// packages/smoodly/src/admin/shell/IconRail.tsx
|
|
4
|
+
// The admin's primary navigation: the design system's 72px IconRail
|
|
5
|
+
// (design_handoff_smoodly_cms/components/navigation/IconRail), which
|
|
6
|
+
// replaced the 200px SideNav so the visual editor gets the width. Ported
|
|
7
|
+
// markup-for-markup; the handoff's click handlers are the shell's router.
|
|
8
|
+
//
|
|
9
|
+
// Items are registry-derived, never separately configured: Pages, then
|
|
10
|
+
// Collections — one rail item whose flyout lists every collection — then
|
|
11
|
+
// Shared when the registry has shared items, Media, and Settings, shown
|
|
12
|
+
// but inert until it exists. The user avatar is pinned to the bottom and
|
|
13
|
+
// opens the same flyout with the theme toggle and Sign out, which the
|
|
14
|
+
// old nav's footer held.
|
|
15
|
+
//
|
|
16
|
+
// Flyouts: click toggles, Esc closes and returns focus to the trigger,
|
|
17
|
+
// ↑/↓ move between entries, a pointer down outside closes. A child being
|
|
18
|
+
// active highlights its parent tile and the entry inside the menu.
|
|
19
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
20
|
+
import { ChevronDown, Image, LayoutPanelTop, Library, Settings2, Square } from "lucide-react";
|
|
21
|
+
import { ThemeToggle } from "../ui/primitives.js";
|
|
22
|
+
import { BASE } from "./base.js";
|
|
23
|
+
import { RouteLink } from "./RouteLink.js";
|
|
24
|
+
/** Lucide at the rail's size and stroke (the tile's CSS restates both). */
|
|
25
|
+
const ICON = { size: 18, strokeWidth: 1.75, "aria-hidden": true };
|
|
26
|
+
/** The app icon, inlined: a package can't ship a file into the host app's
|
|
27
|
+
* /public. Brand blue on white in both themes, so no --logo-filter. */
|
|
28
|
+
function AppIcon() {
|
|
29
|
+
return (_jsxs("svg", { className: "sm-rail__logo", viewBox: "0 0 89 86", fill: "none", xmlns: "http://www.w3.org/2000/svg", role: "img", "aria-label": "Smoodly", children: [_jsx("path", { fill: "#0038B1", d: "M0 51.5552C0 24.3141 25.0505 0 52.5493 0H52.7761C74.4707 0 89 14.6336 89 34.445C89 61.6862 64.4034 86 36.0066 86C14.0946 86 0 70.9165 0 51.5552Z" }), _jsx("path", { fill: "#FFFFFF", d: "M23.25 46.1188C23.25 33.0027 34.8806 21.2959 47.6479 21.2959H47.7532C57.8257 21.2959 64.5714 28.3417 64.5714 37.8805C64.5714 50.9966 53.1516 62.7033 39.9674 62.7033C29.7939 62.7033 23.25 55.4409 23.25 46.1188Z" })] }));
|
|
30
|
+
}
|
|
31
|
+
export function IconRail({ registry, active, email, onSignOut }) {
|
|
32
|
+
const root = useRef(null);
|
|
33
|
+
const uid = useId();
|
|
34
|
+
const [open, setOpen] = useState(null);
|
|
35
|
+
const menuId = (key) => `sm-rail-menu${uid}${key}`;
|
|
36
|
+
const toggle = (key) => setOpen((cur) => (cur === key ? null : key));
|
|
37
|
+
const close = () => setOpen(null);
|
|
38
|
+
// Opening moves focus into the menu; a pointer down outside closes it.
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!open || !root.current)
|
|
41
|
+
return;
|
|
42
|
+
root.current.querySelector(`#${CSS.escape(menuId(open))} [role=menuitem]`)?.focus();
|
|
43
|
+
const onPointerDown = (e) => {
|
|
44
|
+
if (e.target instanceof Node && !root.current?.contains(e.target))
|
|
45
|
+
close();
|
|
46
|
+
};
|
|
47
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
48
|
+
return () => document.removeEventListener("pointerdown", onPointerDown);
|
|
49
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
50
|
+
}, [open]);
|
|
51
|
+
const onKeyDown = (e) => {
|
|
52
|
+
if (!open || !root.current)
|
|
53
|
+
return;
|
|
54
|
+
if (e.key === "Escape") {
|
|
55
|
+
close();
|
|
56
|
+
root.current.querySelector(`[aria-controls="${menuId(open)}"]`)?.focus();
|
|
57
|
+
}
|
|
58
|
+
else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
59
|
+
const items = Array.from(root.current.querySelectorAll(`#${CSS.escape(menuId(open))} [role=menuitem]`));
|
|
60
|
+
if (!items.length)
|
|
61
|
+
return;
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
const i = items.indexOf(document.activeElement);
|
|
64
|
+
items[(i + (e.key === "ArrowDown" ? 1 : -1) + items.length) % items.length].focus();
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const link = (key, label, icon, href) => (_jsx("li", { className: "sm-rail__item", children: href ? (_jsxs(RouteLink, { className: "sm-rail__btn", href: href, "aria-current": key === active ? "page" : undefined, onClick: close, children: [_jsx("span", { className: "sm-rail__tile", children: icon }), _jsx("span", { className: "sm-rail__label", children: label })] })) : (_jsxs("a", { className: "sm-rail__btn", "aria-disabled": true, children: [_jsx("span", { className: "sm-rail__tile", children: icon }), _jsx("span", { className: "sm-rail__label", children: label })] })) }));
|
|
68
|
+
const collections = registry.collections;
|
|
69
|
+
const collectionActive = collections.some((c) => c.name === active);
|
|
70
|
+
const name = email?.split("@")[0] ?? "Editor";
|
|
71
|
+
const initials = name.slice(0, 2).toUpperCase();
|
|
72
|
+
return (_jsxs("nav", { className: "sm-rail", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsx(AppIcon, {}), _jsxs("ul", { className: "sm-rail__list", children: [link("pages", "Pages", _jsx(Square, { ...ICON }), `${BASE}/pages`), collections.length > 0 && (_jsxs("li", { className: "sm-rail__item", children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": open === "collections", "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsxs("span", { className: "sm-rail__tile", children: [_jsx(Library, { ...ICON }), _jsx("span", { className: "sm-rail__chev", children: _jsx(ChevronDown, { ...ICON, size: 8, strokeWidth: 3 }) })] }), _jsx("span", { className: "sm-rail__label", children: "Collections" })] }), _jsxs("ul", { className: "sm-rail__menu", role: "menu", id: menuId("collections"), "data-open": open === "collections", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: "Collections" }), collections.map((c) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-rail__menu-item", href: `${BASE}/${c.name}`, "aria-current": c.name === active ? "page" : undefined, onClick: close, children: _jsx("span", { children: c.title }) }) }, c.name)))] })] })), registry.shared.length > 0 && link("shared", "Shared", _jsx(LayoutPanelTop, { ...ICON }), `${BASE}/shared`), link("media", "Media", _jsx(Image, { ...ICON }), `${BASE}/media`), link("settings", "Settings", _jsx(Settings2, { ...ICON }), null)] }), _jsxs("div", { className: "sm-rail__item sm-rail__foot", children: [_jsx("button", { type: "button", className: "sm-rail__user", title: name, "aria-haspopup": "menu", "aria-expanded": open === "user", "aria-controls": menuId("user"), onClick: () => toggle("user"), children: initials }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: menuId("user"), "data-open": open === "user", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: name }), _jsx("li", { role: "none", children: _jsx(ThemeToggle, {}) }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: () => { close(); onSignOut(); }, children: "Sign out" }) })] })] })] }));
|
|
73
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
|
+
import type { AssetRecord } from "../../media.ts";
|
|
3
|
+
import type { AssetUsageItem } from "../ops.ts";
|
|
4
|
+
export declare function MediaScreen({ initialItems, initialSelected, initialUsage }: {
|
|
5
|
+
initialItems?: AssetRecord[];
|
|
6
|
+
initialSelected?: AssetRecord | null;
|
|
7
|
+
initialUsage?: AssetUsageItem[];
|
|
8
|
+
}): ReactElement;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// The Media screen (spec 2026-09-08 media §10): the library grid full
|
|
4
|
+
// page with search, a kind filter and upload; an inspector on selection
|
|
5
|
+
// with the asset's metadata (saved on blur), its facts and where it is
|
|
6
|
+
// used. Delete stays disabled with the reason while anything uses it.
|
|
7
|
+
import { useCallback, useEffect, useState } from "react";
|
|
8
|
+
import { useAssets } from "../forms/assets-context.js";
|
|
9
|
+
import { uploadFile } from "../forms/upload.js";
|
|
10
|
+
import { MediaGrid } from "../forms/MediaGrid.js";
|
|
11
|
+
import { Banner, Button, Field, Segmented, TopBar } from "../ui/primitives.js";
|
|
12
|
+
import { RouteLink } from "./RouteLink.js";
|
|
13
|
+
import { KIND_FILTERS, acceptFor, formatBytes, kindOfFilter, usageLabel } from "./media-list.js";
|
|
14
|
+
const META = ["title", "alt", "caption", "credit"];
|
|
15
|
+
export function MediaScreen({ initialItems = [], initialSelected = null, initialUsage = [] }) {
|
|
16
|
+
const assets = useAssets();
|
|
17
|
+
const [items, setItems] = useState(initialItems);
|
|
18
|
+
const [search, setSearch] = useState("");
|
|
19
|
+
const [filter, setFilter] = useState("All");
|
|
20
|
+
const [selected, setSelected] = useState(initialSelected);
|
|
21
|
+
const [usage, setUsage] = useState(initialUsage);
|
|
22
|
+
const [meta, setMeta] = useState({
|
|
23
|
+
title: initialSelected?.title ?? "", alt: initialSelected?.alt ?? "", caption: initialSelected?.caption ?? "", credit: initialSelected?.credit ?? "",
|
|
24
|
+
});
|
|
25
|
+
const [busy, setBusy] = useState(false);
|
|
26
|
+
const [error, setError] = useState(null);
|
|
27
|
+
const load = useCallback(() => {
|
|
28
|
+
assets.ops.list({ kind: kindOfFilter(filter), search: search || undefined })
|
|
29
|
+
.then((r) => (r.ok ? setItems(r.data.items) : setError(r.message)))
|
|
30
|
+
.catch(() => setError("Couldn't reach the server."));
|
|
31
|
+
}, [assets, filter, search]);
|
|
32
|
+
useEffect(load, [load]);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!selected)
|
|
35
|
+
return;
|
|
36
|
+
setMeta({ title: selected.title ?? "", alt: selected.alt ?? "", caption: selected.caption ?? "", credit: selected.credit ?? "" });
|
|
37
|
+
if (initialSelected?.id === selected.id)
|
|
38
|
+
return;
|
|
39
|
+
assets.ops.usage(selected.id).then((r) => setUsage(r.ok ? r.data : [])).catch(() => setUsage([]));
|
|
40
|
+
}, [selected, assets, initialSelected]);
|
|
41
|
+
const saveMeta = async (key) => {
|
|
42
|
+
if (!selected)
|
|
43
|
+
return;
|
|
44
|
+
const next = meta[key].trim() || null;
|
|
45
|
+
if ((selected[key] ?? null) === next)
|
|
46
|
+
return;
|
|
47
|
+
const r = await assets.ops.update(selected.id, { [key]: next });
|
|
48
|
+
if (!r.ok) {
|
|
49
|
+
setError(r.message);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
assets.remember(r.data);
|
|
53
|
+
setSelected(r.data);
|
|
54
|
+
setItems((all) => all.map((a) => (a.id === r.data.id ? r.data : a)));
|
|
55
|
+
};
|
|
56
|
+
const onFiles = async (files) => {
|
|
57
|
+
const file = files?.[0];
|
|
58
|
+
if (!file)
|
|
59
|
+
return;
|
|
60
|
+
setBusy(true);
|
|
61
|
+
setError(null);
|
|
62
|
+
const r = await uploadFile(assets.ops, file, assets.upload);
|
|
63
|
+
setBusy(false);
|
|
64
|
+
if (!r.ok) {
|
|
65
|
+
setError(r.message);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
assets.remember(r.data);
|
|
69
|
+
setSelected(r.data);
|
|
70
|
+
load();
|
|
71
|
+
};
|
|
72
|
+
const remove = async () => {
|
|
73
|
+
if (!selected || !window.confirm(`Delete "${selected.title ?? selected.filename}"? This cannot be undone.`))
|
|
74
|
+
return;
|
|
75
|
+
const r = await assets.ops.delete(selected.id);
|
|
76
|
+
if (!r.ok) {
|
|
77
|
+
setError(r.message);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
setSelected(null);
|
|
81
|
+
load();
|
|
82
|
+
};
|
|
83
|
+
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx("span", { className: "sm-title", children: "Media" }), _jsx("span", { className: "sm-count", children: items.length })] }), right: _jsxs("div", { className: "sm-picker-bar", children: [_jsx("input", { className: "sm-input", placeholder: "Search media", value: search, onChange: (e) => setSearch(e.target.value), style: { width: 220 } }), _jsx(Segmented, { options: KIND_FILTERS, value: filter, onChange: setFilter }), _jsxs("label", { className: "sm-btn sm-btn--primary", children: [busy ? "Uploading…" : "Upload", _jsx("input", { type: "file", accept: acceptFor(["image", "video", "file"], assets.media.accept), hidden: true, disabled: busy, onChange: (e) => { void onFiles(e.target.files); } })] })] }) }), error && _jsx(Banner, { tone: "error", onDismiss: () => setError(null), children: error }), _jsxs("div", { className: "sm-mediascreen", style: selected ? undefined : { gridTemplateColumns: "1fr" }, children: [_jsx("div", { className: "sm-mediascreen__grid", children: _jsx(MediaGrid, { items: items, selectedId: selected?.id, onSelect: (a) => setSelected(a.id === selected?.id ? null : a) }) }), selected && (_jsxs("aside", { className: "sm-mediascreen__inspector", children: [_jsx("div", { className: "sm-media__thumb", style: { aspectRatio: selected.width && selected.height ? `${selected.width} / ${selected.height}` : "4 / 3" }, children: selected.kind === "image" && _jsx("img", { src: selected.url, alt: "", style: { left: 0, top: 0, width: "100%", height: "100%" } }) }), META.map((key) => (_jsx(Field, { label: key === "alt" ? "Alt text" : key[0].toUpperCase() + key.slice(1), children: _jsx("input", { className: "sm-input", value: meta[key], onChange: (e) => setMeta((m) => ({ ...m, [key]: e.target.value })), onBlur: () => { void saveMeta(key); } }) }, key))), _jsxs("div", { className: "sm-facts", children: [_jsx("span", { children: "File" }), _jsx("span", { children: selected.filename }), _jsx("span", { children: "Type" }), _jsx("span", { children: selected.mime }), _jsx("span", { children: "Size" }), _jsx("span", { children: formatBytes(selected.size) }), selected.width && selected.height && _jsxs(_Fragment, { children: [_jsx("span", { children: "Dimensions" }), _jsxs("span", { children: [selected.width, "\u00D7", selected.height] })] }), selected.duration !== null && _jsxs(_Fragment, { children: [_jsx("span", { children: "Duration" }), _jsxs("span", { children: [Math.round(selected.duration), " s"] })] })] }), _jsxs("div", { className: "sm-usage", children: [_jsx("span", { className: "sm-label", children: usageLabel(usage.length) }), usage.map((u, i) => u.href
|
|
84
|
+
? _jsxs(RouteLink, { href: u.href, children: [u.title, " \u00B7 ", u.sourceField] }, `${u.sourceId}-${i}`)
|
|
85
|
+
: _jsxs("span", { children: [u.title, " \u00B7 ", u.sourceField] }, `${u.sourceId}-${i}`))] }), _jsx(Button, { variant: "secondary", disabled: usage.length > 0, title: usage.length > 0 ? "Remove it from those places first." : undefined, onClick: () => { void remove(); }, children: "Delete" })] }))] })] }));
|
|
86
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AssetKind } from "../../media.ts";
|
|
2
|
+
export declare const KIND_FILTERS: readonly ["All", "Images", "Video", "Files"];
|
|
3
|
+
export type KindFilter = (typeof KIND_FILTERS)[number];
|
|
4
|
+
export declare const kindOfFilter: (f: KindFilter) => AssetKind | undefined;
|
|
5
|
+
export declare function formatBytes(n: number): string;
|
|
6
|
+
export declare const usageLabel: (n: number) => string;
|
|
7
|
+
/** The `accept` attribute for a field: the registry's list narrowed to the field's kinds. */
|
|
8
|
+
export declare function acceptFor(kinds: AssetKind[], accept: string[]): string;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const KIND_FILTERS = ["All", "Images", "Video", "Files"];
|
|
2
|
+
export const kindOfFilter = (f) => f === "Images" ? "image" : f === "Video" ? "video" : f === "Files" ? "file" : undefined;
|
|
3
|
+
export function formatBytes(n) {
|
|
4
|
+
if (n < 1024)
|
|
5
|
+
return `${n} B`;
|
|
6
|
+
if (n < 1024 * 1024)
|
|
7
|
+
return `${Math.round(n / 1024)} KB`;
|
|
8
|
+
return `${Math.round((n / (1024 * 1024)) * 10) / 10} MB`;
|
|
9
|
+
}
|
|
10
|
+
export const usageLabel = (n) => (n === 0 ? "Not used" : n === 1 ? "Used in 1 place" : `Used in ${n} places`);
|
|
11
|
+
/** The `accept` attribute for a field: the registry's list narrowed to the field's kinds. */
|
|
12
|
+
export function acceptFor(kinds, accept) {
|
|
13
|
+
return accept.filter((m) => kinds.some((k) => (k === "file" ? !m.startsWith("image/") && !m.startsWith("video/") : m.startsWith(`${k}/`)))).join(",");
|
|
14
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// The one modal: a fixed overlay (click outside or Escape closes) and a
|
|
4
|
+
// centred panel with a title row. Same overlay shape as InsertPicker.
|
|
5
|
+
import { useEffect } from "react";
|
|
6
|
+
import { IconButton } from "./primitives.js";
|
|
7
|
+
export function Modal({ title, width = 720, onClose, children, footer }) {
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const onKey = (e) => { if (e.key === "Escape")
|
|
10
|
+
onClose(); };
|
|
11
|
+
window.addEventListener("keydown", onKey);
|
|
12
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
13
|
+
}, [onClose]);
|
|
14
|
+
return (_jsx("div", { className: "sm-modal", onClick: onClose, children: _jsxs("div", { className: "sm-modal__panel", role: "dialog", "aria-label": title, style: { width }, onClick: (e) => e.stopPropagation(), children: [_jsxs("div", { className: "sm-modal__head", children: [_jsx("span", { className: "sm-title", children: title }), _jsx(IconButton, { label: "Close", onClick: onClose, children: "\u00D7" })] }), _jsx("div", { className: "sm-modal__body", children: children }), footer && _jsx("div", { className: "sm-modal__foot", children: footer })] }) }));
|
|
15
|
+
}
|
|
@@ -5,7 +5,7 @@ import type { SaveState } from "../editor/autosave.ts";
|
|
|
5
5
|
export declare function Logo({ height }: {
|
|
6
6
|
height?: number;
|
|
7
7
|
}): import("react").JSX.Element;
|
|
8
|
-
/** Light ⇄ dark, remembered in localStorage.
|
|
8
|
+
/** Light ⇄ dark, remembered in localStorage; an entry in the rail's user menu.
|
|
9
9
|
* The attribute goes on the ADMIN wrapper, never the host <html>: a customer's
|
|
10
10
|
* site may own `data-theme` there. Which half of the button shows is decided by
|
|
11
11
|
* --when-light/--when-dark in the same token block as the colors, so the icon
|