Website: port media share page (/media/[token]) to artcraft-website-next (#33)

* Website: port the media share page to artcraft-website-next

Adds the /media/[token] route with server-side OG metadata, the media
viewer with prompt/reference/info details, and legacy ?media= redirects.

* no ruler on media share page
This commit is contained in:
BFlat authored and GitHub committed 2026-09-26 21:36:04 +07:00
1 parent 8551d58def
commit c6e2c4296a
7 files changed
+833 -11

No files matched your search

@@ -11,3 +11,13 @@ publish = ".next"
[[plugins]]
package = "@netlify/plugin-nextjs"
# Share links. The Next runtime serves /media/:token itself (src/app/media);
# this only maps the legacy query form the Vite site accepted onto the
# canonical route at the edge. Also declared in next.config.ts for local dev.
[[redirects]]
from = "/media"
query = { media = ":token" }
to = "/media/:token"
status = 301
force = true
@@ -8,17 +8,28 @@ const nextConfig: NextConfig = {
turbopack: { root: __dirname },
outputFileTracingRoot: __dirname,
async redirects() {
// Legacy Stripe return paths from the Vite site; checkout now lives in
// the webapp, which serves the canonical slash forms.
return ["checkout_success", "checkout_cancel", "portal_closed"].map(
(route) => ({
source: `/${route}`,
destination: `https://app.getartcraft.com/checkout/${
route === "checkout_success" ? "success" : "cancel"
}`,
permanent: false,
}),
);
return [
// Legacy Stripe return paths from the Vite site; checkout now lives in
// the webapp, which serves the canonical slash forms.
...["checkout_success", "checkout_cancel", "portal_closed"].map(
(route) => ({
source: `/${route}`,
destination: `https://app.getartcraft.com/checkout/${
route === "checkout_success" ? "success" : "cancel"
}`,
permanent: false,
}),
),
// Legacy share-link form (`/media?media=<token>`) from the Vite site;
// the canonical route is /media/<token>. Mirrored in netlify.toml so
// the edge answers it before the Next runtime.
{
source: "/media",
has: [{ type: "query", key: "media", value: "(?<token>.+)" }],
destination: "/media/:token",
permanent: true,
},
];
},
// The hero wall uses WebGL video textures, which need CORS-clean sources.
// The showcase CDN sends no Access-Control-Allow-Origin header, so these
@@ -0,0 +1,71 @@
import type { Metadata } from "next";
import MediaViewer from "@/components/media/media-viewer";
import { API_HOST, mediaFilePath, type MediaFile } from "@/lib/api";
import { mediaKindForUrl, thumbnailUrl } from "@/lib/media";
type Params = Promise<{ token: string }>;
const OG_IMAGE_WIDTH = 1200;
// Share links get unfurled by chat apps and social cards, so the preview
// image is resolved on the server (public media only: this request carries
// no session). Private or missing media falls back to the site defaults.
export async function generateMetadata({
params,
}: {
params: Params;
}): Promise<Metadata> {
const { token } = await params;
const metadata: Metadata = {
title: "Shared Media",
description: "View shared media from ArtCraft.",
alternates: { canonical: `/media/${token}` },
robots: { index: false },
};
const media = await fetchPublicMediaFile(token);
if (!media) return metadata;
const url = media.media_links.cdn_url;
const image =
mediaKindForUrl(url) === "image"
? url
: thumbnailUrl(media.media_links.thumbnail_template, OG_IMAGE_WIDTH);
const description = media.maybe_creator_user
? `Made with ArtCraft by ${media.maybe_creator_user.display_name}.`
: "Made with ArtCraft.";
return {
...metadata,
description,
openGraph: {
title: "Shared Media — ArtCraft",
description,
url: `/media/${token}`,
...(image && { images: [{ url: image }] }),
},
twitter: { card: image ? "summary_large_image" : "summary" },
};
}
export default async function MediaPage({ params }: { params: Params }) {
const { token } = await params;
return <MediaViewer token={token} />;
}
async function fetchPublicMediaFile(token: string): Promise<MediaFile | null> {
try {
const response = await fetch(`${API_HOST}${mediaFilePath(token)}`, {
headers: { Accept: "application/json" },
next: { revalidate: 300 },
});
if (!response.ok) return null;
const payload = (await response.json()) as {
success?: boolean;
media_file?: MediaFile;
};
return payload.success && payload.media_file ? payload.media_file : null;
} catch {
return null;
}
}
@@ -0,0 +1,520 @@
"use client";
import { useCallback, useEffect, useState, type ReactNode } from "react";
import Link from "next/link";
import {
ArrowDownToLineIcon,
BoxIcon,
CheckIcon,
CopyIcon,
ExternalLinkIcon,
ImageIcon,
InfoIcon,
LinkIcon,
LoaderCircleIcon,
PencilIcon,
UserIcon,
} from "lucide-react";
import { twMerge } from "tailwind-merge";
import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell";
import { Button } from "@/components/ui";
import {
getMediaFile,
getPrompt,
type MediaFile,
type Prompt,
} from "@/lib/api";
import { webappUrl } from "@/lib/links";
import {
addCorsParam,
downloadMediaFile,
formatAspectRatio,
formatCreatedAt,
formatDuration,
formatResolution,
mediaKindForUrl,
SHARE_URL_BASE,
thumbnailUrl,
} from "@/lib/media";
const COPY_FEEDBACK_MS = 1500;
const REFERENCE_THUMB_WIDTH = 128;
// Ported from the Vite site's pages/media + LightboxDetails: the media
// preview beside a details rail (author, prompt, reference images, info)
// with share / download / open-in-app actions. Server HTML renders the
// loading frame; the media record and its prompt load on the client.
type LoadState =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "ready"; media: MediaFile; prompt: Prompt | null };
type Dimensions = { width: number; height: number };
export default function MediaViewer({ token }: { token: string }) {
const [state, setState] = useState<LoadState>({ status: "loading" });
const [dimensions, setDimensions] = useState<Dimensions | null>(null);
useEffect(() => {
let live = true;
setState({ status: "loading" });
setDimensions(null);
(async () => {
const mediaResult = await getMediaFile(token);
if (!mediaResult.success) {
return { status: "error", message: mediaResult.errorMessage } as const;
}
const media = mediaResult.data;
const promptResult = media.maybe_prompt_token
? await getPrompt(media.maybe_prompt_token)
: null;
return {
status: "ready",
media,
prompt: promptResult?.success ? promptResult.data : null,
} as const;
})().then((next) => {
if (live) setState(next);
});
return () => {
live = false;
};
}, [token]);
const media = state.status === "ready" ? state.media : null;
const prompt = state.status === "ready" ? state.prompt : null;
const mediaUrl = media?.media_links.cdn_url ?? null;
return (
<SectionShell id="media">
<SectionEyebrow
index="01"
label="Shared media"
annotation={media ? `Token · ${media.token}` : undefined}
/>
<div className="flex flex-col lg:flex-row lg:min-h-[calc(100vh-6rem)]">
<div className="relative flex min-h-[50vh] flex-1 items-center justify-center bg-bg-sunken lg:min-h-0">
{state.status === "loading" && <Spinner />}
{state.status === "error" && (
<EmptyState title="Media not available" message={state.message} />
)}
{mediaUrl && (
<MediaPreview
url={mediaUrl}
token={token}
onDimensions={setDimensions}
/>
)}
</div>
<aside className="flex w-full shrink-0 flex-col border-t border-line lg:w-[340px] lg:border-t-0 lg:border-l">
{state.status === "loading" ? (
<DetailsSkeleton />
) : (
<DetailsBody
media={media}
prompt={prompt}
dimensions={dimensions}
/>
)}
<Actions token={token} media={media} />
</aside>
</div>
</SectionShell>
);
}
// ── Preview ──────────────────────────────────────────────────────────────
function MediaPreview({
url,
token,
onDimensions,
}: {
url: string;
token: string;
onDimensions: (dimensions: Dimensions) => void;
}) {
const [loaded, setLoaded] = useState(false);
const [failed, setFailed] = useState(false);
const kind = mediaKindForUrl(url);
const src = addCorsParam(url);
if (kind === "3d") {
return (
<EmptyState
icon={<BoxIcon aria-hidden className="h-6 w-6" />}
title="3D model"
message="Open this model in ArtCraft to view it in 3D, or download the file below."
>
<Button href={webappUrl(`media/${token}`)} size="sm" className="mt-6">
Open in ArtCraft
<ExternalLinkIcon aria-hidden className="h-3.5 w-3.5" />
</Button>
</EmptyState>
);
}
if (failed) {
return (
<EmptyState
title="Preview unavailable"
message="The file could not be loaded. You can still download it below."
/>
);
}
const onReady = (dimensions: Dimensions) => {
setLoaded(true);
onDimensions(dimensions);
};
return (
<div className="relative flex h-full w-full items-center justify-center">
{!loaded && <Spinner />}
{kind === "video" ? (
<video
src={src}
className="h-full max-h-[calc(100vh-6rem)] w-full object-contain"
controls
autoPlay
loop
muted
playsInline
onError={() => setFailed(true)}
onLoadedData={(e) =>
onReady({
width: e.currentTarget.videoWidth,
height: e.currentTarget.videoHeight,
})
}
/>
) : (
// Plain <img>: user-generated CDN content with unknown dimensions,
// outside next/image's remote-pattern allowlist.
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt="Shared ArtCraft generation"
className={twMerge(
"h-full max-h-[calc(100vh-6rem)] w-full object-contain transition-opacity duration-300",
loaded ? "opacity-100" : "opacity-0",
)}
onError={() => setFailed(true)}
onLoad={(e) =>
onReady({
width: e.currentTarget.naturalWidth,
height: e.currentTarget.naturalHeight,
})
}
/>
)}
</div>
);
}
// ── Details rail ─────────────────────────────────────────────────────────
function DetailsBody({
media,
prompt,
dimensions,
}: {
media: MediaFile | null;
prompt: Prompt | null;
dimensions: Dimensions | null;
}) {
if (!media) return <div className="flex-1" />;
const creator = media.maybe_creator_user;
const references = prompt?.maybe_context_images ?? [];
const infoRows: [string, ReactNode][] = [];
if (prompt?.maybe_model_type) infoRows.push(["Model", prompt.maybe_model_type]);
if (prompt?.maybe_generation_provider) {
infoRows.push(["Provider", prompt.maybe_generation_provider]);
}
if (prompt?.maybe_aspect_ratio) {
infoRows.push(["Aspect ratio", formatAspectRatio(prompt.maybe_aspect_ratio)]);
}
if (prompt?.maybe_resolution) {
infoRows.push(["Resolution", formatResolution(prompt.maybe_resolution)]);
}
if (prompt?.maybe_duration_seconds != null) {
infoRows.push(["Duration", formatDuration(prompt.maybe_duration_seconds)]);
}
if (prompt?.maybe_generate_audio != null) {
infoRows.push(["Audio", prompt.maybe_generate_audio ? "On" : "Off"]);
}
if (dimensions) {
infoRows.push(["Size", `${dimensions.width} × ${dimensions.height}`]);
}
infoRows.push(["Created", formatCreatedAt(media.created_at)]);
return (
<div className="flex flex-1 flex-col gap-6 p-6">
{creator && (
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center border border-line bg-bg-raised text-muted">
<UserIcon aria-hidden className="h-4 w-4" />
</span>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-ink-strong">
{creator.display_name}
</p>
<p className="hud-label text-faint">Author</p>
</div>
</div>
)}
{media.maybe_prompt_token && (
<Section icon={<PencilIcon aria-hidden className="h-3.5 w-3.5" />} label="Prompt">
<PromptText text={prompt?.maybe_positive_prompt ?? null} />
</Section>
)}
{references.length > 0 && (
<Section icon={<ImageIcon aria-hidden className="h-3.5 w-3.5" />} label="Reference images">
<div className="grid grid-cols-5 gap-2">
{references.map((reference, index) => (
<Link
key={reference.media_token}
href={`/media/${reference.media_token}`}
className="relative block aspect-square overflow-hidden border border-line bg-bg-sunken hover:border-line-strong"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={
thumbnailUrl(
reference.media_links.maybe_thumbnail_template,
REFERENCE_THUMB_WIDTH,
) ?? reference.media_links.cdn_url
}
alt={`Reference ${index + 1}`}
className="h-full w-full object-cover"
/>
</Link>
))}
</div>
</Section>
)}
<Section icon={<InfoIcon aria-hidden className="h-3.5 w-3.5" />} label="Information">
<dl className="border border-line bg-bg-raised">
{infoRows.map(([label, value]) => (
<div
key={label}
className="flex items-center justify-between gap-4 border-b border-line px-4 py-3 last:border-b-0"
>
<dt className="text-sm text-muted">{label}</dt>
<dd className="truncate text-right text-sm font-medium text-ink">
{value}
</dd>
</div>
))}
</dl>
</Section>
</div>
);
}
function PromptText({ text }: { text: string | null }) {
const [expanded, setExpanded] = useState(false);
const copy = useCopyFeedback(text);
return (
<div className="space-y-2">
<div
className={twMerge(
"border border-line bg-bg-raised px-4 py-3 text-sm leading-relaxed text-ink break-words",
!expanded && "line-clamp-4",
)}
>
{text ?? <span className="text-muted">No prompt</span>}
</div>
{text && (
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
className="hud-label text-muted hover:text-ink"
>
{expanded ? "Show less" : "Show more"}
</button>
<button
type="button"
onClick={copy.trigger}
className="hud-label inline-flex items-center gap-1.5 text-muted hover:text-ink"
>
{copy.copied ? (
<CheckIcon aria-hidden className="h-3 w-3" />
) : (
<CopyIcon aria-hidden className="h-3 w-3" />
)}
{copy.copied ? "Copied" : "Copy"}
</button>
</div>
)}
</div>
);
}
function Actions({ token, media }: { token: string; media: MediaFile | null }) {
const share = useCopyFeedback(`${SHARE_URL_BASE}${token}`);
const [downloading, setDownloading] = useState(false);
const [downloadError, setDownloadError] = useState(false);
const mediaUrl = media?.media_links.cdn_url;
const handleDownload = useCallback(async () => {
if (!mediaUrl || downloading) return;
setDownloading(true);
setDownloadError(false);
try {
await downloadMediaFile({
url: mediaUrl,
filename: `artcraft-${token}`,
mediaClass: media?.media_class,
});
} catch {
setDownloadError(true);
} finally {
setDownloading(false);
}
}, [mediaUrl, downloading, token, media?.media_class]);
return (
<div className="space-y-2 border-t border-line p-6">
<div className="grid grid-cols-2 gap-2">
<Button variant="secondary" className="w-full" onClick={share.trigger}>
{share.copied ? (
<CheckIcon aria-hidden className="h-3.5 w-3.5" />
) : (
<LinkIcon aria-hidden className="h-3.5 w-3.5" />
)}
{share.copied ? "Copied" : "Share"}
</Button>
<Button
variant="secondary"
className="w-full"
disabled={!mediaUrl}
loading={downloading}
onClick={handleDownload}
>
{!downloading && (
<ArrowDownToLineIcon aria-hidden className="h-3.5 w-3.5" />
)}
Download
</Button>
</div>
{downloadError && (
<p className="hud-label text-danger">Could not download file.</p>
)}
{media && (
<Button
variant="action"
className="w-full"
href={webappUrl(`media/${token}`)}
>
Open in ArtCraft
<ExternalLinkIcon aria-hidden className="h-3.5 w-3.5" />
</Button>
)}
<Button className="w-full" href="/download">
<ArrowDownToLineIcon aria-hidden className="h-3.5 w-3.5" />
Download ArtCraft
</Button>
</div>
);
}
// ── Small pieces ─────────────────────────────────────────────────────────
function Section({
icon,
label,
children,
}: {
icon: ReactNode;
label: string;
children: ReactNode;
}) {
return (
<div className="space-y-2">
<p className="hud-label inline-flex items-center gap-2 text-muted">
{icon}
{label}
</p>
{children}
</div>
);
}
function EmptyState({
icon,
title,
message,
children,
}: {
icon?: ReactNode;
title: string;
message: string;
children?: ReactNode;
}) {
return (
<div className="flex flex-col items-center px-6 py-16 text-center text-muted">
{icon}
<p className={twMerge("hud-label text-ink", icon && "mt-4")}>{title}</p>
<p className="mt-2 max-w-sm text-sm leading-relaxed">{message}</p>
{children}
</div>
);
}
function Spinner() {
return (
<div className="absolute inset-0 flex items-center justify-center">
<LoaderCircleIcon
aria-label="Loading"
className="h-8 w-8 animate-spin text-muted"
/>
</div>
);
}
function DetailsSkeleton() {
return (
<div className="flex-1 animate-pulse space-y-6 p-6" aria-hidden>
<div className="h-9 w-40 bg-bg-sunken" />
<div className="space-y-2">
<div className="h-3 w-16 bg-bg-sunken" />
<div className="h-24 w-full bg-bg-sunken" />
</div>
<div className="space-y-2">
<div className="h-3 w-24 bg-bg-sunken" />
<div className="h-32 w-full bg-bg-sunken" />
</div>
</div>
);
}
// Copies `text` to the clipboard and reports "copied" briefly afterwards.
function useCopyFeedback(text: string | null) {
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return;
const timeout = window.setTimeout(() => setCopied(false), COPY_FEEDBACK_MS);
return () => window.clearTimeout(timeout);
}, [copied]);
const trigger = useCallback(() => {
if (!text) return;
navigator.clipboard
.writeText(text)
.then(() => setCopied(true))
.catch(() => setCopied(false));
}, [text]);
return { copied, trigger };
}
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import gsap from "gsap";
import { HERO_SECTION_ID, RULER_SECTIONS } from "@/lib/landing-data";
import {
@@ -47,7 +48,21 @@ import {
// Progressive enhancement: fine pointers on md+ get the full instrument
// (native scrollbar hidden); reduced-motion visitors get a static rail and
// keep their scrollbar; coarse pointers and small screens get nothing.
// Routes that render without the instrument: utility pages that are a
// single viewport-tall surface rather than a scrolling story.
const RULERLESS_ROUTES = ["/media"];
export default function ScrollRuler() {
const pathname = usePathname();
const rulerless = RULERLESS_ROUTES.some(
(route) => pathname === route || pathname.startsWith(`${route}/`),
);
// Unmounting (not hiding) so the effects release the native scrollbar.
return rulerless ? null : <ScrollRulerInstrument />;
}
function ScrollRulerInstrument() {
const [mode, setMode] = useState<RulerMode | null>(null);
const [geom, setGeom] = useState({ docH: 0, vh: 0, vw: 0 });
const [layoutVersion, setLayoutVersion] = useState(0);
@@ -238,6 +238,72 @@ export function logWebReferral(maybeReferralUrl?: string): Promise<void> {
}).then(() => undefined);
}
// ── Shared media (the /media/[token] share page) ─────────────────────────
// Mirrors MediaFilesApi.GetMediaFileByToken / PromptsApi.GetPromptsByToken
// from the shared lib, trimmed to the fields the share page renders.
export type MediaUser = {
user_token: string;
username: string;
display_name: string;
email_gravatar_hash: string;
};
export type MediaFile = {
token: string;
media_class: string | null;
maybe_creator_user: MediaUser | null;
maybe_prompt_token: string | null;
media_links: {
cdn_url: string;
/** Contains a `{WIDTH}` placeholder. */
thumbnail_template: string | null;
};
created_at: string;
};
export type PromptContextImage = {
media_token: string;
semantic: string;
media_links: { cdn_url: string; maybe_thumbnail_template: string | null };
};
export type Prompt = {
token: string;
maybe_positive_prompt: string | null;
maybe_generation_provider: string | null;
maybe_model_type: string | null;
maybe_context_images: PromptContextImage[] | null;
maybe_aspect_ratio: string | null;
maybe_resolution: string | null;
maybe_duration_seconds: number | null;
maybe_generate_audio: boolean | null;
};
export function mediaFilePath(token: string): string {
return `/v1/media_files/file/${encodeURIComponent(token)}`;
}
export function getMediaFile(token: string): Promise<ApiResult<MediaFile>> {
return request<{ media_file?: MediaFile }>(mediaFilePath(token)).then((r) => {
if (!r.success) return r;
return r.data.media_file
? { success: true, data: r.data.media_file }
: { success: false, errorMessage: "Media not found" };
});
}
export function getPrompt(token: string): Promise<ApiResult<Prompt>> {
return request<{ prompt?: Prompt }>(
`/v1/prompts/${encodeURIComponent(token)}`,
).then((r) => {
if (!r.success) return r;
return r.data.prompt
? { success: true, data: r.data.prompt }
: { success: false, errorMessage: "Prompt not found" };
});
}
function checkoutRequest(
path: string,
body: unknown,
@@ -0,0 +1,129 @@
// Helpers for the shared-media page, ported from the Vite site's
// components/lightbox/shared.ts, lib/download-media.ts, and the
// @storyteller/common media-format / thumbnail utilities.
export const SHARE_URL_BASE = "https://getartcraft.com/media/";
const VIDEO_EXTENSIONS = [".mp4", ".webm", ".mov", ".avi", ".mkv", ".m4v"];
const MODEL_3D_EXTENSIONS = [".glb", ".gltf", ".fbx", ".spz"];
const ASPECT_RATIO_LABELS: Record<string, string> = {
auto: "Auto",
square: "Square",
square_hd: "Square (HD)",
wide: "Wide",
tall: "Tall",
wide_three_by_two: "3:2",
wide_four_by_three: "4:3",
wide_five_by_four: "5:4",
wide_sixteen_by_nine: "16:9",
wide_twenty_one_by_nine: "21:9",
tall_two_by_three: "2:3",
tall_three_by_four: "3:4",
tall_four_by_five: "4:5",
tall_nine_by_sixteen: "9:16",
tall_nine_by_twenty_one: "9:21",
auto_2k: "Auto (2K)",
auto_3k: "Auto (3K)",
auto_4k: "Auto (4K)",
};
const RESOLUTION_LABELS: Record<string, string> = {
half_k: "0.5K",
one_k: "1K",
two_k: "2K",
three_k: "3K",
four_k: "4K",
four_eighty_p: "480p",
seven_twenty_p: "720p",
ten_eighty_p: "1080p",
};
// Fallback extensions when the CDN URL carries none.
const EXT_BY_MEDIA_CLASS: Record<string, string> = {
image: "png",
video: "mp4",
dimensional: "glb",
mesh: "glb",
splat: "spz",
};
export type MediaKind = "image" | "video" | "3d";
export function mediaKindForUrl(url: string): MediaKind {
const lower = url.toLowerCase();
if (VIDEO_EXTENSIONS.some((ext) => lower.includes(ext))) return "video";
if (MODEL_3D_EXTENSIONS.some((ext) => lower.includes(ext))) return "3d";
return "image";
}
// The media CDN only sends CORS headers when asked.
export function addCorsParam(url: string): string {
return `${url}?cors=1`;
}
export function thumbnailUrl(
template: string | null | undefined,
width: number,
): string | null {
return template ? template.replace("{WIDTH}", String(width)) : null;
}
export const formatAspectRatio = (value: string): string =>
ASPECT_RATIO_LABELS[value] ?? value;
export const formatResolution = (value: string): string =>
RESOLUTION_LABELS[value] ?? value;
export const formatDuration = (seconds: number): string => `${seconds}s`;
const CREATED_AT_FORMAT = new Intl.DateTimeFormat("en-US", {
dateStyle: "long",
timeStyle: "short",
});
export function formatCreatedAt(iso: string): string {
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? iso : CREATED_AT_FORMAT.format(date);
}
// Fetches the file as a blob so the browser saves it instead of navigating
// to the CDN. Throws on failure so the caller can surface an error.
export async function downloadMediaFile({
url,
filename,
mediaClass,
}: {
url: string;
filename: string;
mediaClass?: string | null;
}): Promise<void> {
const response = await fetch(`${addCorsParam(url)}&dl=1`, {
credentials: "omit",
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blobUrl = URL.createObjectURL(await response.blob());
try {
const anchor = document.createElement("a");
anchor.style.display = "none";
anchor.href = blobUrl;
anchor.download = `${filename}.${extensionForUrl(url, mediaClass)}`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
} finally {
URL.revokeObjectURL(blobUrl);
}
}
function extensionForUrl(url: string, mediaClass?: string | null): string {
try {
const match = new URL(url, window.location.href).pathname.match(
/\.([a-z0-9]{2,5})$/i,
);
if (match) return match[1].toLowerCase();
} catch {
// Unparseable URL; fall through to the class default.
}
return (mediaClass && EXT_BY_MEDIA_CLASS[mediaClass]) || "bin";
}