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
|
@@ -15,10 +15,11 @@ export type PresenceState = {
|
|
|
15
15
|
status: PresenceStatus;
|
|
16
16
|
others: PresenceEntry[];
|
|
17
17
|
};
|
|
18
|
-
/** The
|
|
18
|
+
/** The channel presence uses: the service's socket (presence-socket.ts). */
|
|
19
19
|
export type PresenceChannel = {
|
|
20
20
|
subscribe(onStatus: (status: string) => void): void;
|
|
21
|
-
/**
|
|
21
|
+
/** The session renewed (its new pass): reconnect with a fresh ticket. The space's object admits a live session of a
|
|
22
|
+
* member only. */
|
|
22
23
|
setAuth(token: string): Promise<unknown>;
|
|
23
24
|
track(state: Record<string, unknown>): Promise<unknown>;
|
|
24
25
|
untrack(): Promise<unknown>;
|
|
@@ -39,31 +40,18 @@ export type Presence = {
|
|
|
39
40
|
track(target: PresenceTarget | null): void;
|
|
40
41
|
/** After a guarded op acked: tell the others. */
|
|
41
42
|
announce(target: PresenceTarget, savedAt: string | null): void;
|
|
42
|
-
/** The session
|
|
43
|
+
/** The session renewed: reconnect the socket with a fresh ticket so the channel outlives the old pass. No-op unless joined. */
|
|
43
44
|
setAuth(token: string): void;
|
|
44
45
|
onSaved(listener: (event: SavedEvent) => void): () => void;
|
|
45
46
|
getState(): PresenceState;
|
|
46
47
|
subscribe(listener: () => void): () => void;
|
|
47
48
|
dispose(): void;
|
|
48
49
|
};
|
|
49
|
-
export declare const shortName: (email: string) => string;
|
|
50
50
|
/** The short names editing `target`, other than me, sorted and deduped. */
|
|
51
51
|
export declare function editorsOf(others: PresenceEntry[], target: PresenceTarget): string[];
|
|
52
|
+
/** A target as the channel carries it: one of the three kinds, a string id and locale. The service's object reads it too. */
|
|
53
|
+
export declare const isTarget: (v: unknown) => v is PresenceTarget;
|
|
52
54
|
export declare function createPresence(opts: {
|
|
53
55
|
email: string;
|
|
54
56
|
connect: () => PresenceChannel;
|
|
55
57
|
}): Presence;
|
|
56
|
-
/** What the shell needs from the auth client to follow refreshes — supabase-js's `auth` satisfies it. */
|
|
57
|
-
export type RefreshSource = {
|
|
58
|
-
onAuthStateChange(cb: (event: string, session: {
|
|
59
|
-
access_token: string;
|
|
60
|
-
} | null) => void): {
|
|
61
|
-
data: {
|
|
62
|
-
subscription: {
|
|
63
|
-
unsubscribe(): void;
|
|
64
|
-
};
|
|
65
|
-
};
|
|
66
|
-
};
|
|
67
|
-
};
|
|
68
|
-
/** Re-send every refreshed session token to the socket (spec 2026-09-26 security pass §1). Returns the stop function. */
|
|
69
|
-
export declare function followTokenRefresh(auth: RefreshSource, presence: Pick<Presence, "setAuth">): () => void;
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
// Editing presence (spec 2026-09-26 concurrency §5): who has what open, and a
|
|
2
2
|
// "saved" announcement, over ONE channel per space. Framework-free: the channel
|
|
3
|
-
// is injected (presence-
|
|
3
|
+
// is injected (presence-socket.ts builds the one over the service's socket; tests fake it),
|
|
4
4
|
// the React glue is presence-context.tsx. Never awaited by anything — a failed
|
|
5
5
|
// join leaves the admin exactly as without presence.
|
|
6
6
|
const sameTarget = (a, b) => a.kind === b.kind && a.id === b.id && a.locale === b.locale;
|
|
7
|
-
|
|
7
|
+
const shortName = (email) => email.split("@")[0];
|
|
8
8
|
/** The short names editing `target`, other than me, sorted and deduped. */
|
|
9
9
|
export function editorsOf(others, target) {
|
|
10
10
|
return [...new Set(others.filter((o) => sameTarget(o, target)).map((o) => shortName(o.email)))].sort();
|
|
11
11
|
}
|
|
12
|
-
|
|
12
|
+
/** A target as the channel carries it: one of the three kinds, a string id and locale. The service's object reads it too. */
|
|
13
|
+
export const isTarget = (v) => typeof v === "object" && v !== null
|
|
13
14
|
&& ["page", "entry", "global"].includes(v.kind)
|
|
14
15
|
&& typeof v.id === "string" && typeof v.locale === "string";
|
|
15
16
|
export function createPresence(opts) {
|
|
@@ -93,11 +94,3 @@ export function createPresence(opts) {
|
|
|
93
94
|
},
|
|
94
95
|
};
|
|
95
96
|
}
|
|
96
|
-
/** Re-send every refreshed session token to the socket (spec 2026-09-26 security pass §1). Returns the stop function. */
|
|
97
|
-
export function followTokenRefresh(auth, presence) {
|
|
98
|
-
const { data } = auth.onAuthStateChange((event, session) => {
|
|
99
|
-
if (event === "TOKEN_REFRESHED" && session)
|
|
100
|
-
presence.setAuth(session.access_token);
|
|
101
|
-
});
|
|
102
|
-
return () => data.subscription.unsubscribe();
|
|
103
|
-
}
|
|
@@ -1,22 +1,29 @@
|
|
|
1
|
-
import { type
|
|
2
|
-
|
|
3
|
-
export type { AuthConfig };
|
|
4
|
-
export type Arrival = "invite" | "recovery";
|
|
5
|
-
/** What an auth email link says it is: GoTrue's implicit flow puts `type=` in the fragment; a query form is accepted too. */
|
|
6
|
-
export declare function arrivalOf(hash: string, search: string): Arrival | null;
|
|
1
|
+
import { type SignInConfig, type StoredSession } from "../session-flow.ts";
|
|
2
|
+
export type SessionUser = StoredSession["user"];
|
|
7
3
|
export type SessionState = {
|
|
8
4
|
status: "loading";
|
|
9
5
|
} | {
|
|
10
6
|
status: "signed_out";
|
|
7
|
+
reason: "signed_out" | "refused";
|
|
8
|
+
message?: string;
|
|
11
9
|
} | {
|
|
12
10
|
status: "signed_in";
|
|
13
|
-
|
|
11
|
+
user: SessionUser;
|
|
14
12
|
};
|
|
15
|
-
export declare function
|
|
16
|
-
|
|
13
|
+
export declare function useSpaceSession({ apiUrl, spaceId, signIn }: {
|
|
14
|
+
apiUrl: string;
|
|
15
|
+
spaceId: string;
|
|
16
|
+
signIn: SignInConfig;
|
|
17
|
+
}): {
|
|
17
18
|
state: SessionState;
|
|
19
|
+
/** The current pass, read per call. */
|
|
18
20
|
token: () => string | null;
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
/** Renews now (once at a time); true when a fresh pass is in hand. A refused refresh sends the person to sign in. */
|
|
22
|
+
renew: () => Promise<boolean>;
|
|
23
|
+
/** Ends this site's session; the signed-out page follows. */
|
|
24
|
+
signOut: () => Promise<void>;
|
|
25
|
+
/** Sends the person to sign in. */
|
|
26
|
+
signInAgain: () => Promise<void>;
|
|
27
|
+
/** Hears every new pass (a renewal here or in another tab). */
|
|
28
|
+
onRenew: (listener: (pass: string) => void) => () => void;
|
|
22
29
|
};
|
|
@@ -1,54 +1,121 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
// The editor's session, browser side
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
import { useEffect, useRef, useState } from "react";
|
|
10
|
-
import {
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const type = fromHash ?? new URLSearchParams(search).get("type");
|
|
15
|
-
return type === "invite" || type === "recovery" ? type : null;
|
|
16
|
-
}
|
|
17
|
-
export function useAdminSession(auth) {
|
|
18
|
-
const browser = typeof window !== "undefined";
|
|
19
|
-
// Read BEFORE the client exists: it consumes and strips the fragment as soon as it is constructed.
|
|
20
|
-
const [arrival, setArrival] = useState(() => browser ? arrivalOf(window.location.hash, window.location.search) : null);
|
|
21
|
-
const [client] = useState(() => createClient(auth.url, auth.key, {
|
|
22
|
-
// Own storage key: a self-hoster's own supabase-js client in the same
|
|
23
|
-
// browser must never share the admin's session slot (DESIGN.md §1).
|
|
24
|
-
auth: { autoRefreshToken: browser, persistSession: browser, detectSessionInUrl: browser, storageKey: "smoodly-auth" },
|
|
25
|
-
}));
|
|
2
|
+
// The editor's session for this site's space, browser side (spec 2026-10-16 sign-in and the dashboard §2, §6): no
|
|
3
|
+
// credentials are typed here. On load a one-time code in the fragment is exchanged and stripped; a stored session is
|
|
4
|
+
// used, renewed five minutes before its pass expires; with neither, the person is sent to sign in (smoodly.io, or the
|
|
5
|
+
// local Worker's dev page) and comes back with a code. A refused pass renews once, then sends them to sign in again;
|
|
6
|
+
// Sign out ends this site's session and shows the signed-out page rather than redirecting, which would sign them
|
|
7
|
+
// straight back in while their smoodly.io session lives. Another tab's renewal or sign out is adopted from the
|
|
8
|
+
// `storage` event. The pure steps are admin/session-flow.ts.
|
|
9
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
10
|
+
import { codeFrom, endSession, exchange, readSession, renew, RENEW_BEFORE_MS, resume, signInAddress, STORAGE_KEY } from "../session-flow.js";
|
|
11
|
+
/** A renewal's timer fires up to this much later, so two tabs rarely post the same refresh at once. */
|
|
12
|
+
const JITTER_MS = 30000;
|
|
13
|
+
export function useSpaceSession({ apiUrl, spaceId, signIn }) {
|
|
26
14
|
const [state, setState] = useState({ status: "loading" });
|
|
27
|
-
const
|
|
15
|
+
const [expiresAt, setExpiresAt] = useState(null);
|
|
16
|
+
const held = useRef(null);
|
|
17
|
+
const listeners = useRef(new Set());
|
|
18
|
+
const inflight = useRef(null);
|
|
19
|
+
const stores = () => ({ local: window.localStorage, tab: window.sessionStorage });
|
|
20
|
+
const adopt = useCallback((s) => {
|
|
21
|
+
held.current = s;
|
|
22
|
+
setExpiresAt(s.expiresAt);
|
|
23
|
+
setState({ status: "signed_in", user: s.user });
|
|
24
|
+
for (const l of listeners.current)
|
|
25
|
+
l(s.pass);
|
|
26
|
+
}, []);
|
|
27
|
+
const signInAgain = useCallback(async () => {
|
|
28
|
+
const back = window.location.origin + window.location.pathname + window.location.search;
|
|
29
|
+
window.location.assign(await signInAddress(signIn, spaceId, back, stores()));
|
|
30
|
+
}, [signIn, spaceId]);
|
|
31
|
+
const renewNow = useCallback(() => {
|
|
32
|
+
// A tab with no session (signed out here or in another tab) renews nothing and never redirects by itself.
|
|
33
|
+
if (!held.current)
|
|
34
|
+
return Promise.resolve(false);
|
|
35
|
+
inflight.current ?? (inflight.current = (async () => {
|
|
36
|
+
try {
|
|
37
|
+
const s = await renew(apiUrl, spaceId, stores(), held.current?.refresh ?? null);
|
|
38
|
+
if (s) {
|
|
39
|
+
adopt(s);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
held.current = null;
|
|
43
|
+
await signInAgain();
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Offline or the service down: the session stays; the next call or timer tries again.
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
inflight.current = null;
|
|
52
|
+
}
|
|
53
|
+
})());
|
|
54
|
+
return inflight.current;
|
|
55
|
+
}, [apiUrl, spaceId, adopt, signInAgain]);
|
|
28
56
|
useEffect(() => {
|
|
29
|
-
let
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
57
|
+
let live = true;
|
|
58
|
+
void (async () => {
|
|
59
|
+
const code = codeFrom(window.location.hash);
|
|
60
|
+
if (code) {
|
|
61
|
+
// The code is good once: out of the address bar and the history entry before anything else.
|
|
62
|
+
window.history.replaceState(window.history.state, "", window.location.pathname + window.location.search);
|
|
63
|
+
const got = await exchange(apiUrl, spaceId, code, stores());
|
|
64
|
+
if (!live)
|
|
65
|
+
return;
|
|
66
|
+
if ("refused" in got)
|
|
67
|
+
setState({ status: "signed_out", reason: "refused", message: got.refused });
|
|
68
|
+
else
|
|
69
|
+
adopt(got);
|
|
36
70
|
return;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
71
|
+
}
|
|
72
|
+
// The stored session, as it is or renewed; a service that cannot be reached keeps a still-valid pass and
|
|
73
|
+
// refuses, with a way back, only once it has expired (session-flow.ts resume).
|
|
74
|
+
const step = await resume(apiUrl, spaceId, stores(), Date.now());
|
|
75
|
+
if (!live)
|
|
40
76
|
return;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
77
|
+
if (step.kind === "adopt")
|
|
78
|
+
adopt(step.session);
|
|
79
|
+
else if (step.kind === "refused")
|
|
80
|
+
setState({ status: "signed_out", reason: "refused", message: step.message });
|
|
81
|
+
else
|
|
82
|
+
await signInAgain();
|
|
83
|
+
})();
|
|
84
|
+
const onStorage = (e) => {
|
|
85
|
+
if (e.key !== STORAGE_KEY(spaceId))
|
|
86
|
+
return;
|
|
87
|
+
const s = readSession(window.localStorage, spaceId);
|
|
88
|
+
if (s) {
|
|
89
|
+
if (s.refresh !== held.current?.refresh)
|
|
90
|
+
adopt(s);
|
|
91
|
+
}
|
|
92
|
+
else if (held.current) {
|
|
93
|
+
held.current = null;
|
|
94
|
+
setState({ status: "signed_out", reason: "signed_out" });
|
|
95
|
+
}
|
|
51
96
|
};
|
|
52
|
-
|
|
53
|
-
|
|
97
|
+
window.addEventListener("storage", onStorage);
|
|
98
|
+
return () => { live = false; window.removeEventListener("storage", onStorage); };
|
|
99
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
100
|
+
// Renew five minutes before the pass expires, a little later per tab.
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (expiresAt === null)
|
|
103
|
+
return;
|
|
104
|
+
const wait = Math.max(0, expiresAt - RENEW_BEFORE_MS - Date.now()) + Math.floor(Math.random() * JITTER_MS);
|
|
105
|
+
const timer = setTimeout(() => { void renewNow(); }, wait);
|
|
106
|
+
return () => clearTimeout(timer);
|
|
107
|
+
}, [expiresAt, renewNow]);
|
|
108
|
+
return {
|
|
109
|
+
state,
|
|
110
|
+
token: () => held.current?.pass ?? null,
|
|
111
|
+
renew: renewNow,
|
|
112
|
+
signOut: async () => {
|
|
113
|
+
held.current = null;
|
|
114
|
+
setExpiresAt(null);
|
|
115
|
+
await endSession(apiUrl, spaceId, stores());
|
|
116
|
+
setState({ status: "signed_out", reason: "signed_out" });
|
|
117
|
+
},
|
|
118
|
+
signInAgain,
|
|
119
|
+
onRenew: (listener) => { listeners.current.add(listener); return () => { listeners.current.delete(listener); }; },
|
|
120
|
+
};
|
|
54
121
|
}
|
|
@@ -1,8 +1,4 @@
|
|
|
1
1
|
import type { AdminRegistry } from "../serialize.ts";
|
|
2
|
-
/** Above this many supported locales the switch is a dropdown. */
|
|
3
|
-
export declare const SEGMENTED_MAX = 3;
|
|
4
|
-
/** "fi" → "Fi": the locale code as the pill reads it. */
|
|
5
|
-
export declare const localeLabel: (locale: string) => string;
|
|
6
2
|
export declare function LocaleSwitcher({ registry, present, parentLocales, value, adding, onSwitch, onAdd }: {
|
|
7
3
|
registry: AdminRegistry;
|
|
8
4
|
/** The locales the record has; `[value]` while nothing is loaded. */
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { Segmented } from "./primitives.js";
|
|
4
4
|
/** Above this many supported locales the switch is a dropdown. */
|
|
5
|
-
|
|
5
|
+
const SEGMENTED_MAX = 3;
|
|
6
6
|
/** "fi" → "Fi": the locale code as the pill reads it. */
|
|
7
|
-
|
|
7
|
+
const localeLabel = (locale) => locale.charAt(0).toUpperCase() + locale.slice(1);
|
|
8
8
|
export function LocaleSwitcher({ registry, present, parentLocales, value, adding, onSwitch, onAdd }) {
|
|
9
9
|
const supported = registry.locales.supported;
|
|
10
10
|
const missing = supported.filter((l) => !present.includes(l) && l !== value);
|
|
@@ -105,17 +105,6 @@ export declare function Segmented<T extends string>({ options, value, onChange,
|
|
|
105
105
|
className?: string;
|
|
106
106
|
} | undefined;
|
|
107
107
|
}): import("react").JSX.Element;
|
|
108
|
-
/** A pill row (the f.radio look, spec 2026-09-14 §5): no track, the chosen pill filled in the
|
|
109
|
-
* soft accent. For a small choice inside a table row, where a segmented control reads as tabs. */
|
|
110
|
-
export declare function Pills<T extends string>({ options, value, onChange, disabled, title, label }: {
|
|
111
|
-
options: readonly T[];
|
|
112
|
-
value: T;
|
|
113
|
-
onChange(v: T): void;
|
|
114
|
-
disabled?: boolean;
|
|
115
|
-
/** Why the row is off, on every pill. */
|
|
116
|
-
title?: string;
|
|
117
|
-
label?(o: T): ReactNode;
|
|
118
|
-
}): import("react").JSX.Element;
|
|
119
108
|
export declare function StatusBadge({ status }: {
|
|
120
109
|
status: string;
|
|
121
110
|
}): import("react").JSX.Element;
|
|
@@ -133,9 +122,6 @@ export declare function SectionLabel({ children, action, caps, style }: {
|
|
|
133
122
|
caps?: boolean;
|
|
134
123
|
style?: CSSProperties;
|
|
135
124
|
}): import("react").JSX.Element;
|
|
136
|
-
export declare function Divider({ bleed }: {
|
|
137
|
-
bleed?: number;
|
|
138
|
-
}): import("react").JSX.Element;
|
|
139
125
|
/** The lines under a publish-validation banner: one per field, each focusing its widget
|
|
140
126
|
* when the screen can reveal it (spec 2026-09-14 §2). */
|
|
141
127
|
export declare function FieldErrorList({ fields, onReveal }: {
|
|
@@ -151,6 +137,10 @@ export declare function Banner({ tone, children, onDismiss, revealKey }: {
|
|
|
151
137
|
* list shrinks as fields are fixed, so typing never scrolls. */
|
|
152
138
|
revealKey?: string;
|
|
153
139
|
}): import("react").JSX.Element;
|
|
140
|
+
/** An upload's progress (spec 2026-10-18 media on R2 and Images §9): a bar and the percentage of the bytes sent. */
|
|
141
|
+
export declare function UploadProgress({ fraction }: {
|
|
142
|
+
fraction: number;
|
|
143
|
+
}): import("react").JSX.Element;
|
|
154
144
|
export declare function cx(...parts: (string | false | null | undefined)[]): string;
|
|
155
145
|
/** One line of a form's info block: a muted label, the value right-aligned and clipped. */
|
|
156
146
|
export declare function InfoRow({ label, value, mono }: {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
// The design system's primitives, ported 1:1 from the handoff's
|
|
4
|
-
// components/ set (
|
|
4
|
+
// components/ set (the cool-blue look, in the tree until 2026-10-19; the shipped
|
|
5
|
+
// tokens are design_handoff_smoodly_neutral/). Structure and dynamic
|
|
5
6
|
// values live here; every static rule, and all hover/focus state, lives in
|
|
6
7
|
// theme.tsx's stylesheet.
|
|
7
8
|
import { Fragment, useEffect, useRef } from "react";
|
|
@@ -105,11 +106,6 @@ export function Segmented({ options, value, onChange, stretch, disabled, label,
|
|
|
105
106
|
return (_jsx("button", { type: "button", className: cx("sm-seg__opt", extra?.className), "aria-pressed": o === value, disabled: disabled || extra?.disabled, title: extra?.title, onClick: () => onChange(o), children: label ? label(o) : o }, o));
|
|
106
107
|
}) }));
|
|
107
108
|
}
|
|
108
|
-
/** A pill row (the f.radio look, spec 2026-09-14 §5): no track, the chosen pill filled in the
|
|
109
|
-
* soft accent. For a small choice inside a table row, where a segmented control reads as tabs. */
|
|
110
|
-
export function Pills({ options, value, onChange, disabled, title, label }) {
|
|
111
|
-
return (_jsx("div", { className: cx("sm-pills", disabled && "sm-pills--disabled"), role: "radiogroup", children: options.map((o) => (_jsx("button", { type: "button", className: "sm-pill", "aria-pressed": o === value, disabled: disabled, title: title, onClick: () => onChange(o), children: label ? label(o) : o }, o))) }));
|
|
112
|
-
}
|
|
113
109
|
/* ── data ────────────────────────────────────────────────────────────── */
|
|
114
110
|
// Keyed by the product labels of ui/status-label.ts (lowercased by StatusBadge).
|
|
115
111
|
const STATUS_TONE = {
|
|
@@ -141,9 +137,6 @@ export function SaveIndicator({ state, pending = false }) {
|
|
|
141
137
|
export function SectionLabel({ children, action, caps, style }) {
|
|
142
138
|
return (_jsxs("div", { className: cx("sm-sectionlabel", caps && "sm-sectionlabel--caps"), style: style, children: [_jsx("span", { className: "sm-label", children: children }), action && _jsx("span", { style: { color: "var(--accent)", fontWeight: 600, fontSize: "var(--text-sm)" }, children: action })] }));
|
|
143
139
|
}
|
|
144
|
-
export function Divider({ bleed = 0 }) {
|
|
145
|
-
return _jsx("div", { className: "sm-divider", style: { margin: `0 -${bleed}px` } });
|
|
146
|
-
}
|
|
147
140
|
/** The lines under a publish-validation banner: one per field, each focusing its widget
|
|
148
141
|
* when the screen can reveal it (spec 2026-09-14 §2). */
|
|
149
142
|
export function FieldErrorList({ fields, onReveal }) {
|
|
@@ -161,6 +154,11 @@ export function Banner({ tone, children, onDismiss, revealKey }) {
|
|
|
161
154
|
}, [revealKey]);
|
|
162
155
|
return (_jsxs("div", { ref: ref, className: `sm-banner sm-banner--${tone}`, role: tone === "error" ? "alert" : undefined, children: [_jsx("div", { style: { flex: 1, minWidth: 0 }, children: children }), onDismiss && (_jsx("button", { type: "button", onClick: onDismiss, "aria-label": "Dismiss", style: { background: "transparent", border: 0, color: "inherit", cursor: "pointer", font: "inherit", padding: 0 }, children: "\u00D7" }))] }));
|
|
163
156
|
}
|
|
157
|
+
/** An upload's progress (spec 2026-10-18 media on R2 and Images §9): a bar and the percentage of the bytes sent. */
|
|
158
|
+
export function UploadProgress({ fraction }) {
|
|
159
|
+
const pct = Math.round(Math.min(1, Math.max(0, fraction)) * 100);
|
|
160
|
+
return (_jsxs("div", { className: "sm-upload", role: "progressbar", "aria-label": "Uploading", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": pct, children: [_jsx("div", { className: "sm-upload__track", children: _jsx("div", { className: "sm-upload__fill", style: { width: `${pct}%` } }) }), _jsxs("span", { className: "sm-upload__pct", children: ["Uploading\u2026 ", pct, "%"] })] }));
|
|
161
|
+
}
|
|
164
162
|
export function cx(...parts) {
|
|
165
163
|
return parts.filter(Boolean).join(" ");
|
|
166
164
|
}
|
package/dist/admin/ui/theme.js
CHANGED
|
@@ -230,6 +230,8 @@ body:has(.sm-admin){margin:0}
|
|
|
230
230
|
lit tile a build in flight, the warning tile a build that has not landed or a site that did not answer. */
|
|
231
231
|
.sm-rail__deploy{margin-top:auto}
|
|
232
232
|
.sm-rail__deploy+.sm-rail__foot{margin-top:0}
|
|
233
|
+
/* A publish on its way to the site (plan 2026-10-14 publish through the queue): a quiet line above the Deploy tile
|
|
234
|
+
and the avatar, the warning colour when a delivery landed late or with an error. */
|
|
233
235
|
.sm-rail__badge{position:absolute;top:-4px;right:-6px;min-width:16px;height:16px;padding:0 4px;border-radius:var(--radius-round);background:var(--accent);color:#fff;font-size:10px;font-weight:600;line-height:16px;text-align:center}
|
|
234
236
|
.sm-rail__tile--lit{background:var(--accent-soft);color:var(--accent);animation:sm-rail-pulse 1.6s ease-in-out infinite}
|
|
235
237
|
.sm-rail__tile--warn{background:var(--warn-soft);color:var(--warn)}
|
|
@@ -681,7 +683,6 @@ body:has(.sm-admin){margin:0}
|
|
|
681
683
|
.sm-counter--over{color:var(--warn)}
|
|
682
684
|
/* The page glyph beside the page title in the empty-selection inspector head. */
|
|
683
685
|
.sm-pageglyph{width:10px;height:13px;border:1.5px solid var(--text-muted);border-radius:var(--radius-xs);flex:none}
|
|
684
|
-
.sm-divider{height:1px;background:var(--line);flex:none}
|
|
685
686
|
.sm-sectionlabel{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
|
686
687
|
/* The overline: a small uppercase tracked heading for a group — the
|
|
687
688
|
inspector's Spacing & layout / Advanced, the picker's Add to <zone> and
|
|
@@ -970,6 +971,11 @@ body:has(.sm-admin){margin:0}
|
|
|
970
971
|
}
|
|
971
972
|
/* The picker's drop bar: tall enough to aim a drag at (2026-09-25). */
|
|
972
973
|
.sm-dropzone--bar{padding:28px 10px;font-size:var(--text-sm)}
|
|
974
|
+
.sm-upload{display:flex;flex-direction:column;align-items:stretch;gap:6px;width:100%;max-width:280px;font-size:var(--text-sm);color:var(--text-muted)}
|
|
975
|
+
.sm-upload__track{height:6px;border-radius:999px;background:var(--surface-inset);overflow:hidden}
|
|
976
|
+
.sm-upload__fill{height:100%;background:var(--accent);border-radius:999px;transition:width 120ms linear}
|
|
977
|
+
.sm-upload__pct{font-variant-numeric:tabular-nums}
|
|
978
|
+
.sm-mediascreen__upload{padding:8px 20px 0}
|
|
973
979
|
.sm-mediagrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:10px}
|
|
974
980
|
.sm-mediacard{
|
|
975
981
|
display:flex;flex-direction:column;gap:4px;padding:6px;background:transparent;border:1.5px solid transparent;
|
|
@@ -999,7 +1005,7 @@ body:has(.sm-admin){margin:0}
|
|
|
999
1005
|
.sm-media__actions{display:flex;gap:6px}
|
|
1000
1006
|
.sm-media__chip{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--surface-inset);border-radius:var(--radius-btn)}
|
|
1001
1007
|
.sm-crop{display:grid;grid-template-columns:1fr 240px;gap:14px;min-height:0}
|
|
1002
|
-
.sm-crop__stage{position:relative;user-select:none;touch-action:none;background:var(--surface-inset);border-radius:var(--radius-md);overflow:hidden}
|
|
1008
|
+
.sm-crop__stage{position:relative;align-self:start;user-select:none;touch-action:none;background:var(--surface-inset);border-radius:var(--radius-md);overflow:hidden}
|
|
1003
1009
|
.sm-crop__stage>img{display:block;width:100%;height:auto;pointer-events:none}
|
|
1004
1010
|
.sm-crop__shade{position:absolute;inset:0;background:rgba(0,0,0,.45);pointer-events:none}
|
|
1005
1011
|
.sm-crop__rect{position:absolute;outline:1.5px solid #fff;box-shadow:0 0 0 1px rgba(0,0,0,.4);cursor:move;background:transparent}
|
|
@@ -1010,6 +1016,11 @@ body:has(.sm-admin){margin:0}
|
|
|
1010
1016
|
.sm-crop__side{display:flex;flex-direction:column;gap:10px}
|
|
1011
1017
|
.sm-crop__preview{position:relative;overflow:hidden;border-radius:var(--radius-md);background:var(--surface-inset)}
|
|
1012
1018
|
.sm-crop__preview>img{position:absolute;max-width:none;display:block}
|
|
1019
|
+
.sm-crop__ghost{position:absolute;outline:1.5px dashed rgba(255,255,255,.9);box-shadow:0 0 0 1px rgba(0,0,0,.35);pointer-events:none}
|
|
1020
|
+
.sm-crop__follows{margin-left:4px;color:var(--accent)}
|
|
1021
|
+
.sm-crop__previews{display:flex;gap:8px;align-items:flex-start}
|
|
1022
|
+
.sm-crop__figure{flex:1;min-width:0;margin:0;display:flex;flex-direction:column;gap:4px}
|
|
1023
|
+
.sm-crop__figure>figcaption{font-size:11px;color:var(--text-muted)}
|
|
1013
1024
|
.sm-mediascreen{display:grid;grid-template-columns:1fr 300px;column-gap:var(--gutter);flex:1;min-height:0}
|
|
1014
1025
|
.sm-mediascreen__grid{padding:var(--table-pad);overflow:auto;background:var(--surface-form);border-radius:var(--radius-card-lg)}
|
|
1015
1026
|
.sm-mediascreen__inspector{
|
package/dist/admin/validate.js
CHANGED
|
@@ -14,6 +14,7 @@ import { rowsProblems } from "../custom/rows.js";
|
|
|
14
14
|
import { isCalendarDay, isInstant } from "./forms/date-picker.js";
|
|
15
15
|
import { fits, misfitMessage } from "./drift.js";
|
|
16
16
|
import { isSegment } from "../paths.js";
|
|
17
|
+
import { isCrop } from "../media.js";
|
|
17
18
|
/** A slug outside the path grammar, at publish (spec 2026-09-28 ASCII slugs §3). */
|
|
18
19
|
export const SLUG_GRAMMAR_MESSAGE = "Use lowercase letters, digits and single hyphens, like my-post.";
|
|
19
20
|
const subfields = (d) => d.fields && typeof d.fields === "object" ? d.fields : {};
|
|
@@ -81,7 +82,15 @@ function validateValue(path, value, d, errors, elements, scope) {
|
|
|
81
82
|
errors.push({ field: path, message: "Not a date and time." });
|
|
82
83
|
return;
|
|
83
84
|
}
|
|
84
|
-
if (d.type === "image"
|
|
85
|
+
if (d.type === "image") {
|
|
86
|
+
// The crop window writes fractions inside the image (spec 2026-10-18 media on R2 and Images §6); the server cuts them.
|
|
87
|
+
const v = value;
|
|
88
|
+
const crops = v.crops === undefined ? [] : v.crops && typeof v.crops === "object" && !Array.isArray(v.crops) ? Object.values(v.crops) : [null];
|
|
89
|
+
if ((v.crop !== undefined && !isCrop(v.crop)) || crops.some((c) => !isCrop(c)))
|
|
90
|
+
errors.push({ field: path, message: "A crop lies outside the image." });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (d.type === "video" || d.type === "file")
|
|
85
94
|
return;
|
|
86
95
|
if (d.type === "list") {
|
|
87
96
|
if (!Array.isArray(value))
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "./config.ts";
|
|
2
|
+
import { type PushBody } from "./push.ts";
|
|
3
|
+
import { type ContentReads, type DeliveryReads } from "./site.ts";
|
|
4
|
+
export type SmoodlyApiOptions = {
|
|
5
|
+
/** Without a trailing slash: `https://api.smoodly.io`, locally `http://localhost:8787`. */
|
|
6
|
+
apiUrl: string;
|
|
7
|
+
spaceId: string;
|
|
8
|
+
/** The secret key on a server, the publishable key where only published content is read. */
|
|
9
|
+
key: string;
|
|
10
|
+
config: ResolvedConfig;
|
|
11
|
+
/** Test seam; default the global fetch. */
|
|
12
|
+
fetch?: typeof fetch;
|
|
13
|
+
/** The schema push before the first call (spec 2026-10-16 the control plane §4): default, under NODE_ENV=development,
|
|
14
|
+
* the config and the site's address as `live`; `false` sends none. A config change reloads the glue module in both
|
|
15
|
+
* dev servers, which makes a new client and so a new push; an unchanged registry costs the service nothing. */
|
|
16
|
+
push?: false | PushBody;
|
|
17
|
+
};
|
|
18
|
+
/** The content API refused a read: its status, its code (`unauthorized`, `not_found`, `validation`, `internal`) and its message. */
|
|
19
|
+
export declare class SmoodlyApiError extends Error {
|
|
20
|
+
readonly status: number;
|
|
21
|
+
readonly code: string;
|
|
22
|
+
constructor(status: number, code: string, message: string);
|
|
23
|
+
}
|
|
24
|
+
/** A site's reads over the content API: the same face as the service's own site, so the read layer and the shells run
|
|
25
|
+
* unchanged. Every call counts as one read for the adapters' counters. */
|
|
26
|
+
export declare function createSmoodlyApi(options: SmoodlyApiOptions): ContentReads & DeliveryReads;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// The site's reads over the content API (spec 2026-10-14 §1): one HTTP call per read, the key in the Authorization
|
|
2
|
+
// header, the envelope `{ ok, data, tags }`. The statics (cacheKey, styles, custom, code pages) are computed from the
|
|
3
|
+
// config exactly as the service's site computes them (site.ts siteStatics), so a cache key never disagrees across
|
|
4
|
+
// the wire. A document's tags ride on the hit (SiteHit.tags), where the read layer takes them.
|
|
5
|
+
import { countRead } from "./read-counter.js";
|
|
6
|
+
import { pushBody, pushSchema } from "./push.js";
|
|
7
|
+
import { siteOptions, siteStatics, } from "./site.js";
|
|
8
|
+
/** The content API refused a read: its status, its code (`unauthorized`, `not_found`, `validation`, `internal`) and its message. */
|
|
9
|
+
export class SmoodlyApiError extends Error {
|
|
10
|
+
constructor(status, code, message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.name = "SmoodlyApiError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** A site's reads over the content API: the same face as the service's own site, so the read layer and the shells run
|
|
18
|
+
* unchanged. Every call counts as one read for the adapters' counters. */
|
|
19
|
+
export function createSmoodlyApi(options) {
|
|
20
|
+
const apiUrl = options.apiUrl.replace(/\/+$/, "");
|
|
21
|
+
const base = `${apiUrl}/v1/spaces/${encodeURIComponent(options.spaceId)}`;
|
|
22
|
+
const doFetch = options.fetch ?? fetch;
|
|
23
|
+
const statics = siteStatics(siteOptions(options.config));
|
|
24
|
+
const localeOf = (o) => o?.locale ?? statics.locales.default;
|
|
25
|
+
const env = typeof process === "undefined" ? {} : process.env;
|
|
26
|
+
const push = options.push ?? (env.NODE_ENV === "development" ? pushBody(options.config, env, "live") : false);
|
|
27
|
+
/** One push per client, awaited by every call until it settles; its failure is logged, never thrown. */
|
|
28
|
+
let pushed = null;
|
|
29
|
+
const pushOnce = (body) => (pushed ?? (pushed = pushSchema({ apiUrl, spaceId: options.spaceId, key: options.key, fetch: doFetch }, body).then(() => undefined, (e) => console.error(`smoodly: the schema push failed — ${e instanceof Error ? e.message : String(e)}; the site runs against the schema the space has.`))));
|
|
30
|
+
async function call(path, params) {
|
|
31
|
+
if (push)
|
|
32
|
+
await pushOnce(push);
|
|
33
|
+
countRead();
|
|
34
|
+
const url = new URL(`${base}${path}`);
|
|
35
|
+
for (const [k, v] of Object.entries(params))
|
|
36
|
+
if (v !== undefined && v !== "")
|
|
37
|
+
url.searchParams.set(k, v);
|
|
38
|
+
let res;
|
|
39
|
+
try {
|
|
40
|
+
res = await doFetch(url.toString(), { headers: { authorization: `Bearer ${options.key}`, accept: "application/json" } });
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
throw new TypeError(`smoodly: can't reach SMOODLY_API_URL (${apiUrl}): ${e instanceof Error ? e.message : String(e)}`);
|
|
44
|
+
}
|
|
45
|
+
const body = (await res.json().catch(() => null));
|
|
46
|
+
if (!body || !body.ok) {
|
|
47
|
+
throw new SmoodlyApiError(res.status, body?.code ?? "internal", body?.message ?? `smoodly: SMOODLY_API_URL (${apiUrl}) answered ${res.status} with no message.`);
|
|
48
|
+
}
|
|
49
|
+
return { data: body.data, tags: body.tags };
|
|
50
|
+
}
|
|
51
|
+
const draftParam = (o) => ({ draft: o?.draft ? "1" : undefined });
|
|
52
|
+
/** The response's tags on a published hit (the read layer takes them from there); a draft carries none. */
|
|
53
|
+
const withTags = (hit, tags, draft) => hit && hit.kind !== "redirect" && !draft ? { ...hit, tags: hit.tags ?? tags } : hit;
|
|
54
|
+
return {
|
|
55
|
+
locales: statics.locales,
|
|
56
|
+
homePageSlug: statics.homePageSlug,
|
|
57
|
+
cacheKey: statics.cacheKey,
|
|
58
|
+
styles: statics.styles,
|
|
59
|
+
custom: statics.custom,
|
|
60
|
+
codePage: statics.codePage,
|
|
61
|
+
async locate(locale, path) {
|
|
62
|
+
return (await call("/locate", { locale, path })).data;
|
|
63
|
+
},
|
|
64
|
+
async targetPaths(target) {
|
|
65
|
+
return (await call("/target-paths", { kind: target.kind, id: target.id })).data;
|
|
66
|
+
},
|
|
67
|
+
async load(target, locale, path, o) {
|
|
68
|
+
const draft = !!o?.draft || !!o?.version;
|
|
69
|
+
const r = await call("/load", { kind: target.kind, id: target.id, locale, path, draft: o?.draft ? "1" : undefined, version: o?.version });
|
|
70
|
+
return withTags(r.data, r.tags, draft);
|
|
71
|
+
},
|
|
72
|
+
async lookup(locale, path) {
|
|
73
|
+
const r = await call("/lookup", { locale, path });
|
|
74
|
+
return withTags(r.data.hit, r.tags, false);
|
|
75
|
+
},
|
|
76
|
+
async lookupWithGlobals(locale, path, items) {
|
|
77
|
+
const r = await call("/lookup", { locale, path, globals: items.map((i) => i.name).join(",") });
|
|
78
|
+
return { hit: withTags(r.data.hit, r.tags, false), globals: new Map(Object.entries(r.data.globals)) };
|
|
79
|
+
},
|
|
80
|
+
async getPagesTree(o) {
|
|
81
|
+
return (await call("/pages", { locale: localeOf(o), ...draftParam(o) })).data;
|
|
82
|
+
},
|
|
83
|
+
async getPage(target, o) {
|
|
84
|
+
return (await call("/page", { locale: localeOf(o), target, ...draftParam(o) })).data;
|
|
85
|
+
},
|
|
86
|
+
async getEntries(collection, o = {}) {
|
|
87
|
+
const { locale: _l, draft: _d, ...query } = o;
|
|
88
|
+
const q = Object.keys(query).length ? JSON.stringify(query) : undefined;
|
|
89
|
+
return (await call(`/entries/${encodeURIComponent(collection)}`, { locale: localeOf(o), query: q, ...draftParam(o) })).data;
|
|
90
|
+
},
|
|
91
|
+
async getEntry(collection, target, o) {
|
|
92
|
+
return (await call(`/entry/${encodeURIComponent(collection)}`, { locale: localeOf(o), target, ...draftParam(o) })).data;
|
|
93
|
+
},
|
|
94
|
+
async getEntriesTree(collection, o) {
|
|
95
|
+
return (await call(`/entries-tree/${encodeURIComponent(collection)}`, { locale: localeOf(o), ...draftParam(o) })).data;
|
|
96
|
+
},
|
|
97
|
+
async getGlobal(item, o) {
|
|
98
|
+
return (await call(`/global/${encodeURIComponent(item.name)}`, { locale: localeOf(o), ...draftParam(o) })).data;
|
|
99
|
+
},
|
|
100
|
+
async addresses(locale) {
|
|
101
|
+
return (await call("/addresses", { locale })).data;
|
|
102
|
+
},
|
|
103
|
+
async newestPublishedAt() {
|
|
104
|
+
return (await call("/status", {})).data.newestPublishedAt;
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|