@bygghammar/cms-platform 0.47.0 → 0.49.0
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/CHANGELOG.md +15 -0
- package/package.json +26 -3
- package/scripts/create-cms-site.mjs +0 -0
- package/src/auth/browser.ts +154 -0
- package/src/auth/middleware.ts +36 -0
- package/src/cms/page-draft-store.ts +121 -0
- package/src/media/FocalImage.tsx +152 -0
- package/src/media/LazyVideo.tsx +202 -0
- package/src/media/MediaMetaProvider.tsx +33 -0
- package/src/media/use-media-upload.ts +90 -0
- package/src/runtime/error-capture.ts +84 -0
- package/src/runtime/error-page.ts +31 -0
- package/src/runtime/error-reporting.ts +61 -0
- package/src/server/auth-context.ts +58 -0
- package/src/server/clients.ts +64 -0
- package/src/server/env.ts +74 -0
- package/src/server/http/backup.ts +152 -0
- package/src/server/http/cms-content.ts +26 -0
- package/src/server/http/fonts.ts +38 -0
- package/src/server/http/health.ts +38 -0
- package/src/server/http/robots.ts +53 -0
- package/src/server/http/sitemap.ts +63 -0
- package/src/site/PageRenderer.tsx +151 -0
- package/src/site/routes.ts +350 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.49.0
|
|
4
|
+
|
|
5
|
+
- Public-site runtime and backend plumbing are package-owned. New `site/routes`: `createSiteRoutes(pages)` derives `resolveRoute`, `pageHeadMeta`, canonical/hreflang assembly and `sitemapIndexEntries`/`sitemapUrls` from a site's page registry, so every site gets identical SEO behaviour.
|
|
6
|
+
- New `site/PageRenderer`: `createPageRenderer(registry, fetchPublishedPage)` returns the published `PageRenderer`, the `DraftPageRenderer` and the page query options, keyed to a site's own block registry.
|
|
7
|
+
- New `server/env`, `server/clients` and `server/auth-context`: Supabase server key resolution (including named-key JSON bundles and the legacy service-role variable), the opaque `sb_` key `apikey` fetch shim, the lazy admin/public client factory and bearer-token auth-context resolution.
|
|
8
|
+
- New `auth/browser` (`createBrowserAuth`) and `auth/middleware` (`createAttachAuth`, `createRequireAuth`): the signed-out/configuration-error auth state model and the RPC bearer attachment, so a misconfigured backend can never blank the public shell.
|
|
9
|
+
- New HTTP handler cores under `server/http/*`: `robots`, `sitemap`, `health`, `fonts`, `backup` (analyze/estimate/export/import) and `cms-content`. Site route files drop to a `createFileRoute` calling the core.
|
|
10
|
+
- New `runtime/error-capture`, `runtime/error-page` and `runtime/error-reporting`: the out-of-band error capture with full cause chains, the dependency-free SSR failure page and the editor telemetry bridge.
|
|
11
|
+
|
|
12
|
+
## 0.48.0
|
|
13
|
+
|
|
14
|
+
- Public-site media runtime is package-owned: new `media/FocalImage` (focus-aware `<img>`, `MediaFocusProvider`, `useFocalStyle`, `useObjectPosition`), `media/MediaMetaProvider` (alt/OG metadata context) and `media/LazyVideo` (bandwidth- and mobile-safe autoplay video). Sites pass their own focus/meta query options and register nothing else.
|
|
15
|
+
- New `media/use-media-upload`: `useMediaUploadWith(uploadBundle, existing)` runs the shared optimize → upload → cache-invalidate pipeline with progress; only the storage call stays site-local.
|
|
16
|
+
- New `cms/page-draft-store`: `createPageDraftStore({ prefix, channel })` gives a site the localStorage + BroadcastChannel draft bridge the page builder and preview routes share.
|
|
17
|
+
|
|
3
18
|
## 0.47.0
|
|
4
19
|
|
|
5
20
|
- Media server logic is package-owned: new `server/media-focus-core` (focus-point map, desktop/mobile upsert, clear), `server/media-meta-core` (localized alt/title/description/credit read and write) and `server/media-replace-core` (width-matched "replace everywhere" with audit trail).
|
package/package.json
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.0",
|
|
4
4
|
"description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"sideEffects":
|
|
6
|
+
"sideEffects": [
|
|
7
|
+
"./src/runtime/error-capture.ts"
|
|
8
|
+
],
|
|
7
9
|
"license": "UNLICENSED",
|
|
8
10
|
"publishConfig": {
|
|
9
11
|
"access": "public"
|
|
@@ -170,7 +172,28 @@
|
|
|
170
172
|
"./server/media-focus-core": "./src/server/media-focus-core.ts",
|
|
171
173
|
"./server/media-meta-core": "./src/server/media-meta-core.ts",
|
|
172
174
|
"./server/media-replace-core": "./src/server/media-replace-core.ts",
|
|
173
|
-
"./server/media-cleanup-host": "./src/server/media-cleanup-host.ts"
|
|
175
|
+
"./server/media-cleanup-host": "./src/server/media-cleanup-host.ts",
|
|
176
|
+
"./media/FocalImage": "./src/media/FocalImage.tsx",
|
|
177
|
+
"./media/MediaMetaProvider": "./src/media/MediaMetaProvider.tsx",
|
|
178
|
+
"./media/LazyVideo": "./src/media/LazyVideo.tsx",
|
|
179
|
+
"./media/use-media-upload": "./src/media/use-media-upload.ts",
|
|
180
|
+
"./cms/page-draft-store": "./src/cms/page-draft-store.ts",
|
|
181
|
+
"./site/routes": "./src/site/routes.ts",
|
|
182
|
+
"./site/PageRenderer": "./src/site/PageRenderer.tsx",
|
|
183
|
+
"./server/env": "./src/server/env.ts",
|
|
184
|
+
"./server/clients": "./src/server/clients.ts",
|
|
185
|
+
"./server/auth-context": "./src/server/auth-context.ts",
|
|
186
|
+
"./auth/browser": "./src/auth/browser.ts",
|
|
187
|
+
"./auth/middleware": "./src/auth/middleware.ts",
|
|
188
|
+
"./server/http/robots": "./src/server/http/robots.ts",
|
|
189
|
+
"./server/http/sitemap": "./src/server/http/sitemap.ts",
|
|
190
|
+
"./server/http/health": "./src/server/http/health.ts",
|
|
191
|
+
"./server/http/fonts": "./src/server/http/fonts.ts",
|
|
192
|
+
"./server/http/backup": "./src/server/http/backup.ts",
|
|
193
|
+
"./server/http/cms-content": "./src/server/http/cms-content.ts",
|
|
194
|
+
"./runtime/error-capture": "./src/runtime/error-capture.ts",
|
|
195
|
+
"./runtime/error-page": "./src/runtime/error-page.ts",
|
|
196
|
+
"./runtime/error-reporting": "./src/runtime/error-reporting.ts"
|
|
174
197
|
},
|
|
175
198
|
"dependencies": {
|
|
176
199
|
"fflate": "^0.8.3"
|
|
File without changes
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser auth (site-agnostic).
|
|
3
|
+
*
|
|
4
|
+
* Wraps a lazily-loaded Supabase browser client so no component imports the
|
|
5
|
+
* client directly and anonymous public pages never initialise it.
|
|
6
|
+
*
|
|
7
|
+
* A site creates one instance:
|
|
8
|
+
* export const { getAuthUser, signInWithPassword, ... } =
|
|
9
|
+
* createBrowserAuth(() => import("@/integrations/supabase/client").then(m => m.supabase));
|
|
10
|
+
*/
|
|
11
|
+
type MinimalAuthClient = {
|
|
12
|
+
auth: {
|
|
13
|
+
getSession(): Promise<{ data: { session: { user?: AuthSessionUser | null } | null } }>;
|
|
14
|
+
onAuthStateChange(
|
|
15
|
+
cb: (event: string, session: { user?: AuthSessionUser | null } | null) => void,
|
|
16
|
+
): { data: { subscription: { unsubscribe(): void } } };
|
|
17
|
+
signInWithPassword(input: { email: string; password: string }): Promise<{
|
|
18
|
+
error: { message: string } | null;
|
|
19
|
+
}>;
|
|
20
|
+
signOut(): Promise<unknown>;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type AuthSessionUser = { id: string; email?: string | null };
|
|
25
|
+
|
|
26
|
+
export type AuthUser = { id: string; email: string | null };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Auth state model.
|
|
30
|
+
*
|
|
31
|
+
* `configuration_error` is reserved for a genuinely missing/invalid backend
|
|
32
|
+
* configuration. A missing token, expired session, hydration timeout, network
|
|
33
|
+
* blip or any signed-out state is NOT a configuration error.
|
|
34
|
+
*/
|
|
35
|
+
export type AuthStatus = "signed_in" | "signed_out" | "configuration_error";
|
|
36
|
+
|
|
37
|
+
export type BrowserAuth = {
|
|
38
|
+
getAuthUser: () => Promise<AuthUser | null>;
|
|
39
|
+
getAuthStatus: () => Promise<{ status: AuthStatus; user: AuthUser | null }>;
|
|
40
|
+
isAuthConfigBroken: () => boolean;
|
|
41
|
+
onAuthUserChange: (cb: (user: AuthUser | null) => void) => () => void;
|
|
42
|
+
signInWithPassword: (email: string, password: string) => Promise<{ error: string | null }>;
|
|
43
|
+
signOut: () => Promise<void>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function createBrowserAuth(loadClient: () => Promise<MinimalAuthClient>): BrowserAuth {
|
|
47
|
+
let configErrorLogged = false;
|
|
48
|
+
|
|
49
|
+
/** True when the failure is a real backend configuration problem. */
|
|
50
|
+
const isConfigurationError = (error: unknown): boolean => {
|
|
51
|
+
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
52
|
+
return /Missing Supabase environment variable/i.test(message);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Log a configuration failure once, without hiding it. */
|
|
56
|
+
const reportAuthConfigError = (error: unknown): boolean => {
|
|
57
|
+
if (!isConfigurationError(error)) return false;
|
|
58
|
+
if (!configErrorLogged) {
|
|
59
|
+
configErrorLogged = true;
|
|
60
|
+
console.error(
|
|
61
|
+
"[auth] Authentication is unavailable — the backend configuration is missing or invalid. Visitors are treated as signed out.",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const clearAuthConfigError = () => {
|
|
68
|
+
configErrorLogged = false;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const isAuthConfigBroken = () => configErrorLogged;
|
|
72
|
+
|
|
73
|
+
const getAuthUser = async (): Promise<AuthUser | null> => {
|
|
74
|
+
try {
|
|
75
|
+
const { data } = await (await loadClient()).auth.getSession();
|
|
76
|
+
clearAuthConfigError();
|
|
77
|
+
const user = data.session?.user;
|
|
78
|
+
return user ? { id: user.id, email: user.email ?? null } : null;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
// Non-configuration failures (expired token, network) mean signed out.
|
|
81
|
+
reportAuthConfigError(error);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const getAuthStatus = async (): Promise<{ status: AuthStatus; user: AuthUser | null }> => {
|
|
87
|
+
const user = await getAuthUser();
|
|
88
|
+
if (user) return { status: "signed_in", user };
|
|
89
|
+
return { status: isAuthConfigBroken() ? "configuration_error" : "signed_out", user: null };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const onAuthUserChange = (cb: (user: AuthUser | null) => void): (() => void) => {
|
|
93
|
+
let unsubscribe: (() => void) | null = null;
|
|
94
|
+
let cancelled = false;
|
|
95
|
+
void loadClient()
|
|
96
|
+
.then((client) => {
|
|
97
|
+
clearAuthConfigError();
|
|
98
|
+
const { data: sub } = client.auth.onAuthStateChange((_e, session) =>
|
|
99
|
+
cb(session?.user ? { id: session.user.id, email: session.user.email ?? null } : null),
|
|
100
|
+
);
|
|
101
|
+
if (cancelled) sub.subscription.unsubscribe();
|
|
102
|
+
else unsubscribe = () => sub.subscription.unsubscribe();
|
|
103
|
+
})
|
|
104
|
+
.catch((error: unknown) => {
|
|
105
|
+
// A misconfiguration must never reject unhandled and blank the public
|
|
106
|
+
// shell: log once and treat the visitor as signed out.
|
|
107
|
+
reportAuthConfigError(error);
|
|
108
|
+
cb(null);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return () => {
|
|
112
|
+
cancelled = true;
|
|
113
|
+
unsubscribe?.();
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const signInWithPassword = async (
|
|
118
|
+
email: string,
|
|
119
|
+
password: string,
|
|
120
|
+
): Promise<{ error: string | null }> => {
|
|
121
|
+
try {
|
|
122
|
+
// The login screen may need to initialise the client even though no token
|
|
123
|
+
// exists yet — that is a normal signed-out flow, not an error.
|
|
124
|
+
const { error } = await (await loadClient()).auth.signInWithPassword({ email, password });
|
|
125
|
+
clearAuthConfigError();
|
|
126
|
+
return { error: error?.message ?? null };
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (reportAuthConfigError(error)) {
|
|
129
|
+
return {
|
|
130
|
+
error:
|
|
131
|
+
"Authentication is not configured for this environment. Contact the administrator.",
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return { error: error instanceof Error ? error.message : "Sign-in failed" };
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const signOut = async (): Promise<void> => {
|
|
139
|
+
try {
|
|
140
|
+
await (await loadClient()).auth.signOut();
|
|
141
|
+
} catch (error) {
|
|
142
|
+
reportAuthConfigError(error);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
getAuthUser,
|
|
148
|
+
getAuthStatus,
|
|
149
|
+
isAuthConfigBroken,
|
|
150
|
+
onAuthUserChange,
|
|
151
|
+
signInWithPassword,
|
|
152
|
+
signOut,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-function auth middleware factories (site-agnostic).
|
|
3
|
+
*
|
|
4
|
+
* `createAttachAuth` attaches the Supabase bearer token to every RPC without
|
|
5
|
+
* ever being able to blank the app: a missing/unavailable auth client just
|
|
6
|
+
* sends the request unauthenticated and the server decides.
|
|
7
|
+
*
|
|
8
|
+
* `createRequireAuth` guards a protected server function. The context resolver
|
|
9
|
+
* is injected (and normally dynamically imported) so nothing server-only ends
|
|
10
|
+
* up in the client bundle.
|
|
11
|
+
*/
|
|
12
|
+
import { createMiddleware } from "@tanstack/react-start";
|
|
13
|
+
|
|
14
|
+
type TokenLoader = () => Promise<string | null | undefined>;
|
|
15
|
+
|
|
16
|
+
export function createAttachAuth(loadToken: TokenLoader) {
|
|
17
|
+
return createMiddleware({ type: "function" }).client(async ({ next }) => {
|
|
18
|
+
let headers: Record<string, string> = {};
|
|
19
|
+
try {
|
|
20
|
+
const token = await loadToken();
|
|
21
|
+
if (token) headers = { Authorization: `Bearer ${token}` };
|
|
22
|
+
} catch {
|
|
23
|
+
// No usable auth client — continue as an anonymous request.
|
|
24
|
+
}
|
|
25
|
+
return next({ headers });
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createRequireAuth<C extends Record<string, unknown>>(
|
|
30
|
+
resolveContext: () => Promise<C>,
|
|
31
|
+
) {
|
|
32
|
+
return createMiddleware({ type: "function" }).server(async ({ next }) => {
|
|
33
|
+
const context = await resolveContext();
|
|
34
|
+
return next({ context });
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-page draft store shared by every site.
|
|
3
|
+
*
|
|
4
|
+
* The page builder writes an unsaved snapshot to `localStorage` and broadcasts
|
|
5
|
+
* it so the preview route can render it live. Only the storage prefix and the
|
|
6
|
+
* broadcast channel name differ per site, so both are parameters.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type PageDraftSnapshot<TBlock = unknown> = {
|
|
10
|
+
blocks: TBlock[];
|
|
11
|
+
globals: Record<string, TBlock[]>;
|
|
12
|
+
locale?: string;
|
|
13
|
+
updatedAt: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type PageDraftStoreOptions = {
|
|
17
|
+
/** localStorage key prefix, e.g. `"voyara:draft:"`. */
|
|
18
|
+
prefix: string;
|
|
19
|
+
/** BroadcastChannel name, e.g. `"voyara-page-draft"`. */
|
|
20
|
+
channel: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type PageDraftStore<TBlock = unknown> = {
|
|
24
|
+
draftKey: (pageKey: string) => string;
|
|
25
|
+
writeDraft: (pageKey: string, snap: Omit<PageDraftSnapshot<TBlock>, "updatedAt">) => void;
|
|
26
|
+
readDraft: (pageKey: string) => PageDraftSnapshot<TBlock> | null;
|
|
27
|
+
clearDraft: (pageKey: string) => void;
|
|
28
|
+
subscribeDraft: (pageKey: string, cb: (snap: PageDraftSnapshot<TBlock>) => void) => () => void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function createPageDraftStore<TBlock = unknown>(
|
|
32
|
+
options: PageDraftStoreOptions,
|
|
33
|
+
): PageDraftStore<TBlock> {
|
|
34
|
+
const draftKey = (pageKey: string) => `${options.prefix}${pageKey}`;
|
|
35
|
+
|
|
36
|
+
const openChannel = (): BroadcastChannel | null => {
|
|
37
|
+
if (typeof window === "undefined" || typeof BroadcastChannel === "undefined") return null;
|
|
38
|
+
try {
|
|
39
|
+
return new BroadcastChannel(options.channel);
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const isSnapshot = (value: unknown): value is PageDraftSnapshot<TBlock> =>
|
|
46
|
+
!!value && Array.isArray((value as PageDraftSnapshot<TBlock>).blocks);
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
draftKey,
|
|
50
|
+
|
|
51
|
+
writeDraft(pageKey, snap) {
|
|
52
|
+
if (typeof window === "undefined") return;
|
|
53
|
+
const payload: PageDraftSnapshot<TBlock> = { ...snap, updatedAt: Date.now() };
|
|
54
|
+
try {
|
|
55
|
+
window.localStorage.setItem(draftKey(pageKey), JSON.stringify(payload));
|
|
56
|
+
} catch {
|
|
57
|
+
/* quota or disabled storage — preview falls back to published */
|
|
58
|
+
}
|
|
59
|
+
const bc = openChannel();
|
|
60
|
+
if (bc) {
|
|
61
|
+
try {
|
|
62
|
+
bc.postMessage({ pageKey, snapshot: payload });
|
|
63
|
+
} finally {
|
|
64
|
+
bc.close();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
readDraft(pageKey) {
|
|
70
|
+
if (typeof window === "undefined") return null;
|
|
71
|
+
try {
|
|
72
|
+
const raw = window.localStorage.getItem(draftKey(pageKey));
|
|
73
|
+
if (!raw) return null;
|
|
74
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
75
|
+
return isSnapshot(parsed) ? parsed : null;
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
clearDraft(pageKey) {
|
|
82
|
+
if (typeof window === "undefined") return;
|
|
83
|
+
try {
|
|
84
|
+
window.localStorage.removeItem(draftKey(pageKey));
|
|
85
|
+
} catch {
|
|
86
|
+
/* ignore */
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
subscribeDraft(pageKey, cb) {
|
|
91
|
+
if (typeof window === "undefined") return () => {};
|
|
92
|
+
|
|
93
|
+
const onStorage = (e: StorageEvent) => {
|
|
94
|
+
if (e.key !== draftKey(pageKey) || !e.newValue) return;
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(e.newValue) as unknown;
|
|
97
|
+
if (isSnapshot(parsed)) cb(parsed);
|
|
98
|
+
} catch {
|
|
99
|
+
/* ignore */
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
window.addEventListener("storage", onStorage);
|
|
103
|
+
|
|
104
|
+
const bc = openChannel();
|
|
105
|
+
const onMsg = (ev: MessageEvent) => {
|
|
106
|
+
const d = ev.data as { pageKey?: string; snapshot?: unknown } | undefined;
|
|
107
|
+
if (!d || d.pageKey !== pageKey) return;
|
|
108
|
+
if (isSnapshot(d.snapshot)) cb(d.snapshot);
|
|
109
|
+
};
|
|
110
|
+
bc?.addEventListener("message", onMsg);
|
|
111
|
+
|
|
112
|
+
return () => {
|
|
113
|
+
window.removeEventListener("storage", onStorage);
|
|
114
|
+
if (bc) {
|
|
115
|
+
bc.removeEventListener("message", onMsg);
|
|
116
|
+
bc.close();
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useState,
|
|
6
|
+
type CSSProperties,
|
|
7
|
+
type ImgHTMLAttributes,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { useQuery } from "@tanstack/react-query";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
focalCssVars,
|
|
14
|
+
focalToObjectPosition,
|
|
15
|
+
lookupEntry,
|
|
16
|
+
lookupFocal,
|
|
17
|
+
type FocusMap,
|
|
18
|
+
} from "./focus";
|
|
19
|
+
import { responsiveImage } from "./srcset";
|
|
20
|
+
|
|
21
|
+
const FocusContext = createContext<FocusMap>({});
|
|
22
|
+
|
|
23
|
+
/** Structural shape of a site's `queryOptions(...)` for its focus map. */
|
|
24
|
+
export type FocusQueryOptions = {
|
|
25
|
+
queryKey: readonly unknown[];
|
|
26
|
+
queryFn: () => Promise<FocusMap>;
|
|
27
|
+
staleTime?: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Provides the site's saved focus points to every `FocalImage` below it.
|
|
32
|
+
* The query itself is site infrastructure and is passed in.
|
|
33
|
+
*/
|
|
34
|
+
export function MediaFocusProvider({
|
|
35
|
+
queryOptions,
|
|
36
|
+
children,
|
|
37
|
+
}: {
|
|
38
|
+
queryOptions: FocusQueryOptions;
|
|
39
|
+
children: ReactNode;
|
|
40
|
+
}) {
|
|
41
|
+
const { data } = useQuery({ ...queryOptions, placeholderData: {} as FocusMap });
|
|
42
|
+
return <FocusContext.Provider value={data ?? {}}>{children}</FocusContext.Provider>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function useFocusMap(): FocusMap {
|
|
46
|
+
return useContext(FocusContext);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `object-position` for a given image URL, honouring its saved focus point. */
|
|
50
|
+
export function useObjectPosition(src: string | undefined): string {
|
|
51
|
+
const map = useFocusMap();
|
|
52
|
+
return focalToObjectPosition(lookupFocal(map, src));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Class + inline CSS vars that apply the desktop focus point and, when set,
|
|
57
|
+
* a separate mobile focus point (below 768px).
|
|
58
|
+
*/
|
|
59
|
+
export function useFocalStyle(src: string | undefined): {
|
|
60
|
+
className: string;
|
|
61
|
+
style: CSSProperties;
|
|
62
|
+
} {
|
|
63
|
+
const map = useFocusMap();
|
|
64
|
+
return {
|
|
65
|
+
className: "focal-pos",
|
|
66
|
+
style: focalCssVars(lookupEntry(map, src)) as CSSProperties,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type FocalImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & {
|
|
71
|
+
src: string | undefined;
|
|
72
|
+
/** When set, builds a responsive srcSet from the media library variants. */
|
|
73
|
+
sizes?: string;
|
|
74
|
+
responsive?: boolean;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `<img>` that always crops around the image's editor-chosen focus point.
|
|
79
|
+
* Falls back to a centred crop when no focus point is stored.
|
|
80
|
+
*
|
|
81
|
+
* When the source is a responsive WebP variant with a `-fallback.png` sibling,
|
|
82
|
+
* renders a `<picture>` so browsers that do not support WebP (or need explicit
|
|
83
|
+
* alpha handling) still receive a transparent PNG.
|
|
84
|
+
*/
|
|
85
|
+
export function FocalImage({
|
|
86
|
+
src,
|
|
87
|
+
sizes,
|
|
88
|
+
responsive = true,
|
|
89
|
+
style,
|
|
90
|
+
onError,
|
|
91
|
+
...rest
|
|
92
|
+
}: FocalImageProps) {
|
|
93
|
+
const focal = useFocalStyle(src);
|
|
94
|
+
// Uploads of small source images skip the intermediate widths, so a candidate
|
|
95
|
+
// in the srcSet can be missing. If the browser picks one of those, fall back
|
|
96
|
+
// to the stored URL on its own — never leave a blank space on the page.
|
|
97
|
+
const [srcSetFailed, setSrcSetFailed] = useState(false);
|
|
98
|
+
useEffect(() => setSrcSetFailed(false), [src]);
|
|
99
|
+
|
|
100
|
+
const img = responsive && !srcSetFailed ? responsiveImage(src, sizes) : { src: src ?? "" };
|
|
101
|
+
const merged: CSSProperties = { ...focal.style, ...style };
|
|
102
|
+
const className = [focal.className, rest.className].filter(Boolean).join(" ");
|
|
103
|
+
|
|
104
|
+
// Only wrap in <picture> when a fallback PNG is advertised. The fallback URL
|
|
105
|
+
// is always derived, but it only exists for transparent uploads; using it for
|
|
106
|
+
// every image would cause unnecessary 404s on opaque assets.
|
|
107
|
+
const hasFallback =
|
|
108
|
+
"fallbackSrc" in img &&
|
|
109
|
+
img.fallbackSrc &&
|
|
110
|
+
// A missing fallback is indistinguishable from an opaque asset, so we only
|
|
111
|
+
// render the <source> when the primary URL is definitely a responsive WebP.
|
|
112
|
+
img.type === "image/webp" &&
|
|
113
|
+
img.srcSet;
|
|
114
|
+
|
|
115
|
+
if (hasFallback && "fallbackSrc" in img) {
|
|
116
|
+
// The <picture> is only a source selector: it must not take the layout or
|
|
117
|
+
// focus classes, or the inner <img> loses `object-position` and the crop
|
|
118
|
+
// ignores the editor-chosen focus point.
|
|
119
|
+
return (
|
|
120
|
+
<picture style={{ display: "contents" }}>
|
|
121
|
+
<source srcSet={img.srcSet} sizes={img.sizes} type="image/webp" />
|
|
122
|
+
<img
|
|
123
|
+
{...rest}
|
|
124
|
+
className={className}
|
|
125
|
+
src={img.fallbackSrc}
|
|
126
|
+
alt={rest.alt}
|
|
127
|
+
style={merged}
|
|
128
|
+
onError={(event) => {
|
|
129
|
+
if (!srcSetFailed) setSrcSetFailed(true);
|
|
130
|
+
onError?.(event);
|
|
131
|
+
}}
|
|
132
|
+
/>
|
|
133
|
+
</picture>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<img
|
|
139
|
+
{...rest}
|
|
140
|
+
className={className}
|
|
141
|
+
src={img.src}
|
|
142
|
+
srcSet={"srcSet" in img ? img.srcSet : undefined}
|
|
143
|
+
sizes={"sizes" in img ? img.sizes : undefined}
|
|
144
|
+
alt={rest.alt}
|
|
145
|
+
style={merged}
|
|
146
|
+
onError={(event) => {
|
|
147
|
+
if (!srcSetFailed) setSrcSetFailed(true);
|
|
148
|
+
onError?.(event);
|
|
149
|
+
}}
|
|
150
|
+
/>
|
|
151
|
+
);
|
|
152
|
+
}
|