Compare commits

...
Author SHA1 Message Date
BFlat 994596682e website: unified lightbox, recreate, desktop CTA, prompt fixes 2026-04-20 21:12:41 +07:00
14 changed files with 1070 additions and 831 deletions
@@ -0,0 +1,38 @@
import { Link } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faDesktop, faXmark } from "@fortawesome/pro-solid-svg-icons";
import { useDismissibleBanner } from "../../lib/useDismissibleBanner";
const DISMISS_KEY = "artcraft-app-download-cta-dismissed-v1";
export function AppDownloadCta() {
const { visible, dismiss } = useDismissibleBanner(DISMISS_KEY);
if (!visible) return null;
return (
<div className="glass animate-fade-in-up mb-2 hidden sm:flex w-fit mx-auto items-center gap-3 rounded-xl px-4 py-2.5 text-sm text-white">
<div className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-primary/20 text-primary">
<FontAwesomeIcon icon={faDesktop} className="h-3.5 w-3.5" />
</div>
<span className="text-white/90">
Get more out of ArtCraft on desktop - 3D Stage, 2D Canvas &amp; more.
</span>
<Link
to="/download"
className="rounded-lg bg-primary/90 px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-primary"
>
Download
</Link>
<button
onClick={dismiss}
className="ml-1 text-white/40 transition-colors hover:text-white/80"
aria-label="Dismiss"
>
<FontAwesomeIcon icon={faXmark} className="h-3 w-3" />
</button>
</div>
);
}
export default AppDownloadCta;
@@ -0,0 +1,426 @@
import { useEffect, useRef, useState } from "react";
import dayjs from "dayjs";
import { Button } from "@storyteller/ui-button";
import { toast } from "@storyteller/ui-toaster";
import { Gravatar } from "@storyteller/ui-gravatar";
import type { UserInfo } from "@storyteller/api";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowDownToLine,
faArrowRotateRight,
faCheck,
faCircleInfo,
faCopy,
faImage,
faLink,
faPencil,
faTrashCan,
faUser,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import {
addCorsParam,
getContextImageThumbnail,
THUMBNAIL_SIZES,
} from "@storyteller/common";
import {
getModelCreatorIcon,
getModelDisplayName,
getProviderDisplayName,
getProviderIconByName,
} from "@storyteller/model-list";
import {
formatAspectRatio,
formatDuration,
formatResolution,
SHARE_URL_BASE,
type PromptData,
useCopyFeedback,
} from "./shared";
// ── InfoRow (private to this file; other consumers do not need it) ─────────
function InfoRow({
label,
value,
}: {
label: string;
value: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between px-4 py-3 border-b border-white/5 last:border-0">
<span className="text-sm text-white/60 font-medium">{label}</span>
<span className="text-sm text-white font-medium flex items-center gap-2">
{value}
</span>
</div>
);
}
// ── Component ──────────────────────────────────────────────────────────────
export interface LightboxDetailsProps {
promptData: PromptData;
mediaToken?: string | null;
mediaUrl?: string | null;
mediaWidth?: number;
mediaHeight?: number;
createdAt?: string | null;
creator?: UserInfo | null;
onClose?: () => void;
onRecreate?: () => void;
onDelete?: () => void;
showDownloadAppCta?: boolean;
}
export function LightboxDetails({
promptData,
mediaToken,
mediaUrl,
mediaWidth,
mediaHeight,
createdAt,
creator,
onClose,
onRecreate,
onDelete,
showDownloadAppCta,
}: LightboxDetailsProps) {
const promptCopy = useCopyFeedback();
const shareCopy = useCopyFeedback();
const [isPromptExpanded, setIsPromptExpanded] = useState(false);
const [isPromptClamped, setIsPromptClamped] = useState(false);
const promptRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setIsPromptExpanded(false);
}, [mediaToken]);
useEffect(() => {
if (!promptRef.current || !promptData.text || promptData.loading) {
setIsPromptClamped(false);
return;
}
const raf = requestAnimationFrame(() => {
if (promptRef.current) {
setIsPromptClamped(
promptRef.current.scrollHeight > promptRef.current.clientHeight,
);
}
});
return () => cancelAnimationFrame(raf);
}, [promptData.text, promptData.loading, isPromptExpanded]);
const handleCopyPrompt = async () => {
if (!promptData.text) return;
try {
await navigator.clipboard.writeText(promptData.text);
toast.success("Prompt copied");
promptCopy.trigger();
} catch {
toast.error("Unable to copy prompt");
}
};
const handleCopyShareLink = async () => {
if (!mediaToken) return;
const shareUrl = `${SHARE_URL_BASE}${mediaToken}`;
try {
await navigator.clipboard.writeText(shareUrl);
toast.success("Share link copied");
shareCopy.trigger();
} catch {
toast.error("Unable to copy link");
}
};
const hasInfoSection =
!!promptData.provider ||
!!promptData.modelType ||
!!promptData.aspectRatio ||
!!promptData.resolution ||
promptData.durationSeconds != null ||
promptData.generateAudio != null ||
(!!mediaWidth && !!mediaHeight) ||
!!createdAt;
return (
<div className="relative flex w-full sm:w-[320px] shrink-0 flex-col bg-ui-panel rounded-b-xl sm:rounded-b-none sm:rounded-r-xl min-h-0 flex-1 sm:flex-none sm:h-full overflow-hidden">
{onClose && (
<button
onClick={onClose}
className="absolute top-3 right-3 z-20 flex h-8 w-8 items-center justify-center rounded-full bg-black/40 text-white/70 transition-colors hover:bg-black/60 hover:text-white"
aria-label="Close"
>
<FontAwesomeIcon icon={faXmark} className="h-4 w-4" />
</button>
)}
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-5 min-h-0">
{promptData.loading ? (
<div className="space-y-6 animate-pulse">
<div className="space-y-2">
<div className="h-4 w-20 bg-white/10 rounded" />
<div className="h-20 w-full bg-white/10 rounded-lg" />
</div>
<div className="space-y-2">
<div className="h-4 w-32 bg-white/10 rounded" />
<div className="h-10 w-full bg-white/10 rounded-lg" />
<div className="h-10 w-full bg-white/10 rounded-lg" />
</div>
</div>
) : (
<>
{creator && (
<div
className={`sticky -top-4 z-10 -mx-4 -mt-4 mb-1 flex items-center gap-3 bg-ui-panel px-4 pt-4 pb-3 border-b border-white/5 ${onClose ? "pr-10" : ""}`}
>
{creator.core_info ? (
<Gravatar
size={36}
username={creator.username}
email_hash={creator.email_gravatar_hash}
avatarIndex={creator.core_info.default_avatar.image_index}
backgroundIndex={
creator.core_info.default_avatar.color_index
}
className="rounded-xl border-white/10"
/>
) : (
<div className="h-9 w-9 shrink-0 flex items-center justify-center rounded-xl bg-white/10 text-white/50 border border-white/5">
<FontAwesomeIcon icon={faUser} />
</div>
)}
<div className="flex flex-col gap-1">
<span className="text-white text-sm font-semibold leading-none">
{creator.display_name}
</span>
<span className="text-white/60 text-xs font-medium">
Author
</span>
</div>
</div>
)}
{promptData.hasToken && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
<FontAwesomeIcon icon={faPencil} />
<span>Prompt</span>
</div>
{promptData.text && (
<button
onClick={handleCopyPrompt}
className="flex items-center gap-1.5 text-xs text-white/60 hover:text-white transition-colors"
>
<FontAwesomeIcon
icon={promptCopy.copied ? faCheck : faCopy}
className="h-3 w-3"
/>
<span>{promptCopy.copied ? "Copied" : "Copy"}</span>
</button>
)}
</div>
<div className="text-sm text-white/90 break-words px-4 py-3 rounded-xl bg-black/20 leading-relaxed border border-white/5">
<div
ref={promptRef}
className={!isPromptExpanded ? "line-clamp-4" : ""}
>
{promptData.text || (
<span className="text-white/60">No prompt</span>
)}
</div>
</div>
{promptData.text && (isPromptClamped || isPromptExpanded) && (
<button
className="flex w-full items-center justify-center gap-1 text-xs text-white/70 hover:text-white transition-colors py-1"
onClick={() => setIsPromptExpanded((prev) => !prev)}
>
<span>{isPromptExpanded ? "Show less" : "Show more"}</span>
</button>
)}
</div>
)}
{promptData.contextImages && promptData.contextImages.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
<FontAwesomeIcon icon={faImage} />
<span>Reference Images</span>
</div>
<div className="grid grid-cols-5 gap-2">
{promptData.contextImages.map((contextImage, index) => {
const { thumbnail } = getContextImageThumbnail(
contextImage,
{ size: THUMBNAIL_SIZES.SMALL },
);
return (
<a
key={contextImage.media_token}
href={`/media/${contextImage.media_token}`}
target="_blank"
rel="noopener noreferrer"
className="relative aspect-square overflow-hidden rounded-lg border border-white/10 hover:border-white/40 transition-colors block"
>
<img
src={thumbnail}
alt={`Reference ${index + 1}`}
className="h-full w-full object-cover"
/>
</a>
);
})}
</div>
</div>
)}
{hasInfoSection && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
<FontAwesomeIcon icon={faCircleInfo} />
<span>Information</span>
</div>
<div className="flex flex-col rounded-xl bg-black/20 border border-white/5 overflow-hidden">
{promptData.modelType && (
<InfoRow
label="Model"
value={
<>
{getModelCreatorIcon(promptData.modelType)}
<span>
{getModelDisplayName(promptData.modelType)}
</span>
</>
}
/>
)}
{promptData.provider && (
<InfoRow
label="Provider"
value={
<>
{getProviderIconByName(
promptData.provider,
"h-4 w-4 invert",
)}
<span>
{getProviderDisplayName(promptData.provider)}
</span>
</>
}
/>
)}
{promptData.aspectRatio && (
<InfoRow
label="Aspect Ratio"
value={formatAspectRatio(promptData.aspectRatio)}
/>
)}
{promptData.resolution && (
<InfoRow
label="Resolution"
value={formatResolution(promptData.resolution)}
/>
)}
{promptData.durationSeconds != null && (
<InfoRow
label="Duration"
value={formatDuration(promptData.durationSeconds)}
/>
)}
{promptData.generateAudio != null && (
<InfoRow
label="Audio"
value={promptData.generateAudio ? "On" : "Off"}
/>
)}
{mediaWidth && mediaHeight && (
<InfoRow
label="Size"
value={`${mediaWidth} × ${mediaHeight}`}
/>
)}
{createdAt && (
<InfoRow
label="Created"
value={dayjs(createdAt).format("MMMM D, YYYY")}
/>
)}
</div>
</div>
)}
</>
)}
</div>
<div className="p-4 space-y-2 border-t border-white/5">
<div className="grid grid-cols-2 gap-2">
<Button
className="w-full border border-ui-panel-border bg-ui-controls/40 hover:bg-ui-controls/60 text-white"
icon={shareCopy.copied ? faCheck : faLink}
variant="secondary"
onClick={handleCopyShareLink}
>
{shareCopy.copied ? "Copied" : "Share"}
</Button>
<a
className={`w-full inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors border border-ui-panel-border ${
mediaUrl
? "bg-ui-controls/40 hover:bg-ui-controls/60 text-white"
: "bg-ui-controls/20 text-white/60 cursor-not-allowed pointer-events-none"
}`}
href={mediaUrl ? addCorsParam(mediaUrl) || mediaUrl : undefined}
download={
mediaUrl ? `artcraft-${mediaToken || "media"}` : undefined
}
aria-disabled={!mediaUrl}
target="_blank"
rel="noopener noreferrer"
>
<FontAwesomeIcon icon={faArrowDownToLine} />
Download
</a>
</div>
{onRecreate && promptData.hasToken && (
<Button
icon={faArrowRotateRight}
className="w-full border border-ui-panel-border bg-ui-controls/40 hover:bg-ui-controls/60 text-white"
variant="secondary"
onClick={onRecreate}
>
Recreate
</Button>
)}
{onDelete && mediaToken && (
<Button
icon={faTrashCan}
className="w-full bg-red-500/10 hover:bg-red-500/20 text-red-500 border border-red-500/20"
variant="destructive"
onClick={onDelete}
>
Delete
</Button>
)}
{showDownloadAppCta && (
<Button
icon={faArrowDownToLine}
className="w-full shadow-lg shadow-brand-primary/20"
variant="primary"
onClick={() => {
window.location.href = "/download";
}}
>
Download ArtCraft
</Button>
)}
</div>
</div>
);
}
export default LightboxDetails;
@@ -1,39 +1,29 @@
import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import { toast } from "@storyteller/ui-toaster";
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
import { MediaFilesApi, PromptsApi } from "@storyteller/api";
import { useEffect, useState, useMemo, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { MediaFilesApi, PromptsApi, type UserInfo } from "@storyteller/api";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCopy,
faLink,
faCheck,
faArrowDownToLine,
faPencil,
faCircleInfo,
faImage,
faChevronLeft,
faChevronRight,
faTrashCan,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import {
addCorsParam,
getContextImageThumbnail,
THUMBNAIL_SIZES,
PLACEHOLDER_IMAGES,
} from "@storyteller/common";
import {
getModelCreatorIcon,
getModelDisplayName,
getProviderDisplayName,
getProviderIconByName,
} from "@storyteller/model-list";
import { addCorsParam, PLACEHOLDER_IMAGES } from "@storyteller/common";
import { ActionReminderModal } from "@storyteller/ui-action-reminder-modal";
import { Viewer3D } from "@storyteller/ui-viewer-3d";
import useEmblaCarousel from "embla-carousel-react";
import type { EmblaOptionsType } from "embla-carousel";
import {
createPromptData,
EMPTY_PROMPT,
is3DModelUrl,
isVideoUrl,
type PromptData,
} from "./shared";
import { LightboxDetails } from "./LightboxDetails";
import { applyRecreateFromMediaToken } from "../../lib/recreate";
// ── Types ──────────────────────────────────────────────────────────────────
@@ -49,82 +39,6 @@ export interface LightboxItem {
imageUrls?: string[];
}
interface ContextImage {
media_links: { cdn_url: string; maybe_thumbnail_template: string | null };
media_token: string;
semantic: string;
}
interface PromptData {
text: string | null;
loading: boolean;
hasToken: boolean;
provider: string | null;
modelType: string | null;
contextImages: ContextImage[] | null;
}
const EMPTY_PROMPT: PromptData = {
text: null,
loading: false,
hasToken: false,
provider: null,
modelType: null,
contextImages: null,
};
const COPY_TIMEOUT = 1500;
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 isVideoUrl = (url: string): boolean =>
VIDEO_EXTENSIONS.some((ext) => url.toLowerCase().includes(ext));
const is3DModelUrl = (url: string): boolean =>
MODEL_3D_EXTENSIONS.some((ext) => url.toLowerCase().includes(ext));
// ── Helpers ────────────────────────────────────────────────────────────────
const useCopyFeedback = () => {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<number | null>(null);
const trigger = useCallback(() => {
setCopied(true);
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
timeoutRef.current = window.setTimeout(() => {
setCopied(false);
timeoutRef.current = null;
}, COPY_TIMEOUT);
}, []);
useEffect(
() => () => {
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
},
[],
);
return { copied, trigger };
};
const InfoRow = ({
label,
value,
}: {
label: string;
value: React.ReactNode;
}) => (
<div className="flex items-center justify-between px-4 py-3 border-b border-white/5 last:border-0">
<span className="text-sm text-white/60 font-medium">{label}</span>
<span className="text-sm text-white font-medium flex items-center gap-2">
{value}
</span>
</div>
);
// ── Component ──────────────────────────────────────────────────────────────
interface LightboxProps {
@@ -157,22 +71,19 @@ export function Lightbox({
onDeleted,
showBatchCarousel = true,
}: LightboxProps) {
const navigate = useNavigate();
const [mediaLoaded, setMediaLoaded] = useState(false);
const [promptData, setPromptData] = useState<PromptData>(EMPTY_PROMPT);
const [creator, setCreator] = useState<UserInfo | null>(null);
const [createdAt, setCreatedAt] = useState<string | null>(null);
const [batchImages, setBatchImages] = useState<string[] | null>(null);
const [batchTokens, setBatchTokens] = useState<string[] | null>(null);
const [mediaWidth, setMediaWidth] = useState<number | undefined>();
const [mediaHeight, setMediaHeight] = useState<number | undefined>();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [isPromptExpanded, setIsPromptExpanded] = useState(false);
const [isPromptClamped, setIsPromptClamped] = useState(false);
const [discoveredBatchToken, setDiscoveredBatchToken] = useState<
string | null
>(null);
const promptRef = useRef<HTMLDivElement>(null);
const promptCopy = useCopyFeedback();
const shareCopy = useCopyFeedback();
const mediaFilesApi = useMemo(() => new MediaFilesApi(), []);
const promptsApi = useMemo(() => new PromptsApi(), []);
@@ -182,7 +93,6 @@ export function Lightbox({
if (isOpen) {
setMediaLoaded(false);
setSelectedIndex(0);
setIsPromptExpanded(false);
setMediaWidth(undefined);
setMediaHeight(undefined);
setDiscoveredBatchToken(null);
@@ -193,6 +103,8 @@ export function Lightbox({
useEffect(() => {
if (!mediaToken || !isOpen) {
setPromptData(EMPTY_PROMPT);
setCreator(null);
setCreatedAt(null);
return;
}
@@ -207,31 +119,34 @@ export function Lightbox({
if (cancelled) return;
if (mediaResponse.success && mediaResponse.data) {
// Auto-discover batch token from media file
const batchToken = (mediaResponse.data as any)?.maybe_batch_token;
const file = mediaResponse.data;
setCreator(file.maybe_creator_user || null);
setCreatedAt(file.created_at || null);
const batchToken = (file as any)?.maybe_batch_token;
if (batchToken) setDiscoveredBatchToken(batchToken);
}
if (mediaResponse.success && mediaResponse.data?.maybe_prompt_token) {
const promptResponse = await promptsApi.GetPromptsByToken({
token: mediaResponse.data.maybe_prompt_token,
});
if (cancelled) return;
if (file.maybe_prompt_token) {
const promptResponse = await promptsApi.GetPromptsByToken({
token: file.maybe_prompt_token,
});
if (cancelled) return;
const d = promptResponse.success ? promptResponse.data : null;
setPromptData({
text: d?.maybe_positive_prompt || null,
loading: false,
hasToken: true,
provider: d?.maybe_generation_provider || null,
modelType: d?.maybe_model_type || null,
contextImages: d?.maybe_context_images || null,
});
const d = promptResponse.success ? promptResponse.data : null;
setPromptData(createPromptData(d, true, false));
} else {
setPromptData(EMPTY_PROMPT);
}
} else {
if (!cancelled) setPromptData(EMPTY_PROMPT);
setPromptData(EMPTY_PROMPT);
setCreator(null);
setCreatedAt(null);
}
} catch {
if (!cancelled) setPromptData(EMPTY_PROMPT);
if (!cancelled) {
setPromptData(EMPTY_PROMPT);
setCreator(null);
setCreatedAt(null);
}
}
}, 180);
@@ -298,22 +213,6 @@ export function Lightbox({
};
}, [effectiveBatchToken, mediaToken, cdnUrl, isOpen, mediaFilesApi]);
// Detect prompt clamping
useEffect(() => {
if (!promptRef.current || !promptData.text || promptData.loading) {
setIsPromptClamped(false);
return;
}
const raf = requestAnimationFrame(() => {
if (promptRef.current) {
setIsPromptClamped(
promptRef.current.scrollHeight > promptRef.current.clientHeight,
);
}
});
return () => cancelAnimationFrame(raf);
}, [promptData.text, promptData.loading, isPromptExpanded]);
// Effective image URLs
const effectiveImageUrls = useMemo(() => {
if (batchImages && batchImages.length > 0) return batchImages;
@@ -409,6 +308,22 @@ export function Lightbox({
}
}, [selectedMediaToken, mediaFilesApi, onDeleted, onClose]);
const recreateMediaClass: "image" | "video" | null = isVideo
? "video"
: is3D
? null
: "image";
const handleRecreate = useCallback(async () => {
if (!selectedMediaToken || !recreateMediaClass) return;
onClose();
await applyRecreateFromMediaToken(
selectedMediaToken,
recreateMediaClass,
navigate,
);
}, [selectedMediaToken, recreateMediaClass, navigate, onClose]);
return (
<>
<Modal
@@ -421,15 +336,6 @@ export function Lightbox({
<div className="flex flex-col sm:flex-row h-full">
{/* Media preview panel */}
<div className="group/nav relative flex h-[45vh] sm:h-full flex-1 items-center justify-center overflow-hidden rounded-t-xl sm:rounded-l-xl sm:rounded-tr-none bg-black">
{/* Close button */}
<button
onClick={onClose}
className="absolute top-3 right-3 z-40 flex h-8 w-8 items-center justify-center rounded-full bg-black/50 text-white/70 transition-colors hover:bg-black/70 hover:text-white"
aria-label="Close"
>
<FontAwesomeIcon icon={faXmark} className="h-4 w-4" />
</button>
{!selectedImageUrl ? (
<div className="flex h-full w-full items-center justify-center">
<span className="text-base-fg/60">Media not available</span>
@@ -452,7 +358,12 @@ export function Lightbox({
disablePictureInPicture
controlsList="nodownload noplaybackrate nofullscreen"
className="h-full w-full object-contain"
onLoadedData={() => setMediaLoaded(true)}
onLoadedData={(e) => {
setMediaLoaded(true);
const el = e.currentTarget;
setMediaWidth(el.videoWidth);
setMediaHeight(el.videoHeight);
}}
ref={(el) => {
if (el) {
el.setAttribute("webkit-playsinline", "true");
@@ -569,222 +480,22 @@ export function Lightbox({
)}
</div>
{/* Info sidebar */}
<div className="flex w-full sm:w-[300px] shrink-0 flex-col bg-ui-panel rounded-b-xl sm:rounded-b-none sm:rounded-r-xl min-h-0 flex-1 sm:flex-none sm:h-full overflow-hidden">
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-5 min-h-0">
{promptData.loading ? (
<div className="space-y-6 animate-pulse">
<div className="space-y-2">
<div className="h-4 w-20 bg-white/10 rounded" />
<div className="h-20 w-full bg-white/10 rounded-lg" />
</div>
<div className="space-y-2">
<div className="h-4 w-32 bg-white/10 rounded" />
<div className="h-10 w-full bg-white/10 rounded-lg" />
<div className="h-10 w-full bg-white/10 rounded-lg" />
</div>
</div>
) : (
<>
{/* Prompt */}
{promptData.hasToken && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
<FontAwesomeIcon icon={faPencil} />
<span>Prompt</span>
</div>
{promptData.text && (
<button
onClick={() => {
if (!promptData.text) return;
navigator.clipboard
.writeText(promptData.text)
.catch(() => {});
toast.success("Prompt copied");
promptCopy.trigger();
}}
className="flex items-center gap-1.5 text-xs text-white/60 hover:text-white transition-colors"
>
<FontAwesomeIcon
icon={promptCopy.copied ? faCheck : faCopy}
className="h-3 w-3"
/>
<span>{promptCopy.copied ? "Copied" : "Copy"}</span>
</button>
)}
</div>
<div className="text-sm text-white/90 break-words px-4 py-3 rounded-xl bg-black/20 leading-relaxed border border-white/5">
<div
ref={promptRef}
className={!isPromptExpanded ? "line-clamp-4" : ""}
>
{promptData.text || (
<span className="text-white/60">No prompt</span>
)}
</div>
</div>
{promptData.text &&
(isPromptClamped || isPromptExpanded) && (
<button
className="flex w-full items-center justify-center gap-1 text-xs text-white/70 hover:text-white transition-colors py-1"
onClick={() => setIsPromptExpanded((prev) => !prev)}
>
<span>
{isPromptExpanded ? "Show less" : "Show more"}
</span>
</button>
)}
</div>
)}
{/* Reference Images */}
{promptData.contextImages &&
promptData.contextImages.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
<FontAwesomeIcon icon={faImage} />
<span>Reference Images</span>
</div>
<div className="grid grid-cols-5 gap-2">
{promptData.contextImages.map(
(contextImage, index) => {
const { thumbnail } = getContextImageThumbnail(
contextImage,
{
size: THUMBNAIL_SIZES.SMALL,
},
);
return (
<a
key={contextImage.media_token}
href={`/media/${contextImage.media_token}`}
target="_blank"
rel="noopener noreferrer"
className="relative aspect-square overflow-hidden rounded-lg border border-white/10 hover:border-white/40 transition-colors block"
>
<img
src={thumbnail}
alt={`Reference ${index + 1}`}
className="h-full w-full object-cover"
/>
</a>
);
},
)}
</div>
</div>
)}
{/* Generation Details */}
{(promptData.provider || promptData.modelType) && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
<FontAwesomeIcon icon={faCircleInfo} />
<span>Information</span>
</div>
<div className="flex flex-col rounded-xl bg-black/20 border border-white/5 overflow-hidden">
{promptData.modelType && (
<InfoRow
label="Model"
value={
<>
{getModelCreatorIcon(promptData.modelType)}
<span>
{getModelDisplayName(promptData.modelType)}
</span>
</>
}
/>
)}
{promptData.provider && (
<InfoRow
label="Provider"
value={
<>
{getProviderIconByName(
promptData.provider,
"h-4 w-4 invert",
)}
<span>
{getProviderDisplayName(promptData.provider)}
</span>
</>
}
/>
)}
{mediaWidth && mediaHeight && (
<InfoRow
label="Size"
value={`${mediaWidth} x ${mediaHeight}`}
/>
)}
</div>
</div>
)}
</>
)}
</div>
{/* Action buttons */}
<div className="p-4 space-y-2 border-t border-white/5">
<div className="grid grid-cols-2 gap-2">
<Button
className="w-full border border-ui-panel-border bg-ui-controls/40 hover:bg-ui-controls/60 text-white"
icon={shareCopy.copied ? faCheck : faLink}
variant="secondary"
onClick={async () => {
if (!selectedMediaToken) return;
const shareUrl = `${SHARE_URL_BASE}${selectedMediaToken}`;
try {
await navigator.clipboard.writeText(shareUrl);
toast.success("Share link copied");
shareCopy.trigger();
} catch {
toast.error("Unable to copy link");
}
}}
>
{shareCopy.copied ? "Copied" : "Share"}
</Button>
<a
className={`w-full inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors border border-ui-panel-border ${
selectedImageUrl
? "bg-ui-controls/40 hover:bg-ui-controls/60 text-white"
: "bg-ui-controls/20 text-white/60 cursor-not-allowed pointer-events-none"
}`}
href={
selectedImageUrl
? addCorsParam(selectedImageUrl) || selectedImageUrl
: undefined
}
download={
selectedImageUrl
? `artcraft-${selectedMediaToken || "media"}`
: undefined
}
target="_blank"
rel="noopener noreferrer"
>
<FontAwesomeIcon icon={faArrowDownToLine} />
Download
</a>
</div>
{selectedMediaToken && (
<Button
icon={faTrashCan}
className="w-full bg-red-500/10 hover:bg-red-500/20 text-red-500 border border-red-500/20"
variant="destructive"
onClick={() => setConfirmDeleteOpen(true)}
>
Delete
</Button>
)}
</div>
</div>
<LightboxDetails
promptData={promptData}
mediaToken={selectedMediaToken}
mediaUrl={selectedImageUrl}
mediaWidth={mediaWidth}
mediaHeight={mediaHeight}
createdAt={createdAt}
creator={creator}
onClose={onClose}
onRecreate={recreateMediaClass ? handleRecreate : undefined}
onDelete={
selectedMediaToken
? () => setConfirmDeleteOpen(true)
: undefined
}
/>
</div>
</Modal>
@@ -0,0 +1,134 @@
import { useCallback, useEffect, useRef, useState } from "react";
export const SHARE_URL_BASE = "https://getartcraft.com/media/";
export const COPY_FEEDBACK_DURATION = 1500;
const VIDEO_EXTENSIONS = [".mp4", ".webm", ".mov", ".avi", ".mkv", ".m4v"];
const MODEL_3D_EXTENSIONS = [".glb", ".gltf", ".fbx", ".spz"];
export const isVideoUrl = (url: string): boolean =>
VIDEO_EXTENSIONS.some((ext) => url.toLowerCase().includes(ext));
export const is3DModelUrl = (url: string): boolean =>
MODEL_3D_EXTENSIONS.some((ext) => url.toLowerCase().includes(ext));
export interface ContextImage {
media_links: { cdn_url: string; maybe_thumbnail_template: string | null };
media_token: string;
semantic: string;
}
export interface PromptData {
text: string | null;
loading: boolean;
hasToken: boolean;
provider: string | null;
modelType: string | null;
modelClass: string | null;
contextImages: ContextImage[] | null;
aspectRatio: string | null;
resolution: string | null;
durationSeconds: number | null;
generationMode: string | null;
generateAudio: boolean | null;
}
export const EMPTY_PROMPT: PromptData = {
text: null,
loading: false,
hasToken: false,
provider: null,
modelType: null,
modelClass: null,
contextImages: null,
aspectRatio: null,
resolution: null,
durationSeconds: null,
generationMode: null,
generateAudio: null,
};
export const createPromptData = (
data: any,
hasToken: boolean,
loading = false,
): PromptData => ({
text: data?.maybe_positive_prompt || null,
loading,
hasToken,
provider: data?.maybe_generation_provider || null,
modelType: data?.maybe_model_type || null,
modelClass: data?.maybe_model_class || null,
contextImages: data?.maybe_context_images || null,
aspectRatio: data?.maybe_aspect_ratio || null,
resolution: data?.maybe_resolution || null,
durationSeconds: data?.maybe_duration_seconds ?? null,
generationMode: data?.maybe_generation_mode || null,
generateAudio: data?.maybe_generate_audio ?? null,
});
// ── Display helpers for prompt metadata ────────────────────────────────────
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",
};
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`;
export function useCopyFeedback() {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<number | null>(null);
const trigger = useCallback(() => {
setCopied(true);
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
timeoutRef.current = window.setTimeout(() => {
setCopied(false);
timeoutRef.current = null;
}, COPY_FEEDBACK_DURATION);
}, []);
useEffect(
() => () => {
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
},
[],
);
return { copied, trigger };
}
@@ -329,15 +329,26 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
);
}, [mentionItems, mentionState.isOpen, mentionState.query]);
// Build a regex that matches any known mention label
// Build a regex that matches any known mention label. Case-insensitive so
// a typed `@image1` still highlights when the canonical label is `@Image1`.
// Sort longest-first so `@Pumpkin Head` matches before `@Pumpkin`.
const mentionRegex = useMemo(() => {
const labels = mentionItems.map((item) => item.label);
if (labels.length === 0) return null;
const sorted = [...labels].sort((a, b) => b.length - a.length);
const pattern = sorted.map((l) => escapeRegex(l)).join("|");
return new RegExp(`(${pattern})`, "g");
return new RegExp(`(${pattern})`, "gi");
}, [mentionItems]);
// Case-insensitive colorMap lookup: "@image1" finds "@Image1".
const lowerColorMap = useMemo(() => {
const m: Record<string, string> = {};
for (const [k, v] of Object.entries(colorMap)) {
m[k.toLowerCase()] = v;
}
return m;
}, [colorMap]);
// Build innerHTML with colored @mentions inline
const buildHTML = useCallback(
(text: string): string => {
@@ -356,7 +367,7 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
while ((match = regex.exec(text)) !== null) {
const fullMatch = match[0];
const color = colorMap[fullMatch];
const color = lowerColorMap[fullMatch.toLowerCase()];
if (match.index > lastIndex) {
html += escapeHTML(text.slice(lastIndex, match.index));
@@ -382,7 +393,7 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
return html;
},
[colorMap, mentionRegex],
[lowerColorMap, mentionRegex],
);
// Sync DOM when value changes from parent
@@ -439,12 +450,18 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
}
}, []);
// Detect @mention trigger from cursor position
// Detect @mention trigger from cursor position. The `@` is a valid mention
// trigger when it follows anything that isn't a word character — that covers
// whitespace, punctuation, CJK/Chinese text, emoji, line starts, etc. Only
// reject it when it's sitting mid-word after an alphanumeric/underscore.
const detectMention = useCallback(
(text: string, cursorPos: number) => {
const textBefore = text.slice(0, cursorPos);
const lastAt = textBefore.lastIndexOf("@");
if (lastAt !== -1 && (lastAt === 0 || /\s/.test(text[lastAt - 1]))) {
if (
lastAt !== -1 &&
(lastAt === 0 || !/[A-Za-z0-9_]/.test(text[lastAt - 1]))
) {
const query = text.slice(lastAt, cursorPos);
if (!query.includes("\n")) {
const pos = getOffsetRect(lastAt);
@@ -305,21 +305,27 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
],
);
// Build a regex that matches all known @-mention labels
// Build a regex that matches all known @-mention labels. Case-insensitive
// so a typed `@image1` still highlights when the canonical label is
// `@Image1`.
const mentionRegex = useMemo(() => {
if (!mentionItems?.length) return null;
// Escape special regex characters in labels and sort longest first
const escaped = mentionItems
.map((m) => m.label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.sort((a, b) => b.length - a.length);
return new RegExp(`(${escaped.join("|")})`, "g");
return new RegExp(`(${escaped.join("|")})`, "gi");
}, [mentionItems]);
// Set of valid mention labels for fast lookup
const mentionLabelSet = useMemo(
() => new Set(mentionItems?.map((m) => m.label)),
[mentionItems],
);
// Lower-cased label → canonical label lookup, so the split-parts match
// against the original case-sensitive mentionItems regardless of how the
// user capitalized their mention.
const mentionLabelMap = useMemo(() => {
const m = new Map<string, string>();
for (const item of mentionItems ?? []) {
m.set(item.label.toLowerCase(), item.label);
}
return m;
}, [mentionItems]);
// Build label → color map for MentionTextarea
const mentionColorMap = useMemo(() => {
@@ -336,12 +342,13 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
if (!hasMentionItems || !mentionRegex) return null;
const parts = prompt.split(mentionRegex);
return parts.map((part, i) => {
if (mentionLabelSet.has(part)) {
const canonical = mentionLabelMap.get(part.toLowerCase());
if (canonical) {
return (
<span
key={i}
style={{
color: getMentionColor(part, mentionItems),
color: getMentionColor(canonical, mentionItems),
fontWeight: 600,
}}
>
@@ -351,7 +358,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
}
return <span key={i}>{part}</span>;
});
}, [prompt, hasMentionItems, mentionRegex, mentionLabelSet, mentionItems]);
}, [prompt, hasMentionItems, mentionRegex, mentionLabelMap, mentionItems]);
return (
<div ref={ref}>
@@ -0,0 +1,184 @@
import type { NavigateFunction } from "react-router-dom";
import {
MediaFilesApi,
PromptsApi,
type Prompts,
} from "@storyteller/api";
import { toast } from "@storyteller/ui-toaster";
import type {
RefAudio,
RefImage,
RefVideo,
} from "../components/prompt-box/types";
import { useCreateImageStore } from "../pages/create-image/create-image-store";
import {
useCreateVideoStore,
type VideoInputMode,
} from "../pages/create-video/create-video-store";
// ── Types ──────────────────────────────────────────────────────────────────
export type RecreateMediaClass = "image" | "video";
export interface RecreatePayload {
prompt: string;
referenceImages: RefImage[];
aspectRatio?: string;
resolution?: string;
modelId?: string;
// video-only
endFrameImage?: RefImage;
referenceVideos?: RefVideo[];
referenceAudios?: RefAudio[];
generateWithSound?: boolean;
durationSeconds?: number;
inputMode?: VideoInputMode;
}
// ── Public API ─────────────────────────────────────────────────────────────
export async function applyRecreateFromMediaToken(
mediaToken: string,
fallbackMediaClass: RecreateMediaClass,
navigate: NavigateFunction,
): Promise<void> {
try {
const promptData = await fetchPromptForMedia(mediaToken);
if (!promptData) {
toast.error("Recreate unavailable for this media");
return;
}
const mediaClass = resolveMediaClass(promptData, fallbackMediaClass);
if (!mediaClass) {
toast.error("Recreate not supported for this media type");
return;
}
const payload = buildRecreatePayload(promptData, mediaClass);
if (mediaClass === "video") {
useCreateVideoStore.getState().setPendingRecreate(payload);
navigate("/create-video");
} else {
useCreateImageStore.getState().setPendingRecreate(payload);
navigate("/create-image");
}
} catch {
toast.error("Failed to load recreate data");
}
}
// Prefer the authoritative `maybe_model_class` from the prompt (image/video/
// audio/3d/…) over a URL-based guess. Unsupported classes return null so the
// caller can short-circuit with a clear error.
function resolveMediaClass(
promptData: Prompts,
fallback: RecreateMediaClass,
): RecreateMediaClass | null {
const cls = promptData.maybe_model_class;
if (cls === "image") return "image";
if (cls === "video") return "video";
if (cls && cls !== "") return null;
return fallback;
}
export function buildRecreatePayload(
promptData: Prompts,
mediaClass: RecreateMediaClass,
): RecreatePayload {
const contextImages = promptData.maybe_context_images || [];
const { referenceImages, endFrameImage, referenceVideos, referenceAudios } =
partitionContextImages(contextImages);
const payload: RecreatePayload = {
prompt: promptData.maybe_positive_prompt || "",
referenceImages,
aspectRatio: promptData.maybe_aspect_ratio || undefined,
resolution: promptData.maybe_resolution || undefined,
modelId: promptData.maybe_model_type || undefined,
};
if (mediaClass === "video") {
payload.endFrameImage = endFrameImage;
payload.referenceVideos = referenceVideos;
payload.referenceAudios = referenceAudios;
payload.generateWithSound = promptData.maybe_generate_audio ?? undefined;
payload.durationSeconds = promptData.maybe_duration_seconds ?? undefined;
payload.inputMode = inferInputMode(promptData, referenceImages);
}
return payload;
}
// ── Internals ──────────────────────────────────────────────────────────────
async function fetchPromptForMedia(
mediaToken: string,
): Promise<Prompts | null> {
const mediaApi = new MediaFilesApi();
const mediaResp = await mediaApi.GetMediaFileByToken({
mediaFileToken: mediaToken,
});
if (!mediaResp.success || !mediaResp.data?.maybe_prompt_token) return null;
const promptsApi = new PromptsApi();
const promptResp = await promptsApi.GetPromptsByToken({
token: mediaResp.data.maybe_prompt_token,
});
return promptResp.success ? promptResp.data ?? null : null;
}
interface PartitionedContext {
referenceImages: RefImage[];
endFrameImage?: RefImage;
referenceVideos: RefVideo[];
referenceAudios: RefAudio[];
}
function partitionContextImages(
contextImages: { semantic: string; media_token: string; media_links: { cdn_url: string } }[],
): PartitionedContext {
const referenceImages: RefImage[] = [];
let endFrameImage: RefImage | undefined;
const referenceVideos: RefVideo[] = [];
const referenceAudios: RefAudio[] = [];
for (const ci of contextImages) {
const base = {
id: crypto.randomUUID(),
url: ci.media_links.cdn_url,
mediaToken: ci.media_token,
file: new File([], "recreate-ref"),
};
switch (ci.semantic) {
case "vid_end_frame":
endFrameImage = base;
break;
case "vid_ref":
referenceVideos.push({ ...base, duration: 0 });
break;
case "audioref":
referenceAudios.push({ ...base, duration: 0 });
break;
default:
// imgref, imgref_character, imgref_style, imgref_bg, imgsrc,
// vid_start_frame, imgmask → image reference row
referenceImages.push(base);
break;
}
}
return { referenceImages, endFrameImage, referenceVideos, referenceAudios };
}
function inferInputMode(
promptData: Prompts,
referenceImages: RefImage[],
): VideoInputMode {
const mode = promptData.maybe_generation_mode;
if (mode === "keyframe" || mode === "reference") return mode;
const hasKeyframeSemantics = (promptData.maybe_context_images || []).some(
(ci) => ci.semantic === "vid_start_frame" || ci.semantic === "vid_end_frame",
);
if (hasKeyframeSemantics) return "keyframe";
return referenceImages.length > 0 ? "reference" : "keyframe";
}
@@ -0,0 +1,26 @@
import { useCallback, useState } from "react";
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
export function useDismissibleBanner(
key: string,
hideForMs: number = ONE_DAY_MS,
): { visible: boolean; dismiss: () => void } {
const [visible, setVisible] = useState(() => {
if (typeof window === "undefined") return true;
const raw = window.localStorage.getItem(key);
if (!raw) return true;
const ts = parseInt(raw, 10);
if (Number.isNaN(ts)) return true;
return Date.now() - ts > hideForMs;
});
const dismiss = useCallback(() => {
if (typeof window !== "undefined") {
window.localStorage.setItem(key, String(Date.now()));
}
setVisible(false);
}, [key]);
return { visible, dismiss };
}
@@ -1,4 +1,5 @@
import { create } from "zustand";
import type { RecreatePayload } from "../../lib/recreate";
export interface GeneratedImage {
media_token: string;
@@ -30,7 +31,10 @@ export type ImageUiState = {
type CreateImageState = {
batches: ImageBatch[];
ui: ImageUiState;
pendingRecreate: RecreatePayload | null;
setUi: (patch: Partial<ImageUiState>) => void;
setPendingRecreate: (payload: RecreatePayload | null) => void;
consumePendingRecreate: () => RecreatePayload | null;
startBatch: (
prompt: string,
requestedCount: number,
@@ -53,13 +57,22 @@ const DEFAULT_UI: ImageUiState = {
quality: undefined,
};
export const useCreateImageStore = create<CreateImageState>((set) => ({
export const useCreateImageStore = create<CreateImageState>((set, get) => ({
batches: [],
ui: { ...DEFAULT_UI },
pendingRecreate: null,
setUi: (patch) =>
set((s) => ({ ui: { ...s.ui, ...patch } })),
setPendingRecreate: (payload) => set({ pendingRecreate: payload }),
consumePendingRecreate: () => {
const payload = get().pendingRecreate;
if (payload) set({ pendingRecreate: null });
return payload;
},
startBatch: (prompt, requestedCount, modelLabel) => {
const id = crypto.randomUUID();
const batch: ImageBatch = {
@@ -19,6 +19,7 @@ import {
CreateMediaPageShell,
} from "../../components/generation-gallery";
import { Lightbox } from "../../components/lightbox/lightbox";
import { AppDownloadCta } from "../../components/app-download-cta/AppDownloadCta";
import { useCreateImageStore } from "./create-image-store";
import { enqueueImageGeneration, startPolling } from "./generate-image-api";
import { AspectRatioPicker } from "./components/AspectRatioPicker";
@@ -29,7 +30,6 @@ import { useImageCostEstimate } from "../../lib/cost-estimate-api";
import {
useOmniGenImageModels,
getModelCreatorIconPath,
getModelDisplayName,
} from "../../lib/omni-gen-hooks";
// ── Constants ────────────────────────────────────────────────────────────
@@ -47,7 +47,7 @@ function buildModelPopoverItems(
): PopoverItem[] {
_modelLookup = new Map(models.map((m) => [m.model, m]));
return models.map((model) => ({
label: getModelDisplayName(model.model, model.full_name),
label: model.full_name || model.model,
selected: model.model === selectedId,
icon: (
<img
@@ -170,6 +170,23 @@ export default function CreateImage() {
// ── Effects ──────────────────────────────────────────────────────────────
// Consume a pending recreate payload (set by the lightbox Recreate button)
// and populate the promptbox fields. Does NOT trigger generation. Subscribes
// to the store so it fires even when the user is already on this route.
const pendingRecreate = useCreateImageStore((s) => s.pendingRecreate);
useEffect(() => {
if (!pendingRecreate) return;
const payload = useCreateImageStore.getState().consumePendingRecreate();
if (!payload) return;
setReferenceImages(payload.referenceImages);
setUi({
prompt: payload.prompt,
...(payload.aspectRatio ? { aspectRatio: payload.aspectRatio } : {}),
...(payload.resolution ? { resolution: payload.resolution } : {}),
...(payload.modelId ? { selectedModelId: payload.modelId } : {}),
});
}, [pendingRecreate, setUi]);
// Resume polling for pending batches
useEffect(() => {
const cleanups = pollingCleanupsRef.current;
@@ -346,11 +363,12 @@ export default function CreateImage() {
}
promptBox={
<div
ref={promptBoxRef}
className="animate-fade-in-up fixed bottom-2 sm:bottom-3 left-0 right-0 z-30 mx-auto w-full max-w-[900px] px-2 sm:px-4"
style={{ animationDelay: "150ms" }}
>
<AppDownloadCta />
<PromptBox
ref={promptBoxRef}
prompt={prompt}
onPromptChange={setPrompt}
onSubmit={handleGenerate}
@@ -1,4 +1,5 @@
import { create } from "zustand";
import type { RecreatePayload } from "../../lib/recreate";
export interface GeneratedVideo {
media_token: string;
@@ -33,7 +34,10 @@ export type VideoUiState = {
type CreateVideoState = {
batches: VideoBatch[];
ui: VideoUiState;
pendingRecreate: RecreatePayload | null;
setUi: (patch: Partial<VideoUiState>) => void;
setPendingRecreate: (payload: RecreatePayload | null) => void;
consumePendingRecreate: () => RecreatePayload | null;
startBatch: (prompt: string, modelLabel: string) => string;
setBatchJobToken: (batchId: string, jobToken: string) => void;
completeBatch: (batchId: string, video: GeneratedVideo) => void;
@@ -54,13 +58,22 @@ const DEFAULT_UI: VideoUiState = {
numVideos: 1,
};
export const useCreateVideoStore = create<CreateVideoState>((set) => ({
export const useCreateVideoStore = create<CreateVideoState>((set, get) => ({
batches: [],
ui: { ...DEFAULT_UI },
pendingRecreate: null,
setUi: (patch) =>
set((s) => ({ ui: { ...s.ui, ...patch } })),
setPendingRecreate: (payload) => set({ pendingRecreate: payload }),
consumePendingRecreate: () => {
const payload = get().pendingRecreate;
if (payload) set({ pendingRecreate: null });
return payload;
},
startBatch: (prompt, modelLabel) => {
const id = crypto.randomUUID();
const batch: VideoBatch = {
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faClock,
@@ -32,6 +33,7 @@ import {
CreateMediaPageShell,
} from "../../components/generation-gallery";
import { Lightbox } from "../../components/lightbox/lightbox";
import { AppDownloadCta } from "../../components/app-download-cta/AppDownloadCta";
import { useCreateVideoStore } from "./create-video-store";
import {
enqueueVideoGeneration,
@@ -46,7 +48,6 @@ import { useVideoCostEstimate } from "../../lib/cost-estimate-api";
import {
useOmniGenVideoModels,
getModelCreatorIconPath,
getModelDisplayName,
} from "../../lib/omni-gen-hooks";
// ── Constants ────────────────────────────────────────────────────────────
@@ -105,7 +106,7 @@ function buildModelPopoverItems(
): PopoverItem[] {
_modelLookup = new Map(models.map((m) => [m.model, m]));
return models.map((model) => ({
label: getModelDisplayName(model.model, model.full_name),
label: model.full_name || model.model,
selected: model.model === selectedId,
icon: (
<img
@@ -440,6 +441,44 @@ export default function CreateVideo() {
// ── Effects ──────────────────────────────────────────────────────────────
// Consume a pending recreate payload (set by the lightbox Recreate button)
// and populate the promptbox fields. Does NOT trigger generation. Subscribes
// to the store so it fires even when the user is already on this route.
//
// References must commit BEFORE the prompt so the mention highlighter (which
// builds its label regex from `referenceImages`/`referenceVideos`/etc.) knows
// about every `@ImageN` before the contentEditable does its DOM sync. Without
// this ordering, only the last reference's mention would end up colored.
const pendingRecreate = useCreateVideoStore((s) => s.pendingRecreate);
useEffect(() => {
if (!pendingRecreate) return;
const payload = useCreateVideoStore.getState().consumePendingRecreate();
if (!payload) return;
flushSync(() => {
setReferenceImages(payload.referenceImages);
setEndFrameImage(payload.endFrameImage);
setReferenceVideos(payload.referenceVideos ?? []);
setReferenceAudios(payload.referenceAudios ?? []);
setUi({
...(payload.modelId ? { selectedModelId: payload.modelId } : {}),
...(payload.inputMode ? { inputMode: payload.inputMode } : {}),
});
});
setUi({
prompt: payload.prompt,
...(payload.aspectRatio ? { selectedSize: payload.aspectRatio } : {}),
...(payload.resolution ? { resolution: payload.resolution } : {}),
...(payload.durationSeconds != null
? { duration: payload.durationSeconds }
: {}),
...(payload.generateWithSound != null
? { generateWithSound: payload.generateWithSound }
: {}),
});
}, [pendingRecreate, setUi]);
useEffect(() => {
const cleanups = pollingCleanupsRef.current;
const pendingBatches = useCreateVideoStore
@@ -770,6 +809,7 @@ export default function CreateVideo() {
}
promptBox={
<div
ref={promptBoxRef}
className="animate-fade-in-up fixed bottom-2 sm:bottom-3 left-0 right-0 z-30 mx-auto w-full max-w-[900px] px-2 sm:px-4"
style={{ animationDelay: "150ms" }}
>
@@ -782,8 +822,8 @@ export default function CreateVideo() {
</span>
</div>
)} */}
<AppDownloadCta />
<PromptBox
ref={promptBoxRef}
prompt={prompt}
onPromptChange={setPrompt}
onSubmit={handleGenerate}
@@ -1,58 +1,20 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams, useSearchParams } from "react-router-dom";
import dayjs from "dayjs";
import { Button } from "@storyteller/ui-button";
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import { toast } from "@storyteller/ui-toaster";
import { MediaFilesApi, PromptsApi, UserInfo } from "@storyteller/api";
import { Gravatar } from "@storyteller/ui-gravatar";
import { twMerge } from "tailwind-merge";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCopy,
faLink,
faCheck,
faArrowDownToLine,
faPencil,
faCircleInfo,
faImage,
faUser,
} from "@fortawesome/pro-solid-svg-icons";
import {
addCorsParam,
getContextImageThumbnail,
THUMBNAIL_SIZES,
PLACEHOLDER_IMAGES,
} from "@storyteller/common";
import {
getModelCreatorIcon,
getModelDisplayName,
getProviderDisplayName,
getProviderIconByName,
} from "@storyteller/model-list";
import { MediaFilesApi, PromptsApi, type UserInfo } from "@storyteller/api";
import { addCorsParam, PLACEHOLDER_IMAGES } from "@storyteller/common";
import { Viewer3D } from "@storyteller/ui-viewer-3d";
import Seo from "../../components/seo";
const VIDEO_EXTENSIONS = [".mp4", ".webm", ".mov", ".avi", ".mkv", ".m4v"];
const MODEL_3D_EXTENSIONS = [".glb", ".gltf", ".fbx", ".spz"];
const COPY_FEEDBACK_DURATION = 1500;
const SHARE_URL_BASE = "https://getartcraft.com/media/";
const isVideoUrl = (url: string): boolean => {
const urlLower = url.toLowerCase();
return VIDEO_EXTENSIONS.some((ext) => urlLower.includes(ext));
};
const is3DModelUrl = (url: string): boolean => {
const urlLower = url.toLowerCase();
return MODEL_3D_EXTENSIONS.some((ext) => urlLower.includes(ext));
};
interface ContextImage {
media_links: { cdn_url: string; maybe_thumbnail_template: string };
media_token: string;
semantic: string;
}
import { LightboxDetails } from "../../components/lightbox/LightboxDetails";
import {
createPromptData,
EMPTY_PROMPT,
is3DModelUrl,
isVideoUrl,
type PromptData,
} from "../../components/lightbox/shared";
import { applyRecreateFromMediaToken } from "../../lib/recreate";
interface MediaData {
url: string | null;
@@ -66,98 +28,25 @@ interface MediaData {
creator: UserInfo | null;
}
interface PromptData {
text: string | null;
loading: boolean;
hasToken: boolean;
provider: string | null;
modelType: string | null;
contextImages: ContextImage[] | null;
}
const EMPTY_PROMPT_DATA: PromptData = {
text: null,
loading: false,
hasToken: false,
provider: null,
modelType: null,
contextImages: null,
const EMPTY_MEDIA: MediaData = {
url: null,
token: null,
createdAt: null,
isVideo: false,
is3D: false,
isLoaded: false,
creator: null,
};
const createPromptData = (
data: any,
hasToken: boolean,
loading: boolean = false,
): PromptData => ({
text: data?.maybe_positive_prompt || null,
loading,
hasToken,
provider: data?.maybe_generation_provider || null,
modelType: data?.maybe_model_type || null,
contextImages: data?.maybe_context_images || null,
});
const useCopyFeedback = () => {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<number | null>(null);
const triggerCopy = useCallback(() => {
setCopied(true);
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
timeoutRef.current = window.setTimeout(() => {
setCopied(false);
timeoutRef.current = null;
}, COPY_FEEDBACK_DURATION);
}, []);
useEffect(() => {
return () => {
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
};
}, []);
return { copied, triggerCopy };
};
const InfoRow = ({
label,
value,
}: {
label: string;
value: React.ReactNode;
}) => (
<div className="flex items-center justify-between px-4 py-3 border-b border-white/5 last:border-0">
<span className="text-sm text-white/60 font-medium">{label}</span>
<span className="text-sm text-white font-medium flex items-center gap-2">
{value}
</span>
</div>
);
export default function MediaPage() {
const { id: routeId } = useParams<{ id?: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const mediaIdParam = routeId || searchParams.get("media") || undefined;
const [media, setMedia] = useState<MediaData>({
url: null,
token: null,
createdAt: null,
isVideo: false,
is3D: false,
isLoaded: false,
creator: null,
});
const [media, setMedia] = useState<MediaData>(EMPTY_MEDIA);
const [mediaRecordLoading, setMediaRecordLoading] = useState(true);
const [promptData, setPromptData] = useState<PromptData>(EMPTY_PROMPT_DATA);
const shareCopy = useCopyFeedback();
const promptCopy = useCopyFeedback();
const [promptData, setPromptData] = useState<PromptData>(EMPTY_PROMPT);
const loadMedia = useCallback(async (id: string) => {
setMediaRecordLoading(true);
@@ -202,30 +91,14 @@ export default function MediaPage() {
setPromptData((prev) => ({ ...prev, loading: false }));
}
} else {
setPromptData(EMPTY_PROMPT_DATA);
setPromptData(EMPTY_PROMPT);
}
} else {
setMedia({
url: null,
token: null,
createdAt: null,
isVideo: false,
is3D: false,
isLoaded: false,
creator: null,
});
setMedia(EMPTY_MEDIA);
toast.error("Media not found");
}
} catch {
setMedia({
url: null,
token: null,
createdAt: null,
isVideo: false,
is3D: false,
isLoaded: false,
creator: null,
});
setMedia(EMPTY_MEDIA);
toast.error("Failed to load media");
} finally {
setMediaRecordLoading(false);
@@ -238,29 +111,16 @@ export default function MediaPage() {
}
}, [mediaIdParam, loadMedia]);
const handleCopyPrompt = useCallback(async () => {
if (!promptData.text) return;
try {
await navigator.clipboard.writeText(promptData.text);
promptCopy.triggerCopy();
} catch {
toast.error("Unable to copy prompt");
}
}, [promptData.text, promptCopy]);
const recreateMediaClass: "image" | "video" | null = media.isVideo
? "video"
: media.is3D
? null
: "image";
const handleCopyShareLink = useCallback(async () => {
if (!media.token) return;
const shareUrl = `${SHARE_URL_BASE}${media.token}`;
try {
await navigator.clipboard.writeText(shareUrl);
shareCopy.triggerCopy();
toast.success("Share link copied");
} catch {
toast.error("Unable to copy link");
}
}, [media.token, shareCopy]);
const shareButtonIcon = shareCopy.copied ? faCheck : faLink;
const handleRecreate = useCallback(async () => {
if (!media.token || !recreateMediaClass) return;
await applyRecreateFromMediaToken(media.token, recreateMediaClass, navigate);
}, [media.token, recreateMediaClass, navigate]);
return (
<div className="relative min-h-screen w-full p-4 pt-16 bg-dots flex items-start lg:items-center justify-center">
@@ -346,269 +206,19 @@ export default function MediaPage() {
)}
</div>
{/* Sidebar Area */}
<div className="flex w-full lg:w-[360px] shrink-0 flex-col bg-ui-panel h-auto lg:h-full">
<div className="flex-1 overflow-y-auto p-5 flex flex-col gap-6">
{mediaRecordLoading ? (
<div className="space-y-6 animate-pulse">
<div className="flex items-center gap-3">
<div className="h-9 w-9 bg-white/10 rounded-xl" />
<div className="space-y-1.5">
<div className="h-4 w-24 bg-white/10 rounded" />
<div className="h-3 w-12 bg-white/10 rounded" />
</div>
</div>
<div className="space-y-2">
<div className="h-4 w-20 bg-white/10 rounded" />
<div className="h-4 w-40 bg-white/10 rounded" />
</div>
<div className="space-y-2">
<div className="h-4 w-16 bg-white/10 rounded" />
<div className="h-20 w-full bg-white/10 rounded-lg" />
</div>
<div className="space-y-2">
<div className="h-4 w-32 bg-white/10 rounded" />
<div className="grid grid-cols-5 gap-2">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="aspect-square w-full bg-white/10 rounded-lg"
/>
))}
</div>
</div>
</div>
) : (
<>
{media.creator && (
<div className="flex items-center gap-3 pb-2">
{media.creator.core_info ? (
<Gravatar
size={36}
username={media.creator.username}
email_hash={media.creator.email_gravatar_hash}
avatarIndex={
media.creator.core_info.default_avatar.image_index
}
backgroundIndex={
media.creator.core_info.default_avatar.color_index
}
className="rounded-xl border-white/10"
/>
) : (
<div className="h-9 w-9 shrink-0 flex items-center justify-center rounded-xl bg-white/10 text-white/50 border border-white/5">
<FontAwesomeIcon icon={faUser} />
</div>
)}
<div className="flex flex-col gap-1">
<span className="text-white text-sm font-semibold leading-none">
{media.creator.display_name}
</span>
<span className="text-white/60 text-xs font-medium">
Author
</span>
</div>
</div>
)}
{media.createdAt && (
<div className="space-y-1.5 hidden">
<div className="text-sm font-medium text-white/90">
Created
</div>
<div className="text-sm text-white/60">
{dayjs(media.createdAt).format("MMM D, YYYY")} at{" "}
{dayjs(media.createdAt).format("hh:mm A")}
</div>
</div>
)}
{promptData.hasToken && (
<>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
<FontAwesomeIcon icon={faPencil} />
<span>Prompt</span>
</div>
<button
onClick={handleCopyPrompt}
className="flex items-center gap-1.5 text-xs text-white/60 hover:text-white transition-colors"
>
<FontAwesomeIcon
icon={promptCopy.copied ? faCheck : faCopy}
/>
<span>{promptCopy.copied ? "Copied" : "Copy"}</span>
</button>
</div>
<div className="relative text-sm text-white/90 break-words px-4 py-3 rounded-xl bg-black/20 leading-relaxed border border-white/5">
{promptData.loading ? (
<div className="flex items-center gap-2">
<LoadingSpinner className="h-4 w-4" />
<span className="text-sm text-white/60">
Loading prompt...
</span>
</div>
) : (
promptData.text || (
<span className="text-sm text-white/60">
No prompt
</span>
)
)}
</div>
</div>
{promptData.contextImages &&
promptData.contextImages.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
<FontAwesomeIcon icon={faImage} />
<span>Reference Images</span>
</div>
<div className="grid grid-cols-5 gap-2">
{promptData.contextImages.map(
(contextImage, index) => {
const { thumbnail } =
getContextImageThumbnail(contextImage, {
size: THUMBNAIL_SIZES.SMALL,
});
return (
<a
key={contextImage.media_token}
href={`/media/${contextImage.media_token}`}
target="_blank"
rel="noopener noreferrer"
className="glass relative aspect-square overflow-hidden rounded-lg border border-white/10 hover:border-white/40 transition-colors block"
>
<img
src={thumbnail}
alt={`Reference image ${index + 1}`}
className="h-full w-full object-cover"
/>
</a>
);
},
)}
</div>
</div>
)}
{(promptData.provider ||
promptData.modelType ||
media.createdAt) && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
<FontAwesomeIcon icon={faCircleInfo} />
<span>Information</span>
</div>
<div className="flex flex-col rounded-xl bg-black/20 border border-white/5 overflow-hidden">
{promptData.modelType && (
<InfoRow
label="Model"
value={
<>
{getModelCreatorIcon(promptData.modelType)}
<span>
{getModelDisplayName(
promptData.modelType,
)}
</span>
</>
}
/>
)}
{promptData.provider && (
<InfoRow
label="Provider"
value={
<>
{getProviderIconByName(
promptData.provider,
"h-4 w-4 invert",
)}
<span>
{getProviderDisplayName(
promptData.provider,
)}
</span>
</>
}
/>
)}
{media.width && media.height && (
<InfoRow
label="Size"
value={`${media.width} × ${media.height}`}
/>
)}
{media.createdAt && (
<InfoRow
label="Created"
value={dayjs(media.createdAt).format(
"MMMM D, YYYY",
)}
/>
)}
</div>
</div>
)}
</>
)}
</>
)}
</div>
<div className="p-5 pt-2 space-y-3 rounded-br-xl lg:rounded-bl-none rounded-bl-xl">
<div className="grid grid-cols-2 gap-2">
<Button
className="w-full border border-ui-panel-border bg-ui-controls/40 hover:bg-ui-controls/60 text-white"
icon={shareButtonIcon}
variant="secondary"
onClick={handleCopyShareLink}
>
{shareCopy.copied ? "Copied" : "Share"}
</Button>
<a
className={twMerge(
"w-full inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors border border-ui-panel-border",
media.url
? "bg-ui-controls/40 hover:bg-ui-controls/60 text-white"
: "bg-ui-controls/20 text-white/60 cursor-not-allowed pointer-events-none",
)}
href={
media.url ? addCorsParam(media.url) || media.url : undefined
}
download={
media.url
? `artcraft-${media.token || (media.isVideo ? "video" : "image")}`
: undefined
}
aria-disabled={!media.url}
target="_blank"
rel="noopener noreferrer"
>
<FontAwesomeIcon icon={faArrowDownToLine} />
Download
</a>
</div>
<Button
icon={faArrowDownToLine}
className="w-full shadow-lg shadow-brand-primary/20"
variant="primary"
onClick={() => {
window.location.href = "/download";
}}
>
Download ArtCraft
</Button>
</div>
</div>
<LightboxDetails
promptData={
mediaRecordLoading ? { ...EMPTY_PROMPT, loading: true } : promptData
}
mediaToken={media.token}
mediaUrl={media.url}
mediaWidth={media.width}
mediaHeight={media.height}
createdAt={media.createdAt}
creator={media.creator}
onRecreate={recreateMediaClass ? handleRecreate : undefined}
showDownloadAppCta
/>
</div>
</div>
</div>
@@ -32,6 +32,7 @@ export const MODEL_TYPE_TO_CREATOR: Record<string, ModelCreator> = {
seedance_1p0_lite: ModelCreator.Bytedance,
seedance_1p5_pro: ModelCreator.Bytedance,
seedance_2p0: ModelCreator.Bytedance,
seedance_2p0_fast: ModelCreator.Bytedance,
// Seedance — dot-normalized aliases
seedance_1_0_lite: ModelCreator.Bytedance,
// Seedream
@@ -185,6 +186,7 @@ export const getModelDisplayName = (modelType: string): string => {
seedance_1p0_lite: "Seedance 1.0 Lite",
seedance_1p5_pro: "Seedance 1.5 Pro",
seedance_2p0: "Seedance 2.0",
seedance_2p0_fast: "Seedance 2.0 Fast",
// Seedance — dot-normalized aliases
seedance_1_0_lite: "Seedance 1.0 Lite",