@bygghammar/cms-platform 0.46.1 → 0.48.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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.48.0
4
+
5
+ - 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.
6
+ - 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.
7
+ - New `cms/page-draft-store`: `createPageDraftStore({ prefix, channel })` gives a site the localStorage + BroadcastChannel draft bridge the page builder and preview routes share.
8
+
9
+ ## 0.47.0
10
+
11
+ - 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).
12
+ - New `server/media-cleanup-host`: `createSupabaseCleanupHost()` assembles the bin rows, `media_meta`/`media_focus` path moves and the cleanup audit entry, so a site only supplies its privileged client, storage and reference rewriter.
13
+
3
14
  ## 0.46.1
4
15
 
5
16
  - Page builder host contract: `renderEditor` now also receives `initialBlocks` (the server baseline) alongside the live canvas.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.46.1",
3
+ "version": "0.48.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
6
  "sideEffects": false,
@@ -166,7 +166,16 @@
166
166
  "./backoffice/ResetPasswordView": "./src/backoffice/ResetPasswordView.tsx",
167
167
  "./backoffice/routes/pages": "./src/backoffice/routes/pages.tsx",
168
168
  "./config/define-site": "./src/config/define-site.ts",
169
- "./server/schema-drift": "./src/server/schema-drift.ts"
169
+ "./server/schema-drift": "./src/server/schema-drift.ts",
170
+ "./server/media-focus-core": "./src/server/media-focus-core.ts",
171
+ "./server/media-meta-core": "./src/server/media-meta-core.ts",
172
+ "./server/media-replace-core": "./src/server/media-replace-core.ts",
173
+ "./server/media-cleanup-host": "./src/server/media-cleanup-host.ts",
174
+ "./media/FocalImage": "./src/media/FocalImage.tsx",
175
+ "./media/MediaMetaProvider": "./src/media/MediaMetaProvider.tsx",
176
+ "./media/LazyVideo": "./src/media/LazyVideo.tsx",
177
+ "./media/use-media-upload": "./src/media/use-media-upload.ts",
178
+ "./cms/page-draft-store": "./src/cms/page-draft-store.ts"
170
179
  },
171
180
  "dependencies": {
172
181
  "fflate": "^0.8.3"
@@ -243,4 +252,4 @@
243
252
  "cms-platform-migrations": "scripts/sync-migrations.mjs",
244
253
  "create-cms-site": "./scripts/create-cms-site.mjs"
245
254
  }
