@bygghammar/cms-platform 0.47.0 → 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,11 @@
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
+
3
9
  ## 0.47.0
4
10
 
5
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).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.47.0",
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,
@@ -170,7 +170,12 @@
170
170
  "./server/media-focus-core": "./src/server/media-focus-core.ts",
171
171
  "./server/media-meta-core": "./src/server/media-meta-core.ts",
172
172
  "./server/media-replace-core": "./src/server/media-replace-core.ts",
173
- "./server/media-cleanup-host": "./src/server/media-cleanup-host.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"
174
179
  },
175
180
  "dependencies": {
176
181
  "fflate": "^0.8.3"
@@ -247,4 +252,4 @@
247
252
  "cms-platform-migrations": "scripts/sync-migrations.mjs",
248
253
  "create-cms-site": "./scripts/create-cms-site.mjs"
249
254
  }
250
- }
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
+ }