mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-04 05:20:58 -05:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
994596682e |
@@ -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 & 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",
|
||||
|
||||
|
||||
Reference in New Issue
Block a user