246
- }
255
+ }
@@ -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
+ }
@@ -0,0 +1,202 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ import { useT } from "../i18n/context";
4
+ import { resolveMediaSrc } from "./urls";
5
+
6
+ /**
7
+ * Bandwidth-safe, mobile-safe <video> wrapper.
8
+ *
9
+ * Mobile browsers (iOS Safari in particular) only start an inline autoplaying
10
+ * video when `muted`, `playsinline` and `autoplay` are present as real
11
+ * attributes on the element that owns the media, and when at least one source
12
+ * is present when the element is parsed. Sources are therefore always
13
+ * rendered — the download itself stays cheap because `preload` is `none`
14
+ * until the element is near the viewport.
15
+ *
16
+ * - the poster paints first (cheap image, correct visual design),
17
+ * - `preload` is upgraded to the caller's hint once near the viewport,
18
+ * - a timeout fallback guarantees the video is never left permanently
19
+ * unloaded when IntersectionObserver never fires,
20
+ * - `play()` is called explicitly and its promise is always caught: a refused
21
+ * autoplay never crashes the page, it reveals an accessible tap-to-play
22
+ * button over the poster,
23
+ * - reduced motion keeps the poster and the tap-to-play button instead of
24
+ * removing the media.
25
+ */
26
+ export type LazyVideoProps = {
27
+ src?: string;
28
+ /** Optional alternative sources (e.g. WebM) tried before `src`. */
29
+ sources?: Array<{ src: string; type: string }>;
30
+ poster?: string;
31
+ className?: string;
32
+ autoPlay?: boolean;
33
+ loop?: boolean;
34
+ muted?: boolean;
35
+ controls?: boolean;
36
+ /** Preload hint used once the element is near the viewport. */
37
+ preload?: "none" | "metadata" | "auto";
38
+ ariaLabel?: string;
39
+ /** Localized accessible label for the tap-to-play fallback control. */
40
+ playLabel?: { sv: string } & Record<string, string>;
41
+ };
42
+
43
+ export function prefersReducedMotion(): boolean {
44
+ if (typeof window === "undefined" || !window.matchMedia) return false;
45
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
46
+ }
47
+
48
+ /**
49
+ * Order the sources so the broadly compatible MP4/H.264 rendition is always
50
+ * offered. Safari picks the first source it claims to support, and some iOS
51
+ * versions claim WebM support they cannot actually decode, so MP4 goes first.
52
+ */
53
+ export function orderedSources(
54
+ rawSrc: string | undefined,
55
+ rawSources: Array<{ src: string; type: string }> | undefined,
56
+ ): Array<{ src: string; type: string }> {
57
+ const src = resolveMediaSrc(rawSrc);
58
+ const sources = rawSources?.map((s) => ({ ...s, src: resolveMediaSrc(s.src) }));
59
+ const list =
60
+ sources && sources.length ? [...sources] : src ? [{ src, type: guessType(src) }] : [];
61
+ const seen = new Set<string>();
62
+ const unique = list.filter((s) => {
63
+ if (!s.src || seen.has(s.src)) return false;
64
+ seen.add(s.src);
65
+ return true;
66
+ });
67
+ if (src && !seen.has(src)) unique.push({ src, type: guessType(src) });
68
+ const rank = (type: string) => (/mp4/i.test(type) ? 0 : /webm/i.test(type) ? 1 : 2);
69
+ return unique.sort((a, b) => rank(a.type) - rank(b.type));
70
+ }
71
+
72
+ function guessType(url: string): string {
73
+ const clean = url.split("?")[0]!.toLowerCase();
74
+ if (clean.endsWith(".webm")) return "video/webm";
75
+ if (clean.endsWith(".mov")) return "video/quicktime";
76
+ return "video/mp4";
77
+ }
78
+
79
+ export function LazyVideo({
80
+ src,
81
+ sources,
82
+ poster,
83
+ className,
84
+ autoPlay = true,
85
+ loop = true,
86
+ muted = true,
87
+ controls = false,
88
+ preload = "metadata",
89
+ ariaLabel,
90
+ playLabel,
91
+ }: LazyVideoProps) {
92
+ const { t } = useT();
93
+ const ref = useRef<HTMLVideoElement>(null);
94
+ const [active, setActive] = useState(false);
95
+ const [reduced, setReduced] = useState(false);
96
+ const [blocked, setBlocked] = useState(false);
97
+
98
+ const list = orderedSources(src, sources);
99
+
100
+ useEffect(() => {
101
+ setReduced(prefersReducedMotion());
102
+ }, []);
103
+
104
+ useEffect(() => {
105
+ if (active) return;
106
+ const el = ref.current;
107
+ if (!el) return;
108
+ if (typeof IntersectionObserver === "undefined") {
109
+ setActive(true);
110
+ return;
111
+ }
112
+ const io = new IntersectionObserver(
113
+ (entries) => {
114
+ if (entries.some((e) => e.isIntersecting)) {
115
+ setActive(true);
116
+ io.disconnect();
117
+ }
118
+ },
119
+ { rootMargin: "200px" },
120
+ );
121
+ io.observe(el);
122
+ // Never leave the video permanently unloaded (mobile browsers can skip
123
+ // observer callbacks for elements inside transformed/animated ancestors).
124
+ const timer = setTimeout(() => setActive(true), 3000);
125
+ return () => {
126
+ io.disconnect();
127
+ clearTimeout(timer);
128
+ };
129
+ }, [active]);
130
+
131
+ // Explicit playback attempt: a refused promise must never propagate.
132
+ useEffect(() => {
133
+ const el = ref.current;
134
+ if (!el || !active) return;
135
+ if (!autoPlay || reduced) return;
136
+ let cancelled = false;
137
+ el.muted = muted;
138
+ const attempt = el.play();
139
+ if (attempt && typeof attempt.catch === "function") {
140
+ attempt
141
+ .then(() => {
142
+ if (!cancelled) setBlocked(false);
143
+ })
144
+ .catch(() => {
145
+ if (!cancelled) setBlocked(true);
146
+ });
147
+ }
148
+ return () => {
149
+ cancelled = true;
150
+ };
151
+ }, [active, autoPlay, muted, reduced]);
152
+
153
+ const play = useCallback(() => {
154
+ const el = ref.current;
155
+ if (!el) return;
156
+ setActive(true);
157
+ el.muted = muted;
158
+ const attempt = el.play();
159
+ if (attempt && typeof attempt.catch === "function") {
160
+ attempt.then(() => setBlocked(false)).catch(() => setBlocked(true));
161
+ }
162
+ }, [muted]);
163
+
164
+ const showPlayButton = !controls && (blocked || (reduced && autoPlay));
165
+
166
+ return (
167
+ <>
168
+ <video
169
+ ref={ref}
170
+ className={className}
171
+ poster={resolveMediaSrc(poster) || undefined}
172
+ preload={active ? preload : "none"}
173
+ autoPlay={autoPlay && !reduced}
174
+ loop={loop}
175
+ muted={muted}
176
+ controls={controls}
177
+ playsInline
178
+ aria-label={ariaLabel}
179
+ >
180
+ {list.map((s) => (
181
+ <source key={s.src} src={s.src} type={s.type} />
182
+ ))}
183
+ </video>
184
+ {showPlayButton ? (
185
+ <button
186
+ type="button"
187
+ onClick={play}
188
+ aria-label={t(
189
+ playLabel ?? { sv: ariaLabel ? `Spela upp ${ariaLabel}` : "Spela upp video" },
190
+ )}
191
+ className="absolute inset-0 z-10 grid place-items-center bg-transparent"
192
+ >
193
+ <span className="grid size-14 place-items-center rounded-full bg-white/85 text-black shadow-lg">
194
+ <svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true">
195
+ <path d="M8 5v14l11-7z" />
196
+ </svg>
197
+ </span>
198
+ </button>
199
+ ) : null}
200
+ </>
201
+ );
202
+ }
@@ -0,0 +1,33 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+ import { useQuery } from "@tanstack/react-query";
3
+
4
+ import { lookupMediaMeta, type MediaMeta, type MediaMetaMap } from "./media-meta";
5
+
6
+ const MediaMetaContext = createContext<MediaMetaMap>({});
7
+
8
+ /** Structural shape of a site's `queryOptions(...)` for its media metadata. */
9
+ export type MediaMetaQueryOptions = {
10
+ queryKey: readonly unknown[];
11
+ queryFn: () => Promise<MediaMetaMap>;
12
+ staleTime?: number;
13
+ };
14
+
15
+ export function MediaMetaProvider({
16
+ queryOptions,
17
+ children,
18
+ }: {
19
+ queryOptions: MediaMetaQueryOptions;
20
+ children: ReactNode;
21
+ }) {
22
+ const { data } = useQuery({ ...queryOptions, placeholderData: {} as MediaMetaMap });
23
+ return <MediaMetaContext.Provider value={data ?? {}}>{children}</MediaMetaContext.Provider>;
24
+ }
25
+
26
+ export function useMediaMetaMap(): MediaMetaMap {
27
+ return useContext(MediaMetaContext);
28
+ }
29
+
30
+ /** Saved metadata (alt / OG title / description / credit) for one image URL. */
31
+ export function useMediaMeta(src: string | undefined): MediaMeta {
32
+ return lookupMediaMeta(useMediaMetaMap(), src);
33
+ }
@@ -0,0 +1,90 @@
1
+ import { useCallback, useState } from "react";
2
+ import { useQueryClient } from "@tanstack/react-query";
3
+
4
+ import { optimizeForUpload } from "./optimize";
5
+ import { pickVideoPoster } from "./pick-video-poster";
6
+ import { cleanFileName, splitExt, uniqueFileName } from "./filename";
7
+
8
+ export type UploadProgress = { label: string; percent: number } | null;
9
+
10
+ /** One file in a site's media library, as far as uploading is concerned. */
11
+ export type UploadExistingItem = { name: string };
12
+
13
+ /** The site's server call that stores an already-optimized bundle. */
14
+ export type UploadBundleFn = (input: {
15
+ data: {
16
+ files: Array<{ name: string; contentType: string; bytes: number[] }>;
17
+ };
18
+ }) => Promise<{ urls: string[] }>;
19
+
20
+ /**
21
+ * Shared browser-side media upload (optimize → storage → cache invalidate)
22
+ * with a coarse percentage so callers can render a progress bar.
23
+ *
24
+ * The storage call itself is site infrastructure and is injected.
25
+ */
26
+ export function useMediaUploadWith(
27
+ uploadBundle: UploadBundleFn,
28
+ existing: UploadExistingItem[] = [],
29
+ ) {
30
+ const queryClient = useQueryClient();
31
+ const [progress, setProgress] = useState<UploadProgress>(null);
32
+ const [uploading, setUploading] = useState(false);
33
+
34
+ const upload = useCallback(
35
+ async (files: FileList | File[] | null): Promise<string[]> => {
36
+ const arr = files ? Array.from(files as ArrayLike<File>) : [];
37
+ if (!arr.length) return [];
38
+ setUploading(true);
39
+ const urls: string[] = [];
40
+ const taken = new Set<string>(
41
+ existing.flatMap((i) => {
42
+ const { base } = splitExt(i.name);
43
+ return [i.name, base, base.replace(/-(?:480|768|1200|1920)$/, "")];
44
+ }),
45
+ );
46
+ try {
47
+ for (let idx = 0; idx < arr.length; idx++) {
48
+ const file = arr[idx]!;
49
+ const step = (frac: number, label: string) =>
50
+ setProgress({
51
+ label: `${label} ${idx + 1}/${arr.length}: ${file.name}`,
52
+ percent: Math.round(((idx + frac) / arr.length) * 100),
53
+ });
54
+
55
+ // Videos: let the editor pick the poster frame before we transcode.
56
+ const posterTime = file.type.startsWith("video/") ? await pickVideoPoster(file) : null;
57
+
58
+ step(0.05, "Optimizing");
59
+ const { base } = splitExt(cleanFileName(file.name));
60
+ const baseSlug = uniqueFileName(base, taken);
61
+ taken.add(baseSlug);
62
+ const bundle = await optimizeForUpload(file, baseSlug, {
63
+ posterTime: posterTime ?? undefined,
64
+ });
65
+
66
+ step(0.4, "Uploading");
67
+ const all = [bundle.primary, ...bundle.extras];
68
+ const payload = await Promise.all(
69
+ all.map(async (part) => ({
70
+ name: part.name,
71
+ contentType: part.contentType,
72
+ bytes: Array.from(new Uint8Array(await part.blob.arrayBuffer())),
73
+ })),
74
+ );
75
+ const { urls: uploaded } = await uploadBundle({ data: { files: payload } });
76
+ urls.push(uploaded[0]!);
77
+ step(1, "Uploading");
78
+ }
79
+ await queryClient.invalidateQueries({ queryKey: ["media-library"] });
80
+ return urls;
81
+ } finally {
82
+ setUploading(false);
83
+ setProgress(null);
84
+ }
85
+ },
86
+ [existing, queryClient, uploadBundle],
87
+ );
88
+
89
+ return { upload, uploading, progress };
90
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Supabase-backed `CleanupHost` for the media cleanup core.
3
+ *
4
+ * Every site stores media bookkeeping in the same three tables (`media_trash`,
5
+ * `media_meta`, `media_focus`) and the same `audit_logs` table, so the database
6
+ * side effects the cleanup core needs are platform logic, not site logic. A host
7
+ * supplies its privileged client, its storage provider and its reference
8
+ * rewriter; this factory assembles the rest.
9
+ */
10
+ import { actorEmail, type PlatformAuthContext } from "./guards";
11
+ import type { CleanupHost, CleanupStorage, MediaCleanupResult } from "./media-cleanup-core";
12
+
13
+ /* eslint-disable @typescript-eslint/no-explicit-any */
14
+
15
+ const PATH_TABLES = ["media_meta", "media_focus"] as const;
16
+
17
+ export type CleanupHostDeps = {
18
+ context: PlatformAuthContext;
19
+ /** Privileged client used for the bin rows, path moves and the audit entry. */
20
+ admin: any;
21
+ /** Storage bound to the privileged client (usually `getMediaStorage(admin)`). */
22
+ storage: CleanupStorage;
23
+ /** Repoint stored references from any of `oldUrls` to `newUrl`; returns hits. */
24
+ rewriteReferences: (oldUrls: string[], newUrl: string) => Promise<number>;
25
+ };
26
+
27
+ export function createSupabaseCleanupHost(deps: CleanupHostDeps): CleanupHost {
28
+ const { admin, context } = deps;
29
+
30
+ return {
31
+ storage: deps.storage,
32
+ rewriteReferences: deps.rewriteReferences,
33
+
34
+ trashDuplicate: async ({ trashId, path, trashPrefix, size, mergedInto }) => {
35
+ await admin.from("media_trash").insert({
36
+ id: trashId,
37
+ original_path: path,
38
+ paths: [path],
39
+ trash_prefix: trashPrefix,
40
+ size_bytes: size,
41
+ content_type: null,
42
+ deleted_by: context.userId,
43
+ deleted_by_email: actorEmail(context),
44
+ usage_snapshot: { mergedInto },
45
+ });
46
+ for (const table of PATH_TABLES) {
47
+ await admin.from(table).delete().eq("path", path);
48
+ }
49
+ },
50
+
51
+ movePathRows: async (from: string, to: string) => {
52
+ for (const table of PATH_TABLES) {
53
+ // A row already sitting on the destination path wins; drop the stale one
54
+ // instead of violating the unique path constraint.
55
+ const { data: existing } = await admin
56
+ .from(table)
57
+ .select("path")
58
+ .eq("path", to)
59
+ .maybeSingle();
60
+ if (existing) {
61
+ await admin.from(table).delete().eq("path", from);
62
+ continue;
63
+ }
64
+ await admin.from(table).update({ path: to }).eq("path", from);
65
+ }
66
+ },
67
+
68
+ logCleanup: async (result: MediaCleanupResult) => {
69
+ await admin.from("audit_logs").insert({
70
+ actor_id: context.userId,
71
+ actor_email: actorEmail(context),
72
+ action: "media.cleanup",
73
+ entity: "media",
74
+ entity_id: "bulk",
75
+ meta: result,
76
+ });
77
+ },
78
+ };
79
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Host-agnostic focus-point (focal crop) server logic.
3
+ *
4
+ * Owns the whole `media_focus` lifecycle: the public read of every stored
5
+ * point, the desktop/mobile upsert and the clear. Hosts supply a privileged
6
+ * client for the public read (the table is staff-only through row-level
7
+ * security, but the coordinates themselves are needed to render the site) and
8
+ * their authenticated request context for the writes, so RLS applies as the
9
+ * signed-in user.
10
+ */
11
+ import { assertStaff, type PlatformAuthContext } from "./guards";
12
+ import { clampFocal, focusKey, type FocusDevice, type FocusMap } from "../media/focus";
13
+
14
+ /* eslint-disable @typescript-eslint/no-explicit-any */
15
+
16
+ /** Privileged-client factory the public read needs. */
17
+ export type FocusAdmin = () => Promise<any>;
18
+
19
+ /** Authenticated request context the writes run under. */
20
+ export type FocusContext = PlatformAuthContext & { supabase: any };
21
+
22
+ export type SetFocusInput = { src: string; x: number; y: number; device?: FocusDevice };
23
+ export type ClearFocusInput = { src: string; device?: FocusDevice };
24
+
25
+ export type FocusWriteResult = {
26
+ ok: true;
27
+ path?: string;
28
+ device?: FocusDevice;
29
+ x?: number;
30
+ y?: number;
31
+ };
32
+
33
+ /**
34
+ * Every stored focus point, keyed by canonical media path. Returns an empty map
35
+ * rather than throwing so a failed read never breaks page rendering.
36
+ */
37
+ export async function getMediaFocusMapCore(admin: FocusAdmin): Promise<FocusMap> {
38
+ const db = await admin();
39
+ const { data, error } = await db
40
+ .from("media_focus")
41
+ .select("path, focal_x, focal_y, mobile_x, mobile_y");
42
+ if (error) return {};
43
+
44
+ const map: FocusMap = {};
45
+ for (const row of (data ?? []) as {
46
+ path: string;
47
+ focal_x: number;
48
+ focal_y: number;
49
+ mobile_x: number | null;
50
+ mobile_y: number | null;
51
+ }[]) {
52
+ map[row.path] = {
53
+ x: row.focal_x,
54
+ y: row.focal_y,
55
+ mobile:
56
+ row.mobile_x !== null && row.mobile_y !== null
57
+ ? { x: row.mobile_x, y: row.mobile_y }
58
+ : null,
59
+ };
60
+ }
61
+ return map;
62
+ }
63
+
64
+ /**
65
+ * Store a focus point. A mobile point is an override: the existing desktop
66
+ * coordinates are preserved (defaulting to dead centre) so switching device
67
+ * never silently recentres the other breakpoint.
68
+ */
69
+ export async function setMediaFocusCore(
70
+ context: FocusContext,
71
+ data: SetFocusInput,
72
+ ): Promise<FocusWriteResult> {
73
+ await assertStaff(context);
74
+
75
+ const path = focusKey(data.src);
76
+ if (!path) throw new Error("Missing image path");
77
+ const point = clampFocal({ x: data.x, y: data.y });
78
+ const device: FocusDevice = data.device === "mobile" ? "mobile" : "desktop";
79
+
80
+ if (device === "mobile") {
81
+ const { data: existing } = await context.supabase
82
+ .from("media_focus")
83
+ .select("focal_x, focal_y")
84
+ .eq("path", path)
85
+ .maybeSingle();
86
+ const { error } = await context.supabase.from("media_focus").upsert(
87
+ {
88
+ path,
89
+ focal_x: existing?.focal_x ?? 50,
90
+ focal_y: existing?.focal_y ?? 50,
91
+ mobile_x: point.x,
92
+ mobile_y: point.y,
93
+ updated_by: context.userId,
94
+ },
95
+ { onConflict: "path" },
96
+ );
97
+ if (error) throw new Error(error.message);
98
+ return { ok: true, path, device, ...point };
99
+ }
100
+
101
+ const { error } = await context.supabase
102
+ .from("media_focus")
103
+ .upsert(
104
+ { path, focal_x: point.x, focal_y: point.y, updated_by: context.userId },
105
+ { onConflict: "path" },
106
+ );
107
+ if (error) throw new Error(error.message);
108
+ return { ok: true, path, device, ...point };
109
+ }
110
+
111
+ /**
112
+ * Drop a focus point. Clearing mobile only nulls the override; clearing desktop
113
+ * removes the row so the image falls back to a centred crop.
114
+ */
115
+ export async function clearMediaFocusCore(
116
+ context: FocusContext,
117
+ data: ClearFocusInput,
118
+ ): Promise<FocusWriteResult> {
119
+ await assertStaff(context);
120
+
121
+ const path = focusKey(data.src);
122
+ if (!path) return { ok: true };
123
+
124
+ if (data.device === "mobile") {
125
+ const { error } = await context.supabase
126
+ .from("media_focus")
127
+ .update({ mobile_x: null, mobile_y: null, updated_by: context.userId })
128
+ .eq("path", path);
129
+ if (error) throw new Error(error.message);
130
+ return { ok: true };
131
+ }
132
+
133
+ const { error } = await context.supabase.from("media_focus").delete().eq("path", path);
134
+ if (error) throw new Error(error.message);
135
+ return { ok: true };
136
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Host-agnostic media metadata server logic (alt / title / description /
3
+ * credit).
4
+ *
5
+ * Alt text, title and description are localized maps so every language carries
6
+ * its own copy; metadata is keyed by the same canonical, variant-collapsed path
7
+ * as focus points. The public read uses a privileged client because the table is
8
+ * staff-only through row-level security while the text itself is rendered on the
9
+ * public site; writes run under the host's authenticated context.
10
+ */
11
+ import { assertStaff, type PlatformAuthContext } from "./guards";
12
+ import {
13
+ cleanLocalized,
14
+ metaKey,
15
+ type LocalizedText,
16
+ type MediaMetaMap,
17
+ } from "../media/media-meta";
18
+
19
+ /* eslint-disable @typescript-eslint/no-explicit-any */
20
+
21
+ export type MediaMetaAdmin = () => Promise<any>;
22
+ export type MediaMetaContext = PlatformAuthContext & { supabase: any };
23
+
24
+ export type SaveMediaMetaInput = {
25
+ src: string;
26
+ alt?: LocalizedText;
27
+ title?: LocalizedText;
28
+ description?: LocalizedText;
29
+ credit?: string;
30
+ };
31
+
32
+ /** All stored metadata, keyed by canonical path. Never throws. */
33
+ export async function getMediaMetaMapCore(admin: MediaMetaAdmin): Promise<MediaMetaMap> {
34
+ const db = await admin();
35
+ const { data, error } = await db
36
+ .from("media_meta")
37
+ .select("path, alt, title, description, credit");
38
+ if (error) return {};
39
+
40
+ const map: MediaMetaMap = {};
41
+ for (const row of (data ?? []) as {
42
+ path: string;
43
+ alt: LocalizedText | null;
44
+ title: LocalizedText | null;
45
+ description: LocalizedText | null;
46
+ credit: string | null;
47
+ }[]) {
48
+ map[row.path] = {
49
+ alt: row.alt ?? {},
50
+ title: row.title ?? {},
51
+ description: row.description ?? {},
52
+ credit: row.credit ?? "",
53
+ };
54
+ }
55
+ return map;
56
+ }
57
+
58
+ /** Upsert metadata for one asset; empty locales are stripped before storing. */
59
+ export async function setMediaMetaCore(
60
+ context: MediaMetaContext,
61
+ data: SaveMediaMetaInput,
62
+ ): Promise<{ ok: true; path: string }> {
63
+ await assertStaff(context);
64
+
65
+ const path = metaKey(data.src);
66
+ if (!path) throw new Error("Missing image path");
67
+
68
+ const { error } = await context.supabase.from("media_meta").upsert(
69
+ {
70
+ path,
71
+ alt: cleanLocalized(data.alt),
72
+ title: cleanLocalized(data.title),
73
+ description: cleanLocalized(data.description),
74
+ credit: (data.credit ?? "").trim(),
75
+ updated_by: context.userId,
76
+ },
77
+ { onConflict: "path" },
78
+ );
79
+ if (error) throw new Error(error.message);
80
+ return { ok: true, path };
81
+ }
82
+
83
+ /** Remove all metadata for one asset. */
84
+ export async function clearMediaMetaCore(
85
+ context: MediaMetaContext,
86
+ data: { src: string },
87
+ ): Promise<{ ok: true }> {
88
+ await assertStaff(context);
89
+
90
+ const path = metaKey(data.src);
91
+ if (!path) return { ok: true };
92
+ const { error } = await context.supabase.from("media_meta").delete().eq("path", path);
93
+ if (error) throw new Error(error.message);
94
+ return { ok: true };
95
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Host-agnostic "replace this file everywhere" logic.
3
+ *
4
+ * Swaps one library asset for another across every stored CMS reference.
5
+ * Responsive variants are matched width-for-width so srcSets stay consistent;
6
+ * anything without a matching width falls back to the new primary file.
7
+ *
8
+ * Nothing is deleted — the old asset stays in the library, so a wrong replace is
9
+ * reversed by replacing back.
10
+ */
11
+ import { assertStaff, actorEmail, type PlatformAuthContext } from "./guards";
12
+ import { RESPONSIVE_WIDTHS, variantSiblings } from "../media/srcset";
13
+
14
+ /* eslint-disable @typescript-eslint/no-explicit-any */
15
+
16
+ const VARIANT_RE = /-(480|768|1200|1920|2560)\.webp$/i;
17
+
18
+ function variantBase(path: string): string | null {
19
+ const m = path.match(VARIANT_RE);
20
+ return m ? path.slice(0, path.length - m[0].length) : null;
21
+ }
22
+
23
+ /** Every storage path belonging to one asset (primary + responsive siblings). */
24
+ function family(path: string): string[] {
25
+ return [path, ...variantSiblings(path)];
26
+ }
27
+
28
+ /** Storage surface the replace needs — satisfied by `getMediaStorage()`. */
29
+ export type ReplaceStorage = {
30
+ list: () => Promise<{ name: string }[]>;
31
+ publicUrl: (path: string) => string;
32
+ };
33
+
34
+ export type ReplaceDeps = {
35
+ context: PlatformAuthContext & { supabase: any };
36
+ /** Privileged client used for the reference rewrite and the audit row. */
37
+ admin: () => Promise<any>;
38
+ /** Storage bound to the privileged client. */
39
+ storage: (admin: any) => ReplaceStorage;
40
+ /** Repoint stored references from any of `oldUrls` to `newUrl`; returns hits. */
41
+ rewriteReferences: (admin: any, oldUrls: string[], newUrl: string) => Promise<number>;
42
+ };
43
+
44
+ export type ReplaceResult = { rewritten: number; widths: number };
45
+
46
+ export async function replaceMediaEverywhereCore(
47
+ deps: ReplaceDeps,
48
+ data: { fromPath: string; toPath: string },
49
+ ): Promise<ReplaceResult> {
50
+ await assertStaff(deps.context);
51
+ if (!data.fromPath || !data.toPath) throw new Error("Both files are required");
52
+ if (data.fromPath === data.toPath) throw new Error("Pick two different files");
53
+
54
+ const admin = await deps.admin();
55
+ const storage = deps.storage(admin);
56
+
57
+ const existing = new Set((await storage.list()).map((f) => f.name));
58
+ if (!existing.has(data.fromPath)) throw new Error("Source file not found");
59
+ if (!existing.has(data.toPath)) throw new Error("Replacement file not found");
60
+
61
+ const newBase = variantBase(data.toPath);
62
+ const newPrimaryUrl = storage.publicUrl(data.toPath);
63
+
64
+ // Most specific first: width-matched variants, then the primary path.
65
+ const pairs: { oldPath: string; newUrl: string }[] = [];
66
+ for (const oldPath of family(data.fromPath)) {
67
+ if (!existing.has(oldPath) && oldPath !== data.fromPath) continue;
68
+ const width = oldPath.match(VARIANT_RE)?.[1];
69
+ let target = newPrimaryUrl;
70
+ if (width && newBase) {
71
+ const candidate = `${newBase}-${width}.webp`;
72
+ if (existing.has(candidate)) target = storage.publicUrl(candidate);
73
+ }
74
+ pairs.push({ oldPath, newUrl: target });
75
+ }
76
+ pairs.sort((a, b) => b.oldPath.length - a.oldPath.length);
77
+
78
+ let rewritten = 0;
79
+ for (const pair of pairs) {
80
+ const url = storage.publicUrl(pair.oldPath);
81
+ if (!url || url === pair.newUrl) continue;
82
+ const candidates = Array.from(
83
+ new Set([url, encodeURI(url), decodeURI(url), `/${pair.oldPath}`]),
84
+ );
85
+ rewritten += await deps.rewriteReferences(admin, candidates, pair.newUrl);
86
+ }
87
+
88
+ try {
89
+ await admin.from("audit_logs").insert({
90
+ actor_id: deps.context.userId,
91
+ actor_email: actorEmail(deps.context),
92
+ action: "media.replace_everywhere",
93
+ entity: "media",
94
+ entity_id: data.fromPath,
95
+ meta: { to: data.toPath, rewritten },
96
+ });
97
+ } catch (e) {
98
+ console.warn("audit failed", e);
99
+ }
100
+
101
+ return { rewritten, widths: RESPONSIVE_WIDTHS.length };
102
+ }