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:
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";
|
||||
}
|
||||
Reference in new issue
Block a user