smoodly 0.0.51 → 0.0.53
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 +14 -13
- package/dist/admin/auth.d.ts +3 -11
- package/dist/admin/auth.js +1 -30
- package/dist/admin/client-ops.d.ts +5 -4
- package/dist/admin/client-ops.js +15 -12
- package/dist/admin/delivery-mode.d.ts +1 -1
- package/dist/admin/delivery-mode.js +2 -2
- package/dist/admin/drift.d.ts +0 -2
- package/dist/admin/drift.js +1 -1
- package/dist/admin/editor/EditorView.d.ts +0 -3
- package/dist/admin/editor/EditorView.js +11 -16
- package/dist/admin/editor/FloatingToolbar.d.ts +0 -1
- package/dist/admin/editor/FloatingToolbar.js +1 -1
- package/dist/admin/editor/SeoSettings.d.ts +0 -2
- package/dist/admin/editor/SeoSettings.js +2 -2
- package/dist/admin/editor/canvas-address.d.ts +8 -0
- package/dist/admin/editor/canvas-address.js +18 -2
- package/dist/admin/editor/canvas-diagnosis.d.ts +2 -0
- package/dist/admin/editor/canvas-diagnosis.js +3 -0
- package/dist/admin/editor/inline-dom.d.ts +0 -4
- package/dist/admin/editor/inline-dom.js +1 -1
- package/dist/admin/editor/preview-grant.d.ts +6 -0
- package/dist/admin/editor/preview-grant.js +25 -0
- package/dist/admin/editor/useCanvasDiagnosis.js +2 -2
- package/dist/admin/files.generated.js +1 -1
- package/dist/admin/fixed-nodes.d.ts +0 -6
- package/dist/admin/fixed-nodes.js +1 -81
- package/dist/admin/forms/CropEditor.d.ts +4 -2
- package/dist/admin/forms/CropEditor.js +58 -5
- package/dist/admin/forms/ImagePreview.d.ts +3 -1
- package/dist/admin/forms/ImagePreview.js +9 -3
- package/dist/admin/forms/MediaGrid.js +2 -1
- package/dist/admin/forms/MediaPicker.js +9 -6
- package/dist/admin/forms/MediaWidget.js +11 -9
- package/dist/admin/forms/assets-context.d.ts +7 -3
- package/dist/admin/forms/assets-context.js +5 -3
- package/dist/admin/forms/crop-editor.d.ts +24 -2
- package/dist/admin/forms/crop-editor.js +36 -2
- package/dist/admin/forms/date-picker.d.ts +0 -1
- package/dist/admin/forms/date-picker.js +1 -1
- package/dist/admin/forms/tabs.d.ts +0 -1
- package/dist/admin/forms/tabs.js +1 -1
- package/dist/admin/forms/upload.d.ts +18 -11
- package/dist/admin/forms/upload.js +62 -24
- package/dist/admin/global-items.d.ts +0 -3
- package/dist/admin/global-items.js +0 -54
- package/dist/admin/handler.d.ts +22 -28
- package/dist/admin/handler.js +146 -47
- package/dist/admin/health.d.ts +0 -4
- package/dist/admin/index.d.ts +6 -9
- package/dist/admin/index.js +3 -6
- package/dist/admin/ops.d.ts +40 -32
- package/dist/admin/page-data.d.ts +14 -6
- package/dist/admin/page-data.js +1 -1
- package/dist/admin/session-flow.d.ts +65 -0
- package/dist/admin/session-flow.js +121 -0
- package/dist/admin/shell/AdminApp.d.ts +8 -3
- package/dist/admin/shell/AdminApp.js +37 -58
- package/dist/admin/shell/ContentHealthPage.js +1 -3
- package/dist/admin/shell/GlobalFields.d.ts +0 -3
- package/dist/admin/shell/GlobalFields.js +1 -1
- package/dist/admin/shell/IconRail.js +10 -8
- package/dist/admin/shell/MediaScreen.js +9 -6
- package/dist/admin/shell/MembersPage.d.ts +1 -2
- package/dist/admin/shell/MembersPage.js +19 -90
- package/dist/admin/shell/SettingsView.d.ts +1 -2
- package/dist/admin/shell/SettingsView.js +2 -2
- package/dist/admin/shell/SignedOutView.d.ts +5 -0
- package/dist/admin/shell/SignedOutView.js +9 -0
- package/dist/admin/shell/console-links.d.ts +5 -0
- package/dist/admin/shell/console-links.js +11 -0
- package/dist/admin/shell/members-list.d.ts +1 -14
- package/dist/admin/shell/members-list.js +2 -23
- package/dist/admin/shell/pages-tree.d.ts +0 -2
- package/dist/admin/shell/pages-tree.js +1 -1
- package/dist/admin/shell/presence-socket.d.ts +10 -0
- package/dist/admin/shell/presence-socket.js +191 -0
- package/dist/admin/shell/presence.d.ts +6 -18
- package/dist/admin/shell/presence.js +4 -11
- package/dist/admin/shell/session.d.ts +19 -12
- package/dist/admin/shell/session.js +114 -47
- package/dist/admin/ui/LocaleSwitcher.d.ts +0 -4
- package/dist/admin/ui/LocaleSwitcher.js +2 -2
- package/dist/admin/ui/primitives.d.ts +4 -14
- package/dist/admin/ui/primitives.js +7 -9
- package/dist/admin/ui/theme.js +13 -2
- package/dist/admin/validate.js +10 -1
- package/dist/api-client.d.ts +26 -0
- package/dist/api-client.js +107 -0
- package/dist/assemble.d.ts +4 -41
- package/dist/assemble.js +5 -303
- package/dist/asset-store.d.ts +28 -30
- package/dist/asset-store.js +26 -121
- package/dist/astro/cache.d.ts +1 -1
- package/dist/astro/components/Head.astro +4 -1
- package/dist/astro/components/Image.astro +12 -17
- package/dist/astro/components/Video.astro +5 -2
- package/dist/astro/head.d.ts +3 -0
- package/dist/astro/head.js +24 -0
- package/dist/astro/index.js +61 -27
- package/dist/astro/locale-paths.d.ts +5 -2
- package/dist/astro/locale-paths.js +10 -2
- package/dist/astro/routes/admin.js +9 -7
- package/dist/astro/runtime.d.ts +3 -2
- package/dist/astro/runtime.js +5 -4
- package/dist/astro/server.d.ts +7 -11
- package/dist/astro/server.js +22 -28
- package/dist/astro/shell.d.ts +3 -3
- package/dist/astro/shell.js +1 -1
- package/dist/astro/static-media-node.d.ts +4 -0
- package/dist/astro/static-media-node.js +25 -0
- package/dist/astro/static-media.d.ts +17 -0
- package/dist/astro/static-media.js +55 -0
- package/dist/astro/static.d.ts +5 -10
- package/dist/astro/static.js +13 -29
- package/dist/canvas-marker.d.ts +14 -0
- package/dist/canvas-marker.js +36 -0
- package/dist/cli/api.d.ts +14 -0
- package/dist/cli/api.js +48 -0
- package/dist/cli/app-env.d.ts +2 -0
- package/dist/cli/app-env.js +20 -0
- package/dist/cli/export.d.ts +16 -2
- package/dist/cli/export.js +52 -35
- package/dist/cli/import.d.ts +7 -11
- package/dist/cli/import.js +50 -54
- package/dist/cli/load-config.d.ts +0 -1
- package/dist/cli/load-config.js +1 -1
- package/dist/cli/main.d.ts +4 -12
- package/dist/cli/main.js +26 -72
- package/dist/cli/push.d.ts +6 -0
- package/dist/cli/push.js +31 -0
- package/dist/collections.d.ts +0 -1
- package/dist/collections.js +1 -1
- package/dist/config.d.ts +20 -5
- package/dist/config.js +18 -1
- package/dist/content/content-api.d.ts +23 -23
- package/dist/content/content-api.js +48 -56
- package/dist/content/mime.d.ts +1 -0
- package/dist/content/mime.js +6 -0
- package/dist/css.js +1 -1
- package/dist/deploy-store.d.ts +19 -0
- package/dist/entry-store.d.ts +8 -94
- package/dist/entry-store.js +9 -557
- package/dist/env.d.ts +27 -20
- package/dist/env.js +38 -50
- package/dist/fields.d.ts +11 -3
- package/dist/fields.js +33 -2
- package/dist/global-id.d.ts +2 -0
- package/dist/global-id.js +4 -2
- package/dist/image/SmoodlyImage.d.ts +9 -5
- package/dist/image/SmoodlyImage.js +29 -15
- package/dist/image/SmoodlyVideo.d.ts +1 -1
- package/dist/image/SmoodlyVideo.js +3 -2
- package/dist/image/index.d.ts +2 -3
- package/dist/image/index.js +0 -2
- package/dist/image/markup.d.ts +53 -0
- package/dist/image/markup.js +111 -0
- package/dist/index.d.ts +13 -23
- package/dist/index.js +7 -20
- package/dist/media-cut.d.ts +44 -0
- package/dist/media-cut.js +70 -0
- package/dist/media.d.ts +58 -4
- package/dist/media.js +126 -16
- package/dist/member-store.d.ts +2 -50
- package/dist/member-store.js +0 -56
- package/dist/next/admin-route.d.ts +1 -1
- package/dist/next/admin-route.js +5 -3
- package/dist/next/meta.js +2 -3
- package/dist/next/page-renderer.d.ts +2 -2
- package/dist/next/page-renderer.js +22 -11
- package/dist/next/queries.d.ts +2 -2
- package/dist/path-index.d.ts +0 -24
- package/dist/path-index.js +0 -98
- package/dist/paths.d.ts +1 -1
- package/dist/paths.js +1 -1
- package/dist/push.d.ts +30 -0
- package/dist/push.js +52 -0
- package/dist/read-counter.d.ts +2 -0
- package/dist/read-counter.js +6 -0
- package/dist/reads.d.ts +7 -7
- package/dist/reads.js +3 -0
- package/dist/registry-json.d.ts +11 -0
- package/dist/registry-json.js +81 -0
- package/dist/render.d.ts +0 -1
- package/dist/render.js +1 -1
- package/dist/resolve.d.ts +12 -6
- package/dist/resolve.js +11 -8
- package/dist/revalidate.d.ts +8 -1
- package/dist/revalidate.js +8 -1
- package/dist/richtext/index.d.ts +1 -1
- package/dist/section-wrapper.d.ts +1 -1
- package/dist/section-wrapper.js +0 -9
- package/dist/signed-message.d.ts +26 -0
- package/dist/signed-message.js +103 -0
- package/dist/site.d.ts +59 -18
- package/dist/site.js +21 -252
- package/dist/space-transfer.d.ts +6 -3
- package/dist/space-transfer.js +18 -6
- package/dist/store.d.ts +2 -80
- package/dist/store.js +3 -489
- package/dist/toolset.d.ts +2 -1
- package/dist/toolset.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -15
- package/dist/admin/auth-config.d.ts +0 -6
- package/dist/admin/browser/fetch-op.d.ts +0 -10
- package/dist/admin/browser/fetch-op.js +0 -44
- package/dist/admin/browser/main.d.ts +0 -1
- package/dist/admin/browser/main.js +0 -23
- package/dist/admin/ops-impl.d.ts +0 -51
- package/dist/admin/ops-impl.js +0 -1403
- package/dist/admin/shell/InviteModal.d.ts +0 -6
- package/dist/admin/shell/InviteModal.js +0 -28
- package/dist/admin/shell/LoginView.d.ts +0 -5
- package/dist/admin/shell/LoginView.js +0 -80
- package/dist/admin/shell/SchemaNotice.d.ts +0 -4
- package/dist/admin/shell/SchemaNotice.js +0 -4
- package/dist/admin/shell/SetPasswordView.d.ts +0 -7
- package/dist/admin/shell/SetPasswordView.js +0 -41
- package/dist/admin/shell/presence-realtime.d.ts +0 -3
- package/dist/admin/shell/presence-realtime.js +0 -32
- package/dist/admin/supabase-auth.d.ts +0 -7
- package/dist/admin/supabase-auth.js +0 -30
- package/dist/astro/deploy-deps.d.ts +0 -10
- package/dist/astro/deploy-deps.js +0 -8
- package/dist/astro/image.d.ts +0 -27
- package/dist/astro/image.js +0 -29
- package/dist/bind.d.ts +0 -19
- package/dist/bind.js +0 -34
- package/dist/cli/add-admin.d.ts +0 -11
- package/dist/cli/add-admin.js +0 -25
- package/dist/cli/load-collections.d.ts +0 -2
- package/dist/cli/load-collections.js +0 -27
- package/dist/cli/migrate.d.ts +0 -43
- package/dist/cli/migrate.js +0 -138
- package/dist/cli/rebuild.d.ts +0 -23
- package/dist/cli/rebuild.js +0 -22
- package/dist/cli/space-io.d.ts +0 -41
- package/dist/cli/space-io.js +0 -84
- package/dist/client.d.ts +0 -11
- package/dist/client.js +0 -54
- package/dist/current-space.d.ts +0 -4
- package/dist/current-space.js +0 -9
- package/dist/document-stamp.d.ts +0 -7
- package/dist/document-stamp.js +0 -48
- package/dist/image/loaders.d.ts +0 -15
- package/dist/image/loaders.js +0 -5
- package/dist/image/next-config.d.ts +0 -18
- package/dist/image/next-config.js +0 -32
- package/dist/image/remote-pattern.d.ts +0 -1
- package/dist/image/remote-pattern.js +0 -5
- package/dist/migrations.d.ts +0 -37
- package/dist/migrations.js +0 -73
- package/dist/project.d.ts +0 -88
- package/dist/project.js +0 -231
- package/dist/published.d.ts +0 -142
- package/dist/published.js +0 -164
- package/dist/ref-index.d.ts +0 -19
- package/dist/ref-index.js +0 -0
- package/dist/schema-read.d.ts +0 -23
- package/dist/schema-read.js +0 -50
- package/dist/schema-version.d.ts +0 -19
- package/dist/schema-version.js +0 -34
- package/dist/sql-space.d.ts +0 -44
- package/dist/sql-space.js +0 -289
- package/dist/sql.d.ts +0 -15
- package/dist/sql.js +0 -556
- package/dist/supabase-asset-store.d.ts +0 -21
- package/dist/supabase-asset-store.js +0 -170
- package/dist/supabase-entry-store.d.ts +0 -92
- package/dist/supabase-entry-store.js +0 -824
- package/dist/supabase-member-store.d.ts +0 -21
- package/dist/supabase-member-store.js +0 -96
- package/dist/supabase-path-index.d.ts +0 -30
- package/dist/supabase-path-index.js +0 -138
- package/dist/supabase-published-store.d.ts +0 -56
- package/dist/supabase-published-store.js +0 -314
- package/dist/supabase-store.d.ts +0 -103
- package/dist/supabase-store.js +0 -678
- package/migrations/20260926120000_smoodly_v1.sql +0 -535
- package/migrations/20260926130000_smoodly_v2.sql +0 -26
- package/migrations/20260926140000_smoodly_v3.sql +0 -38
- package/migrations/20260926150000_smoodly_v4.sql +0 -41
- package/migrations/20260929120000_smoodly_v5.sql +0 -56
- package/migrations/20260929135523_smoodly_v6.sql +0 -200
- package/migrations/20260930040956_smoodly_v7.sql +0 -84
- package/migrations/20261007120000_smoodly_v8.sql +0 -154
- package/migrations/20261007200000_smoodly_v9.sql +0 -30
- package/migrations/20261008120000_smoodly_v10.sql +0 -47
- package/migrations/20261009120000_smoodly_v11.sql +0 -49
- /package/dist/{admin/auth-config.js → deploy-store.js} +0 -0
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { ResolvedConfig } from "../config.ts";
|
|
2
|
-
import type { PageRecord, PageStore } from "../store.ts";
|
|
3
2
|
import { type CodeAddress } from "../addresses.ts";
|
|
4
3
|
export type FixedNode = CodeAddress;
|
|
5
4
|
export declare const fixedNodeSpecs: (config: ResolvedConfig) => FixedNode[];
|
|
@@ -7,8 +6,3 @@ export declare const fixedNodeSpecs: (config: ResolvedConfig) => FixedNode[];
|
|
|
7
6
|
export declare function fixedNodeOf(record: {
|
|
8
7
|
fixed?: string | null;
|
|
9
8
|
}, config: ResolvedConfig): FixedNode | null;
|
|
10
|
-
/** Create every code-owned record that has none yet and give each the
|
|
11
|
-
* locales it lacks, publishing what it creates, then list. Code wins an address (spec 2026-09-29
|
|
12
|
-
* anchored paths §4): an editor's page already there changes nothing
|
|
13
|
-
* here — the index serves the record, the Pages list flags the page. */
|
|
14
|
-
export declare function ensureFixedNodes(pages: PageStore, config: ResolvedConfig): Promise<PageRecord[]>;
|
|
@@ -1,86 +1,6 @@
|
|
|
1
|
-
import { codeAddresses
|
|
1
|
+
import { codeAddresses } from "../addresses.js";
|
|
2
2
|
export const fixedNodeSpecs = (config) => codeAddresses({ locales: config.locales, pages: config.registry.pages ?? [], collections: config.registry.collections ?? [] });
|
|
3
3
|
/** The spec that owns a record, or null for an editor's page and for a record no registration owns any more. */
|
|
4
4
|
export function fixedNodeOf(record, config) {
|
|
5
5
|
return record.fixed ? fixedNodeSpecs(config).find((s) => s.key === record.fixed) ?? null : null;
|
|
6
6
|
}
|
|
7
|
-
/** Publish a row this call just created (spec 2026-09-30 tester findings §2): the site served the address
|
|
8
|
-
* from code until now, so the empty page goes live as it was. A row that already existed is never
|
|
9
|
-
* published here — an editor's unpublish stands. A folder has no tree to publish. */
|
|
10
|
-
async function publishCreated(pages, record, locale, address) {
|
|
11
|
-
if (record.template === null)
|
|
12
|
-
return record;
|
|
13
|
-
try {
|
|
14
|
-
// A locale added to an older record starts as a copy of the default locale's DRAFT. What goes live
|
|
15
|
-
// is the empty page code was serving; the copy stays the new locale's draft, for an editor to publish.
|
|
16
|
-
const copy = await pages.getDraftTree(record.id, locale);
|
|
17
|
-
const empty = !copy || (Object.values(copy.zones).every((nodes) => nodes.length === 0) && !copy.fields && !copy.meta);
|
|
18
|
-
if (empty)
|
|
19
|
-
return await pages.publish(record.id, locale);
|
|
20
|
-
await pages.saveDraft(record.id, locale, { zones: {} });
|
|
21
|
-
await pages.publish(record.id, locale);
|
|
22
|
-
await pages.saveDraft(record.id, locale, copy);
|
|
23
|
-
return (await pages.getPage(record.id)) ?? record;
|
|
24
|
-
}
|
|
25
|
-
catch (e) {
|
|
26
|
-
console.warn(`smoodly: could not publish "${address}" in "${locale}": ${e instanceof Error ? e.message : String(e)}`);
|
|
27
|
-
return record;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
/** Create every code-owned record that has none yet and give each the
|
|
31
|
-
* locales it lacks, publishing what it creates, then list. Code wins an address (spec 2026-09-29
|
|
32
|
-
* anchored paths §4): an editor's page already there changes nothing
|
|
33
|
-
* here — the index serves the record, the Pages list flags the page. */
|
|
34
|
-
export async function ensureFixedNodes(pages, config) {
|
|
35
|
-
const def = config.locales.default;
|
|
36
|
-
const all = await pages.listPages();
|
|
37
|
-
let wrote = false;
|
|
38
|
-
for (const spec of fixedNodeSpecs(config)) {
|
|
39
|
-
let record = all.find((p) => p.fixed === spec.key);
|
|
40
|
-
const defAddress = spec.segments[def];
|
|
41
|
-
if (!record) {
|
|
42
|
-
wrote = true;
|
|
43
|
-
try {
|
|
44
|
-
record = await pages.createPage({ locale: def, slug: lastPart(defAddress), template: spec.template, title: spec.title, fixed: spec.key });
|
|
45
|
-
}
|
|
46
|
-
catch (e) {
|
|
47
|
-
// Two admins loading at once race on this very create; the loser's
|
|
48
|
-
// fixedTaken is the other one winning. Anything else is said out loud.
|
|
49
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
50
|
-
if (!message.includes("already exists"))
|
|
51
|
-
console.warn(`smoodly: could not materialize "${defAddress}": ${message}`);
|
|
52
|
-
continue;
|
|
53
|
-
}
|
|
54
|
-
record = await publishCreated(pages, record, def, defAddress);
|
|
55
|
-
}
|
|
56
|
-
else if (record.template === null && spec.template !== null) {
|
|
57
|
-
// The collection gained its index page. A racing admin's hasTemplate is that admin winning.
|
|
58
|
-
wrote = true;
|
|
59
|
-
try {
|
|
60
|
-
record = await pages.setTemplate(record.id, spec.template);
|
|
61
|
-
}
|
|
62
|
-
catch (e) {
|
|
63
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
64
|
-
if (!message.includes("already has a page type"))
|
|
65
|
-
console.warn(`smoodly: could not give "${defAddress}" its page type: ${message}`);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
// Every locale the registration names — including one added to the config after the record was first materialized.
|
|
69
|
-
for (const [locale, address] of Object.entries(spec.segments)) {
|
|
70
|
-
if (record.locales[locale])
|
|
71
|
-
continue;
|
|
72
|
-
wrote = true;
|
|
73
|
-
try {
|
|
74
|
-
record = await pages.addLocale(record.id, locale, { from: def, slug: lastPart(address) });
|
|
75
|
-
record = await publishCreated(pages, record, locale, address);
|
|
76
|
-
}
|
|
77
|
-
catch (e) {
|
|
78
|
-
// Only a concurrent add of the SAME locale is a race; it skips silently.
|
|
79
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
80
|
-
if (!message.includes("already exists in locale"))
|
|
81
|
-
console.warn(`smoodly: could not materialize "${address}" in "${locale}": ${message}`);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
return wrote ? pages.listPages() : all;
|
|
86
|
-
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ReactElement } from "react";
|
|
2
2
|
import type { AssetRecord, ImageValue } from "../../media.ts";
|
|
3
3
|
import type { AdminField } from "../serialize.ts";
|
|
4
|
-
|
|
4
|
+
type CropEditorProps = {
|
|
5
5
|
asset: AssetRecord;
|
|
6
6
|
descriptor: AdminField;
|
|
7
7
|
value: ImageValue;
|
|
@@ -9,4 +9,6 @@ export declare function CropEditor({ asset, descriptor, value, caption, onSave,
|
|
|
9
9
|
caption?: string;
|
|
10
10
|
onSave(next: ImageValue, caption?: string): void;
|
|
11
11
|
onClose(): void;
|
|
12
|
-
}
|
|
12
|
+
};
|
|
13
|
+
export declare function CropEditor(props: CropEditorProps): ReactElement;
|
|
14
|
+
export {};
|
|
@@ -6,15 +6,26 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
6
6
|
// and a live preview at the chosen aspect. Pointer deltas become image
|
|
7
7
|
// fractions through the stage's width; the math is in crop-editor.ts.
|
|
8
8
|
// A richtext image card passes its `caption` too (2026-09-25): the card
|
|
9
|
-
// shows no inputs, so the modal is where both texts are edited.
|
|
9
|
+
// shows no inputs, so the modal is where both texts are edited. A field with
|
|
10
|
+
// named crops (spec 2026-10-18 media on R2 and Images §6, §9) gets a tab per
|
|
11
|
+
// crop sharing one hotspot, the other crops dashed on the image, and a
|
|
12
|
+
// preview each. The images are cuts where the service cuts (adminImageSrc).
|
|
10
13
|
import { useRef, useState } from "react";
|
|
11
|
-
import { parseAspect } from "../../media.js";
|
|
14
|
+
import { cropAspect, parseAspect } from "../../media.js";
|
|
12
15
|
import { frameFor } from "../../image/crop-math.js";
|
|
13
16
|
import { Button, Field, Segmented } from "../ui/primitives.js";
|
|
14
17
|
import { Modal } from "../ui/Modal.js";
|
|
15
|
-
import { initialState, moveCrop, placeHotspot, relock, resizeCrop } from "./crop-editor.js";
|
|
18
|
+
import { adminImageSrc, effectiveCrop, frameCrop, initialNamed, initialState, moveCrop, moveSharedHotspot, placeHotspot, relock, resetCrop, resizeCrop, savedNamed, selectCrop, } from "./crop-editor.js";
|
|
16
19
|
const pct = (n) => `${n * 100}%`;
|
|
17
|
-
|
|
20
|
+
/** The darkened image outside a crop. */
|
|
21
|
+
const shade = (c) => `polygon(0 0, 100% 0, 100% 100%, 0 100%, 0 ${pct(c.top)}, ${pct(c.left)} ${pct(c.top)}, ${pct(c.left)} ${pct(c.top + c.height)}, ${pct(c.left + c.width)} ${pct(c.top + c.height)}, ${pct(c.left + c.width)} ${pct(c.top)}, 0 ${pct(c.top)})`;
|
|
22
|
+
export function CropEditor(props) {
|
|
23
|
+
const crops = props.descriptor.crops;
|
|
24
|
+
return crops && typeof crops === "object" && Object.keys(crops).length > 0
|
|
25
|
+
? _jsx(NamedCropEditor, { ...props, ratios: crops })
|
|
26
|
+
: _jsx(SingleCropEditor, { ...props });
|
|
27
|
+
}
|
|
28
|
+
function SingleCropEditor({ asset, descriptor, value, caption, onSave, onClose }) {
|
|
18
29
|
const declared = descriptor.aspect;
|
|
19
30
|
const options = Array.isArray(declared) ? declared : typeof declared === "string" ? [declared] : [];
|
|
20
31
|
const width = asset.width ?? 1, height = asset.height ?? 1;
|
|
@@ -60,5 +71,47 @@ export function CropEditor({ asset, descriptor, value, caption, onSave, onClose
|
|
|
60
71
|
...(alt.trim() ? { alt: alt.trim() } : { alt: undefined }),
|
|
61
72
|
...(options.length > 1 && chosen ? { aspect: chosen } : { aspect: undefined }),
|
|
62
73
|
}, caption === undefined ? undefined : cap);
|
|
63
|
-
return (_jsx(Modal, { title: "Crop and focus", width: 900, onClose: onClose, footer: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx(Button, { onClick: save, children: "Save" })] }), children: _jsxs("div", { className: "sm-crop", children: [_jsxs("div", { className: "sm-crop__stage", ref: stage, onPointerMove: move, onPointerUp: end, onPointerCancel: end, children: [_jsx("img", { src: asset
|
|
74
|
+
return (_jsx(Modal, { title: "Crop and focus", width: 900, onClose: onClose, footer: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx(Button, { onClick: save, children: "Save" })] }), children: _jsxs("div", { className: "sm-crop", children: [_jsxs("div", { className: "sm-crop__stage", ref: stage, onPointerMove: move, onPointerUp: end, onPointerCancel: end, children: [_jsx("img", { src: adminImageSrc(asset, 1280), alt: "", draggable: false }), _jsx("div", { className: "sm-crop__shade", style: { clipPath: shade(crop) } }), _jsx("div", { className: "sm-crop__rect", style: { left: pct(crop.left), top: pct(crop.top), width: pct(crop.width), height: pct(crop.height) }, onPointerDown: begin("move"), children: ["nw", "ne", "sw", "se"].map((h) => (_jsx("span", { className: `sm-crop__handle sm-crop__handle--${h}`, onPointerDown: begin(h) }, h))) }), _jsx("div", { className: "sm-crop__hotspot", title: "Focus point", style: { left: pct(hotspot.x), top: pct(hotspot.y) }, onPointerDown: begin("hotspot") })] }), _jsxs("div", { className: "sm-crop__side", children: [options.length > 1 && (_jsx(Field, { label: "Aspect", asDiv: true, children: _jsx(Segmented, { options: options, value: chosen ?? options[0], onChange: pick }) })), _jsx(Field, { label: "Preview", asDiv: true, children: _jsx("div", { className: "sm-crop__preview", style: { aspectRatio: String(previewAspect) }, children: _jsx("img", { src: adminImageSrc(asset, 640), alt: "", style: { left: `${frame.left}%`, top: `${frame.top}%`, width: `${frame.width}%`, height: `${frame.height}%` } }) }) }), _jsx(Field, { label: "Alt text for this use", hint: "Empty uses the asset's own alt.", children: _jsx("input", { className: "sm-input", value: alt, placeholder: asset.alt ?? "", onChange: (e) => setAlt(e.target.value) }) }), caption !== undefined && (_jsx(Field, { label: "Caption", hint: "Shown under the image.", children: _jsx("input", { className: "sm-input", value: cap, onChange: (e) => setCap(e.target.value) }) }))] })] }) }));
|
|
75
|
+
}
|
|
76
|
+
const titleOf = (name) => name[0].toUpperCase() + name.slice(1);
|
|
77
|
+
/** A field with named crops: a tab per crop, one hotspot shared by all, a crop nobody framed following it. */
|
|
78
|
+
function NamedCropEditor({ asset, value, onSave, onClose, ratios }) {
|
|
79
|
+
const names = Object.keys(ratios);
|
|
80
|
+
const width = asset.width ?? 1, height = asset.height ?? 1;
|
|
81
|
+
const imageAspect = width / height;
|
|
82
|
+
const [state, setState] = useState(() => initialNamed(value, ratios, width, height));
|
|
83
|
+
const [alt, setAlt] = useState(value.alt ?? "");
|
|
84
|
+
const stage = useRef(null);
|
|
85
|
+
const drag = useRef(null);
|
|
86
|
+
const { active, hotspot } = state;
|
|
87
|
+
const crop = effectiveCrop(state, active, ratios, width, height);
|
|
88
|
+
const begin = (kind) => (e) => {
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
e.stopPropagation();
|
|
91
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
92
|
+
drag.current = { kind, x: e.clientX, y: e.clientY, start: state };
|
|
93
|
+
};
|
|
94
|
+
const move = (e) => {
|
|
95
|
+
const d = drag.current, el = stage.current;
|
|
96
|
+
if (!d || !el)
|
|
97
|
+
return;
|
|
98
|
+
const dx = (e.clientX - d.x) / el.clientWidth, dy = (e.clientY - d.y) / el.clientHeight;
|
|
99
|
+
const startHotspot = d.start.hotspot;
|
|
100
|
+
if (d.kind === "hotspot")
|
|
101
|
+
return setState(moveSharedHotspot(d.start, { x: startHotspot.x + dx, y: startHotspot.y + dy }));
|
|
102
|
+
const from = effectiveCrop(d.start, d.start.active, ratios, width, height);
|
|
103
|
+
const next = d.kind === "move" ? moveCrop(from, dx, dy) : resizeCrop(from, d.kind, dx, dy, parseAspect(ratios[d.start.active]) / imageAspect);
|
|
104
|
+
setState(frameCrop(d.start, d.start.active, next));
|
|
105
|
+
};
|
|
106
|
+
const end = () => { drag.current = null; };
|
|
107
|
+
const follows = (name) => state.crops[name] === null;
|
|
108
|
+
return (_jsx(Modal, { title: "Crop and focus", width: 960, onClose: onClose, footer: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx(Button, { onClick: () => onSave(savedNamed(value, state, alt)), children: "Save" })] }), children: _jsxs("div", { className: "sm-crop", children: [_jsxs("div", { className: "sm-crop__stage", ref: stage, onPointerMove: move, onPointerUp: end, onPointerCancel: end, children: [_jsx("img", { src: adminImageSrc(asset, 1280), alt: "", draggable: false }), _jsx("div", { className: "sm-crop__shade", style: { clipPath: shade(crop) } }), names.filter((n) => n !== active).map((n) => {
|
|
109
|
+
const c = effectiveCrop(state, n, ratios, width, height);
|
|
110
|
+
return _jsx("div", { className: "sm-crop__ghost", title: titleOf(n), style: { left: pct(c.left), top: pct(c.top), width: pct(c.width), height: pct(c.height) } }, n);
|
|
111
|
+
}), _jsx("div", { className: "sm-crop__rect", style: { left: pct(crop.left), top: pct(crop.top), width: pct(crop.width), height: pct(crop.height) }, onPointerDown: begin("move"), children: ["nw", "ne", "sw", "se"].map((h) => (_jsx("span", { className: `sm-crop__handle sm-crop__handle--${h}`, onPointerDown: begin(h) }, h))) }), _jsx("div", { className: "sm-crop__hotspot", title: "Focus point", style: { left: pct(hotspot.x), top: pct(hotspot.y) }, onPointerDown: begin("hotspot") })] }), _jsxs("div", { className: "sm-crop__side", children: [_jsx(Field, { label: "Crop", asDiv: true, hint: "One focus point for every crop: a crop you have not framed follows it.", children: _jsx(Segmented, { options: names, value: active, onChange: (n) => setState((s) => selectCrop(s, n)), label: (n) => _jsxs(_Fragment, { children: [`${titleOf(n)} ${ratios[n]}`, follows(n) && _jsx("span", { className: "sm-crop__follows", title: "Follows the focus point", "aria-label": "Follows the focus point", children: "\u2022" })] }) }) }), !follows(active) && _jsx(Button, { variant: "secondary", onClick: () => setState((s) => resetCrop(s, s.active)), children: "Reset to focus point" }), _jsx(Field, { label: "Preview", asDiv: true, children: _jsx("div", { className: "sm-crop__previews", children: names.map((n) => {
|
|
112
|
+
const c = effectiveCrop(state, n, ratios, width, height);
|
|
113
|
+
const box = cropAspect(c, imageAspect);
|
|
114
|
+
const f = frameFor({ width, height, crop: c, hotspot }, box);
|
|
115
|
+
return (_jsxs("figure", { className: "sm-crop__figure", children: [_jsx("div", { className: "sm-crop__preview", style: { aspectRatio: String(box) }, children: _jsx("img", { src: adminImageSrc(asset, 640), alt: "", style: { left: `${f.left}%`, top: `${f.top}%`, width: `${f.width}%`, height: `${f.height}%` } }) }), _jsx("figcaption", { children: titleOf(n) })] }, n));
|
|
116
|
+
}) }) }), _jsx(Field, { label: "Alt text for this use", hint: "Empty uses the asset's own alt.", children: _jsx("input", { className: "sm-input", value: alt, placeholder: asset.alt ?? "", onChange: (e) => setAlt(e.target.value) }) })] })] }) }));
|
|
64
117
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { ReactElement } from "react";
|
|
2
2
|
import type { AssetRecord, ImageValue } from "../../media.ts";
|
|
3
|
-
export declare function ImagePreview({ asset, value, aspect, height }: {
|
|
3
|
+
export declare function ImagePreview({ asset, value, aspect, crops, height }: {
|
|
4
4
|
asset: AssetRecord;
|
|
5
5
|
value: ImageValue;
|
|
6
6
|
/** The field's declared aspect (one string); the value's own wins. */
|
|
7
7
|
aspect?: string;
|
|
8
|
+
/** The field's named crops: the first, the main one, is shown (spec 2026-10-18 media on R2 and Images §6). */
|
|
9
|
+
crops?: Record<string, string>;
|
|
8
10
|
height: number;
|
|
9
11
|
}): ReactElement | null;
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { boxAspectOf, frameFor } from "../../image/crop-math.js";
|
|
4
|
-
|
|
4
|
+
import { adminImageSrc, effectiveCrop, initialNamed } from "./crop-editor.js";
|
|
5
|
+
export function ImagePreview({ asset, value, aspect, crops, height }) {
|
|
5
6
|
if (!asset.width || !asset.height)
|
|
6
7
|
return null;
|
|
7
|
-
const
|
|
8
|
+
const first = crops ? Object.keys(crops)[0] : undefined;
|
|
9
|
+
const main = crops && first ? { crop: effectiveCrop(initialNamed(value, crops, asset.width, asset.height), first, crops, asset.width, asset.height), aspect: crops[first] } : null;
|
|
10
|
+
const image = {
|
|
11
|
+
url: asset.url, width: asset.width, height: asset.height, mime: asset.mime, alt: "", hotspot: value.hotspot,
|
|
12
|
+
crop: main ? main.crop : value.crop, aspect: main ? main.aspect : value.aspect ?? aspect,
|
|
13
|
+
};
|
|
8
14
|
const box = boxAspectOf(image);
|
|
9
15
|
const f = frameFor(image, box);
|
|
10
|
-
return (_jsx("div", { className: "sm-media__canvas", style: { maxHeight: height }, children: _jsx("div", { className: "sm-media__thumb", style: { aspectRatio: String(box), width: `calc(${height}px * ${box})`, maxHeight: height }, children: _jsx("img", { src: asset
|
|
16
|
+
return (_jsx("div", { className: "sm-media__canvas", style: { maxHeight: height }, children: _jsx("div", { className: "sm-media__thumb", style: { aspectRatio: String(box), width: `calc(${height}px * ${box})`, maxHeight: height }, children: _jsx("img", { src: adminImageSrc(asset, 640), alt: "", style: { left: `${f.left}%`, top: `${f.top}%`, width: `${f.width}%`, height: `${f.height}%` } }) }) }));
|
|
11
17
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { formatBytes } from "../shell/media-list.js";
|
|
4
|
+
import { adminImageSrc } from "./crop-editor.js";
|
|
4
5
|
export function AssetThumb({ asset, className }) {
|
|
5
6
|
if (asset.kind === "image")
|
|
6
|
-
return _jsx("img", { className: className, src: asset
|
|
7
|
+
return _jsx("img", { className: className, src: adminImageSrc(asset, 320), alt: "", loading: "lazy" });
|
|
7
8
|
if (asset.kind === "video") {
|
|
8
9
|
return asset.posterId
|
|
9
10
|
? _jsx("video", { className: className, src: asset.url, muted: true, playsInline: true, preload: "metadata" })
|
|
@@ -5,7 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
5
5
|
// kind filter. `initialItems` seeds the grid (tests and the widget's
|
|
6
6
|
// optimistic reopen); the effect loads the real list.
|
|
7
7
|
import { useCallback, useEffect, useState } from "react";
|
|
8
|
-
import { Banner, Segmented } from "../ui/primitives.js";
|
|
8
|
+
import { Banner, Segmented, UploadProgress } from "../ui/primitives.js";
|
|
9
9
|
import { Modal } from "../ui/Modal.js";
|
|
10
10
|
import { useAssets } from "./assets-context.js";
|
|
11
11
|
import { uploadFile } from "./upload.js";
|
|
@@ -16,7 +16,9 @@ export function MediaPicker({ kinds, onPick, onClose, initialItems = [] }) {
|
|
|
16
16
|
const [items, setItems] = useState(initialItems);
|
|
17
17
|
const [search, setSearch] = useState("");
|
|
18
18
|
const [filter, setFilter] = useState(kinds.length === 1 ? (kinds[0] === "image" ? "Images" : kinds[0] === "video" ? "Video" : "Files") : "All");
|
|
19
|
-
|
|
19
|
+
/** The fraction sent while a file uploads; null when none is. */
|
|
20
|
+
const [progress, setProgress] = useState(null);
|
|
21
|
+
const busy = progress !== null;
|
|
20
22
|
const [error, setError] = useState(null);
|
|
21
23
|
const filters = kinds.length === 1 ? [filter] : KIND_FILTERS;
|
|
22
24
|
const load = useCallback(() => {
|
|
@@ -29,10 +31,10 @@ export function MediaPicker({ kinds, onPick, onClose, initialItems = [] }) {
|
|
|
29
31
|
const file = files?.[0];
|
|
30
32
|
if (!file)
|
|
31
33
|
return;
|
|
32
|
-
|
|
34
|
+
setProgress(0);
|
|
33
35
|
setError(null);
|
|
34
|
-
const r = await uploadFile(assets.
|
|
35
|
-
|
|
36
|
+
const r = await uploadFile(assets.upload.send, file, assets.upload.measure, setProgress);
|
|
37
|
+
setProgress(null);
|
|
36
38
|
if (!r.ok) {
|
|
37
39
|
setError(r.message);
|
|
38
40
|
return;
|
|
@@ -40,5 +42,6 @@ export function MediaPicker({ kinds, onPick, onClose, initialItems = [] }) {
|
|
|
40
42
|
assets.remember(r.data);
|
|
41
43
|
onPick(r.data);
|
|
42
44
|
};
|
|
43
|
-
return (_jsxs(Modal, { title: "Media", onClose: onClose, children: [_jsxs("div", { className: "sm-picker-bar", children: [_jsx("input", { className: "sm-input", placeholder: "Search media", value: search, onChange: (e) => setSearch(e.target.value), style: { maxWidth: 260 } }), filters.length > 1 && _jsx(Segmented, { options: filters, value: filter, onChange: setFilter }), _jsxs("label", { className: "sm-btn sm-btn--primary", style: { marginLeft: "auto" }, children: [busy ? "Uploading…" : "Upload", _jsx("input", { type: "file", accept: acceptFor(kinds, assets.media.accept), hidden: true, disabled: busy, onChange: (e) => { void onFiles(e.target.files); } })] })] }), error && _jsx(Banner, { tone: "error", onDismiss: () => setError(null), children: error }), _jsx("div", { className: "sm-dropzone sm-dropzone--bar", onDragOver: (e) => e.preventDefault(), onDrop: (e) => { e.preventDefault();
|
|
45
|
+
return (_jsxs(Modal, { title: "Media", onClose: onClose, children: [_jsxs("div", { className: "sm-picker-bar", children: [_jsx("input", { className: "sm-input", placeholder: "Search media", value: search, onChange: (e) => setSearch(e.target.value), style: { maxWidth: 260 } }), filters.length > 1 && _jsx(Segmented, { options: filters, value: filter, onChange: setFilter }), _jsxs("label", { className: "sm-btn sm-btn--primary", style: { marginLeft: "auto" }, children: [busy ? "Uploading…" : "Upload", _jsx("input", { type: "file", accept: acceptFor(kinds, assets.media.accept), hidden: true, disabled: busy, onChange: (e) => { void onFiles(e.target.files); } })] })] }), error && _jsx(Banner, { tone: "error", onDismiss: () => setError(null), children: error }), _jsx("div", { className: "sm-dropzone sm-dropzone--bar", onDragOver: (e) => e.preventDefault(), onDrop: (e) => { e.preventDefault(); if (!busy)
|
|
46
|
+
void onFiles(e.dataTransfer.files); }, children: progress !== null ? _jsx(UploadProgress, { fraction: progress }) : "Drop a file here to upload it" }), _jsx(MediaGrid, { items: items, onSelect: onPick })] }));
|
|
44
47
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
// The media field widget (spec 2026-09-08 media §10): empty is a drop
|
|
4
4
|
// zone (upload straight away, or open the library); filled is the
|
|
5
5
|
// thumbnail with the crop applied — contained on a canvas at most
|
|
@@ -11,7 +11,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
11
11
|
// The record comes from the assets context (cached per id); `asset`
|
|
12
12
|
// preloads it for static renders.
|
|
13
13
|
import { useEffect, useState } from "react";
|
|
14
|
-
import { Button, Field } from "../ui/primitives.js";
|
|
14
|
+
import { Button, Field, UploadProgress } from "../ui/primitives.js";
|
|
15
15
|
import { acceptFor, formatBytes } from "../shell/media-list.js";
|
|
16
16
|
import { useAssets } from "./assets-context.js";
|
|
17
17
|
import { uploadFile } from "./upload.js";
|
|
@@ -26,7 +26,9 @@ export function MediaWidget({ descriptor, value, onChange, asset: preloaded, lab
|
|
|
26
26
|
const kind = descriptor.type;
|
|
27
27
|
const id = typeof value?.asset === "string" ? value.asset : null;
|
|
28
28
|
const [asset, setAsset] = useState(preloaded ?? null);
|
|
29
|
-
|
|
29
|
+
/** The fraction sent while a file uploads; null when none is. */
|
|
30
|
+
const [progress, setProgress] = useState(null);
|
|
31
|
+
const busy = progress !== null;
|
|
30
32
|
const [error, setError] = useState(null);
|
|
31
33
|
const [picker, setPicker] = useState(false);
|
|
32
34
|
const [crop, setCrop] = useState(false);
|
|
@@ -54,10 +56,10 @@ export function MediaWidget({ descriptor, value, onChange, asset: preloaded, lab
|
|
|
54
56
|
const file = files?.[0];
|
|
55
57
|
if (!file)
|
|
56
58
|
return;
|
|
57
|
-
|
|
59
|
+
setProgress(0);
|
|
58
60
|
setError(null);
|
|
59
|
-
const r = await uploadFile(assets.
|
|
60
|
-
|
|
61
|
+
const r = await uploadFile(assets.upload.send, file, assets.upload.measure, setProgress);
|
|
62
|
+
setProgress(null);
|
|
61
63
|
if (!r.ok) {
|
|
62
64
|
setError(r.message);
|
|
63
65
|
return;
|
|
@@ -65,10 +67,10 @@ export function MediaWidget({ descriptor, value, onChange, asset: preloaded, lab
|
|
|
65
67
|
select(r.data);
|
|
66
68
|
};
|
|
67
69
|
const accept = acceptFor([kind], assets.media.accept);
|
|
68
|
-
const fileInput = (label, variant = "secondary") => (_jsxs("label", { className: `sm-btn sm-btn--${variant}`, children: [
|
|
70
|
+
const fileInput = (label, variant = "secondary") => (_jsxs("label", { className: `sm-btn sm-btn--${variant}`, children: [progress !== null ? `Uploading… ${Math.round(progress * 100)}%` : label, _jsx("input", { type: "file", accept: accept, hidden: true, disabled: busy, onChange: (e) => { void onFiles(e.target.files); } })] }));
|
|
69
71
|
let body;
|
|
70
72
|
if (!id) {
|
|
71
|
-
body = (
|
|
73
|
+
body = (_jsx("div", { className: "sm-dropzone", onDragOver: (e) => e.preventDefault(), onDrop: (e) => { e.preventDefault(); void onFiles(e.dataTransfer.files); }, children: progress !== null ? _jsx(UploadProgress, { fraction: progress }) : _jsxs(_Fragment, { children: [_jsx("span", { children: "Drop a file here" }), _jsxs("div", { className: "sm-media__actions", children: [fileInput("Upload", "primary"), _jsx(Button, { variant: "secondary", onClick: () => setPicker(true), children: "Choose from library" })] })] }) }));
|
|
72
74
|
}
|
|
73
75
|
else if (!asset) {
|
|
74
76
|
body = _jsx("div", { className: "sm-media__chip", children: "Loading\u2026" });
|
|
@@ -78,7 +80,7 @@ export function MediaWidget({ descriptor, value, onChange, asset: preloaded, lab
|
|
|
78
80
|
}
|
|
79
81
|
else {
|
|
80
82
|
const v = value;
|
|
81
|
-
const preview = asset.kind === "image" && asset.width && asset.height ? (_jsx(ImagePreview, { asset: asset, value: v, aspect: typeof descriptor.aspect === "string" ? descriptor.aspect : undefined, height: CANVAS_H })) : asset.kind === "video" ? (() => {
|
|
83
|
+
const preview = asset.kind === "image" && asset.width && asset.height ? (_jsx(ImagePreview, { asset: asset, value: v, aspect: typeof descriptor.aspect === "string" ? descriptor.aspect : undefined, crops: descriptor.crops && typeof descriptor.crops === "object" ? descriptor.crops : undefined, height: CANVAS_H })) : asset.kind === "video" ? (() => {
|
|
82
84
|
const ratio = asset.width && asset.height ? asset.width / asset.height : 16 / 9;
|
|
83
85
|
return (_jsx("div", { className: "sm-media__canvas", children: _jsx("div", { className: "sm-media__thumb", style: { aspectRatio: String(ratio), width: `calc(${CANVAS_H}px * ${ratio})` }, children: _jsx(AssetThumb, { asset: asset, className: "sm-mediacard__img" }) }) }));
|
|
84
86
|
})() : (_jsxs("div", { className: "sm-media__chip", children: [_jsx("span", { style: { fontWeight: 500 }, children: asset.filename }), _jsx("span", { className: "sm-media__alt", children: formatBytes(asset.size) })] }));
|
|
@@ -2,18 +2,22 @@ import { type ReactElement, type ReactNode } from "react";
|
|
|
2
2
|
import type { AdminOps } from "../ops.ts";
|
|
3
3
|
import type { AdminRegistry } from "../serialize.ts";
|
|
4
4
|
import type { AssetRecord } from "../../media.ts";
|
|
5
|
-
import { type
|
|
5
|
+
import { type Measure, type UploadSend } from "./upload.ts";
|
|
6
|
+
export type AssetsUpload = {
|
|
7
|
+
send: UploadSend;
|
|
8
|
+
measure: Measure;
|
|
9
|
+
};
|
|
6
10
|
export type AssetsApi = {
|
|
7
11
|
ops: AdminOps["assets"];
|
|
8
12
|
get(id: string): Promise<AssetRecord | null>;
|
|
9
13
|
remember(record: AssetRecord): void;
|
|
10
|
-
upload:
|
|
14
|
+
upload: AssetsUpload;
|
|
11
15
|
media: AdminRegistry["media"];
|
|
12
16
|
};
|
|
13
17
|
export declare function AssetsProvider({ ops, media, upload, children }: {
|
|
14
18
|
ops: AdminOps;
|
|
15
19
|
media: AdminRegistry["media"];
|
|
16
|
-
upload?:
|
|
20
|
+
upload?: Partial<AssetsUpload>;
|
|
17
21
|
children: ReactNode;
|
|
18
22
|
}): ReactElement;
|
|
19
23
|
export declare function useAssets(): AssetsApi;
|
|
@@ -2,17 +2,19 @@
|
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
// The one place widgets reach the assets ops: a context with a small
|
|
4
4
|
// record cache (an id fetched once per shell lifetime), the field
|
|
5
|
-
// limits from the registry, and the injectable upload
|
|
5
|
+
// limits from the registry, and the injectable upload (the send to the service's media route, the measure).
|
|
6
6
|
import { createContext, useContext, useMemo, useRef } from "react";
|
|
7
|
-
import {
|
|
7
|
+
import { checkedSend } from "./upload.js";
|
|
8
8
|
import { measureFile } from "./measure.js";
|
|
9
|
+
/** Where no send is given (a screen rendered outside the shell): an upload says so instead of failing silently. */
|
|
10
|
+
const noSend = async () => ({ ok: false, code: "internal", message: "smoodly: uploads need the admin's media route — render inside the admin shell." });
|
|
9
11
|
const Ctx = createContext(null);
|
|
10
12
|
export function AssetsProvider({ ops, media, upload, children }) {
|
|
11
13
|
const cache = useRef(new Map());
|
|
12
14
|
const api = useMemo(() => ({
|
|
13
15
|
ops: ops.assets,
|
|
14
16
|
media,
|
|
15
|
-
upload: upload ??
|
|
17
|
+
upload: { send: checkedSend(upload?.send ?? noSend, media), measure: upload?.measure ?? measureFile },
|
|
16
18
|
remember: (record) => { cache.current.set(record.id, record); },
|
|
17
19
|
get: async (id) => {
|
|
18
20
|
const hit = cache.current.get(id);
|
|
@@ -1,13 +1,35 @@
|
|
|
1
|
-
import { type Crop, type Hotspot, type ImageValue } from "../../media.ts";
|
|
1
|
+
import { type AssetRecord, type Crop, type Hotspot, type ImageValue } from "../../media.ts";
|
|
2
2
|
export type Handle = "nw" | "ne" | "sw" | "se";
|
|
3
3
|
export type CropState = {
|
|
4
4
|
crop: Crop;
|
|
5
5
|
hotspot: Hotspot;
|
|
6
6
|
};
|
|
7
|
-
export declare const MIN_SIDE = 0.05;
|
|
8
7
|
export declare function fitAspect(aspect: number, imageAspect: number): Crop;
|
|
9
8
|
export declare function moveCrop(crop: Crop, dx: number, dy: number): Crop;
|
|
10
9
|
export declare function resizeCrop(crop: Crop, handle: Handle, dx: number, dy: number, lock?: number, min?: number): Crop;
|
|
11
10
|
export declare function placeHotspot(h: Hotspot, crop: Crop): Hotspot;
|
|
12
11
|
export declare function relock(state: CropState, aspect: number | undefined, imageAspect: number): CropState;
|
|
13
12
|
export declare function initialState(value: ImageValue | undefined, aspect: number | undefined, imageAspect: number): CropState;
|
|
13
|
+
/** What the admin shows of an asset (spec 2026-10-18 media on R2 and Images §9): its thumbnail cut at the width asked,
|
|
14
|
+
* never past the image's own (the grid and the picker 320, the field and the inspector 640, the crop window 1280),
|
|
15
|
+
* else the original where the service cuts nothing; always at the default quality. */
|
|
16
|
+
export declare function adminImageSrc(asset: AssetRecord, width: 320 | 640 | 1280): string;
|
|
17
|
+
/** The window over a field with named crops: one hotspot for every crop, each crop framed or following it (null). */
|
|
18
|
+
export type NamedCropState = {
|
|
19
|
+
hotspot: Hotspot;
|
|
20
|
+
crops: Record<string, Crop | null>;
|
|
21
|
+
active: string;
|
|
22
|
+
};
|
|
23
|
+
export declare function initialNamed(value: ImageValue, ratios: Record<string, string>, width: number, height: number): NamedCropState;
|
|
24
|
+
/** The rectangle a crop shows: framed, or around the hotspot at its ratio. */
|
|
25
|
+
export declare function effectiveCrop(state: NamedCropState, name: string, ratios: Record<string, string>, width: number, height: number): Crop;
|
|
26
|
+
export declare const selectCrop: (state: NamedCropState, name: string) => NamedCropState;
|
|
27
|
+
/** Dragged or resized: the crop is framed and stops following. */
|
|
28
|
+
export declare const frameCrop: (state: NamedCropState, name: string, crop: Crop) => NamedCropState;
|
|
29
|
+
/** Back on the hotspot. */
|
|
30
|
+
export declare const resetCrop: (state: NamedCropState, name: string) => NamedCropState;
|
|
31
|
+
/** The shared hotspot moves anywhere in the image; every following crop moves with it. */
|
|
32
|
+
export declare const moveSharedHotspot: (state: NamedCropState, hotspot: Hotspot) => NamedCropState;
|
|
33
|
+
/** What Save writes: the framed crops only, the hotspot and the alt; a crop or aspect saved before the field named
|
|
34
|
+
* crops is dropped (the first crop took it over when it matched). Nothing touches the asset row. */
|
|
35
|
+
export declare function savedNamed(value: ImageValue, state: NamedCropState, alt: string): ImageValue;
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// fractions of the original image. A "lock" is the wanted pixel ratio
|
|
3
3
|
// divided by the image's own, i.e. the ratio crop.width / crop.height
|
|
4
4
|
// must keep.
|
|
5
|
-
import { CENTER_HOTSPOT, FULL_CROP, clamp01 } from "../../media.js";
|
|
6
|
-
|
|
5
|
+
import { CENTER_HOTSPOT, FULL_CROP, clamp01, followingCrop, framedCrop } from "../../media.js";
|
|
6
|
+
import { cutUrl, cutWidths, DEFAULT_QUALITY } from "../../media-cut.js";
|
|
7
|
+
const MIN_SIDE = 0.05;
|
|
7
8
|
export function fitAspect(aspect, imageAspect) {
|
|
8
9
|
const fa = aspect / imageAspect;
|
|
9
10
|
const width = fa <= 1 ? fa : 1;
|
|
@@ -70,3 +71,36 @@ export function relock(state, aspect, imageAspect) {
|
|
|
70
71
|
export function initialState(value, aspect, imageAspect) {
|
|
71
72
|
return relock({ crop: value?.crop ?? FULL_CROP, hotspot: value?.hotspot ?? CENTER_HOTSPOT }, aspect, imageAspect);
|
|
72
73
|
}
|
|
74
|
+
/** What the admin shows of an asset (spec 2026-10-18 media on R2 and Images §9): its thumbnail cut at the width asked,
|
|
75
|
+
* never past the image's own (the grid and the picker 320, the field and the inspector 640, the crop window 1280),
|
|
76
|
+
* else the original where the service cuts nothing; always at the default quality. */
|
|
77
|
+
export function adminImageSrc(asset, width) {
|
|
78
|
+
if (!asset.thumb || !asset.width)
|
|
79
|
+
return asset.url;
|
|
80
|
+
const widths = cutWidths(asset.width);
|
|
81
|
+
const fit = widths.filter((w) => w <= width);
|
|
82
|
+
return cutUrl(asset.thumb, fit.length > 0 ? fit[fit.length - 1] : widths[0], DEFAULT_QUALITY);
|
|
83
|
+
}
|
|
84
|
+
export function initialNamed(value, ratios, width, height) {
|
|
85
|
+
const names = Object.keys(ratios);
|
|
86
|
+
const crops = Object.fromEntries(names.map((n) => [n, framedCrop(value, names, n, ratios[n], width / height) ?? null]));
|
|
87
|
+
return { hotspot: value.hotspot ?? CENTER_HOTSPOT, crops, active: names[0] };
|
|
88
|
+
}
|
|
89
|
+
/** The rectangle a crop shows: framed, or around the hotspot at its ratio. */
|
|
90
|
+
export function effectiveCrop(state, name, ratios, width, height) {
|
|
91
|
+
return state.crops[name] ?? followingCrop(ratios[name], state.hotspot, width, height);
|
|
92
|
+
}
|
|
93
|
+
export const selectCrop = (state, name) => ({ ...state, active: name });
|
|
94
|
+
/** Dragged or resized: the crop is framed and stops following. */
|
|
95
|
+
export const frameCrop = (state, name, crop) => ({ ...state, crops: { ...state.crops, [name]: crop } });
|
|
96
|
+
/** Back on the hotspot. */
|
|
97
|
+
export const resetCrop = (state, name) => ({ ...state, crops: { ...state.crops, [name]: null } });
|
|
98
|
+
/** The shared hotspot moves anywhere in the image; every following crop moves with it. */
|
|
99
|
+
export const moveSharedHotspot = (state, hotspot) => ({ ...state, hotspot: { x: clamp01(hotspot.x), y: clamp01(hotspot.y) } });
|
|
100
|
+
/** What Save writes: the framed crops only, the hotspot and the alt; a crop or aspect saved before the field named
|
|
101
|
+
* crops is dropped (the first crop took it over when it matched). Nothing touches the asset row. */
|
|
102
|
+
export function savedNamed(value, state, alt) {
|
|
103
|
+
const { crop: _crop, aspect: _aspect, crops: _crops, alt: _alt, ...rest } = value;
|
|
104
|
+
const framed = Object.fromEntries(Object.entries(state.crops).filter((e) => e[1] !== null));
|
|
105
|
+
return { ...rest, ...(Object.keys(framed).length > 0 ? { crops: framed } : {}), hotspot: state.hotspot, ...(alt.trim() ? { alt: alt.trim() } : {}) };
|
|
106
|
+
}
|
|
@@ -4,7 +4,6 @@ export type Day = {
|
|
|
4
4
|
m: number;
|
|
5
5
|
d: number;
|
|
6
6
|
};
|
|
7
|
-
export declare function daysInMonth(y: number, m: number): number;
|
|
8
7
|
/** Strict `YYYY-MM-DD`, and a day the calendar has (no 2026-02-30). */
|
|
9
8
|
export declare function parseDay(s: string): Day | null;
|
|
10
9
|
export declare const formatDay: (day: Day) => string;
|
|
@@ -7,7 +7,7 @@ const DAY_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
|
7
7
|
const INSTANT_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):00Z$/;
|
|
8
8
|
const MS_PER_DAY = 86400000;
|
|
9
9
|
const pad = (n) => String(n).padStart(2, "0");
|
|
10
|
-
|
|
10
|
+
function daysInMonth(y, m) {
|
|
11
11
|
return new Date(Date.UTC(y, m, 0)).getUTCDate();
|
|
12
12
|
}
|
|
13
13
|
/** Strict `YYYY-MM-DD`, and a day the calendar has (no 2026-02-30). */
|
package/dist/admin/forms/tabs.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
const CSS_DESCRIPTOR = { type: "css" };
|
|
2
2
|
const entries = (ns, map) => Object.entries(map ?? {}).map(([key, descriptor]) => ({ key, ns, descriptor }));
|
|
3
3
|
export function tabsFor(holder) {
|
|
4
4
|
const content = [{ entries: entries("fields", holder.fields) }];
|
|
@@ -1,17 +1,24 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
1
|
+
import type { OpResult } from "../ops.ts";
|
|
2
|
+
import type { AssetRecord } from "../../media.ts";
|
|
3
3
|
export type Measured = {
|
|
4
4
|
width?: number;
|
|
5
5
|
height?: number;
|
|
6
6
|
duration?: number;
|
|
7
7
|
poster?: Blob;
|
|
8
8
|
};
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
export declare function uploadFile(
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
*
|
|
17
|
-
export declare function
|
|
9
|
+
/** One upload request: the file, its parameters (name, mime, the measured dimensions, posterId), the progress as a
|
|
10
|
+
* fraction of the bytes sent. */
|
|
11
|
+
export type UploadSend = (file: Blob, params: Record<string, string>, onProgress?: (fraction: number) => void) => Promise<OpResult<AssetRecord>>;
|
|
12
|
+
export type Measure = (file: Blob, mime: string) => Promise<Measured>;
|
|
13
|
+
export declare function uploadFile(send: UploadSend, file: File, measure: Measure, onProgress?: (fraction: number) => void): Promise<OpResult<AssetRecord>>;
|
|
14
|
+
export declare const uploadQuery: (params: Record<string, string>) => string;
|
|
15
|
+
/** A send that first applies the space's media rules (its registry's `media`) with the store's own words, so a file
|
|
16
|
+
* the space refuses is never sent; the service checks again, and its word is final. */
|
|
17
|
+
export declare function checkedSend(send: UploadSend, rules: {
|
|
18
|
+
maxSize: number;
|
|
19
|
+
accept: readonly string[];
|
|
20
|
+
}): UploadSend;
|
|
21
|
+
/** The admin's send: an XMLHttpRequest PUT (fetch reports no upload progress) to the admin media route with the
|
|
22
|
+
* editor's pass. A pass refused as expired renews once and the file is sent again, as an operation is
|
|
23
|
+
* (client-ops.ts). */
|
|
24
|
+
export declare function xhrSend(url: string, token: () => string | null, onExpired?: () => Promise<boolean> | void): UploadSend;
|