mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-03 17:08:13 +00:00
website: media page update
This commit is contained in:
1 parent
bbb53e14b0
commit
e653e31078
1 file changed
+182
-156
@@ -12,6 +12,9 @@ import {
|
||||
faLink,
|
||||
faCheck,
|
||||
faArrowDownToLine,
|
||||
faPencil,
|
||||
faCircleInfo,
|
||||
faImage,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
addCorsParam,
|
||||
@@ -47,6 +50,8 @@ interface MediaData {
|
||||
createdAt: string | null;
|
||||
isVideo: boolean;
|
||||
isLoaded: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface PromptData {
|
||||
@@ -106,6 +111,15 @@ const useCopyFeedback = () => {
|
||||
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();
|
||||
@@ -121,13 +135,11 @@ export default function MediaPage() {
|
||||
const [mediaRecordLoading, setMediaRecordLoading] = useState(true);
|
||||
|
||||
const [promptData, setPromptData] = useState<PromptData>(EMPTY_PROMPT_DATA);
|
||||
const [isPromptHovered, setIsPromptHovered] = useState(false);
|
||||
|
||||
const shareCopy = useCopyFeedback();
|
||||
const promptCopy = useCopyFeedback();
|
||||
|
||||
const loadMedia = useCallback(async (id: string) => {
|
||||
setMedia(prev => ({ ...prev, isLoaded: false }));
|
||||
setMediaRecordLoading(true);
|
||||
|
||||
const mediaFilesApi = new MediaFilesApi();
|
||||
@@ -140,12 +152,17 @@ export default function MediaPage() {
|
||||
const file = mediaResponse.data;
|
||||
const url = file.media_links?.cdn_url || null;
|
||||
|
||||
setMedia({
|
||||
url,
|
||||
token: file.token || id,
|
||||
createdAt: file.created_at || null,
|
||||
isVideo: url ? isVideoUrl(url) : false,
|
||||
isLoaded: false,
|
||||
setMedia((prev) => {
|
||||
const isSameUrl = prev.url === url;
|
||||
return {
|
||||
url,
|
||||
token: file.token || id,
|
||||
createdAt: file.created_at || null,
|
||||
isVideo: url ? isVideoUrl(url) : false,
|
||||
isLoaded: isSameUrl ? prev.isLoaded : false,
|
||||
width: isSameUrl ? prev.width : undefined,
|
||||
height: isSameUrl ? prev.height : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
if (file.maybe_prompt_token) {
|
||||
@@ -183,30 +200,7 @@ export default function MediaPage() {
|
||||
}
|
||||
}, [mediaIdParam, loadMedia]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!media.url) return;
|
||||
|
||||
if (media.isVideo) {
|
||||
setMedia(prev => ({ ...prev, isLoaded: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
img.src = addCorsParam(media.url) || media.url;
|
||||
const handleLoad = () => setMedia(prev => ({ ...prev, isLoaded: true }));
|
||||
|
||||
if (img.complete) {
|
||||
handleLoad();
|
||||
} else {
|
||||
img.addEventListener("load", handleLoad);
|
||||
img.addEventListener("error", handleLoad);
|
||||
}
|
||||
|
||||
return () => {
|
||||
img.removeEventListener("load", handleLoad);
|
||||
img.removeEventListener("error", handleLoad);
|
||||
};
|
||||
}, [media.url, media.isVideo]);
|
||||
|
||||
|
||||
const handleCopyPrompt = useCallback(async () => {
|
||||
if (!promptData.text) return;
|
||||
@@ -231,49 +225,71 @@ export default function MediaPage() {
|
||||
}, [media.token, shareCopy]);
|
||||
|
||||
const shareButtonIcon = shareCopy.copied ? faCheck : faLink;
|
||||
const shareButtonText = shareCopy.copied ? "Share link copied" : "Copy Share Link";
|
||||
|
||||
const getResolutionLabel = (width?: number) => {
|
||||
if (!width) return null;
|
||||
if (width >= 1000) return `${Math.round(width / 1024)}K`;
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen w-full px-4 sm:px-6 pt-24 pb-8 bg-dots">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="grid h-full grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||
<div className="lg:col-span-2 relative flex min-h-[360px] sm:min-h-[420px] items-center justify-center overflow-hidden rounded-xl bg-black/30">
|
||||
<div className="relative min-h-screen w-full p-4 pt-16 bg-dots flex items-start lg:items-center justify-center">
|
||||
<div className="mx-auto max-w-[1920px] w-full h-auto lg:h-[calc(100vh-100px)] min-h-[500px]">
|
||||
<div className="flex flex-col lg:flex-row h-full w-full overflow-hidden rounded-xl border border-white/[2%]">
|
||||
{/* Media Preview Area */}
|
||||
<div className="relative flex-1 bg-black/20 backdrop-blur-lg flex items-center justify-center overflow-hidden min-h-[30vh] lg:min-h-0">
|
||||
{mediaRecordLoading ? (
|
||||
<div className="absolute inset-0 animate-pulse">
|
||||
<div className="h-full w-full bg-white/5" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<LoadingSpinner className="h-12 w-12 text-white/60" />
|
||||
</div>
|
||||
) : media.url ? (
|
||||
<div className="relative flex h-full w-full items-center justify-center">
|
||||
<div className="relative h-full w-full flex items-center justify-center">
|
||||
{media.isVideo ? (
|
||||
<video
|
||||
src={addCorsParam(media.url) || media.url}
|
||||
className="max-h-[75vh] w-full object-contain"
|
||||
className="h-full w-full object-contain"
|
||||
controls
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
onError={() => console.error('Video failed to load')}
|
||||
onLoadedData={() => setMedia(prev => ({ ...prev, isLoaded: true }))}
|
||||
onLoadedData={(e) => {
|
||||
const el = e.currentTarget;
|
||||
setMedia(prev => ({
|
||||
...prev,
|
||||
isLoaded: true,
|
||||
width: el.videoWidth,
|
||||
height: el.videoHeight
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<img
|
||||
src={addCorsParam(media.url) || media.url}
|
||||
alt="Generated image"
|
||||
className="max-h-[75vh] w-full object-contain"
|
||||
className="h-full w-full object-contain transition-opacity duration-300"
|
||||
style={{ opacity: media.isLoaded ? 1 : 0 }}
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).src = PLACEHOLDER_IMAGES.DEFAULT;
|
||||
(e.currentTarget as HTMLImageElement).style.opacity = "0.3";
|
||||
// Set the `data-brokenurl` property for debugging the broken images:
|
||||
(e.currentTarget as HTMLImageElement).dataset.brokenurl = media.url || "";
|
||||
setMedia(prev => ({ ...prev, isLoaded: true }));
|
||||
}}
|
||||
onLoad={() => setMedia(prev => ({ ...prev, isLoaded: true }))}
|
||||
onLoad={(e) => {
|
||||
const el = e.currentTarget;
|
||||
setMedia(prev => ({
|
||||
...prev,
|
||||
isLoaded: true,
|
||||
width: el.naturalWidth,
|
||||
height: el.naturalHeight
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
{!media.isLoaded && (
|
||||
<div className="absolute inset-0 bg-ui-panel/80 flex items-center justify-center">
|
||||
<LoadingSpinner className="h-12 w-12 text-white" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<LoadingSpinner className="h-12 w-12 text-white/60" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -286,43 +302,36 @@ export default function MediaPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex h-full flex-col lg:col-span-1 mt-6 lg:mt-0">
|
||||
<div className="flex-1 space-y-5 text-white">
|
||||
{/* 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-4 animate-pulse">
|
||||
<div className="space-y-1.5">
|
||||
<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-4 w-40 bg-white/10 rounded" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<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-1.5">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-32 bg-white/10 rounded" />
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="aspect-square w-14 bg-white/10 rounded-lg"
|
||||
/>
|
||||
<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 className="space-y-1.5">
|
||||
<div className="h-4 w-28 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>
|
||||
) : (
|
||||
<>
|
||||
{media.createdAt && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-1.5 hidden">
|
||||
<div className="text-sm font-medium text-white/90">
|
||||
Created
|
||||
</div>
|
||||
<div className="text-sm text-white/70">
|
||||
<div className="text-sm text-white/60">
|
||||
{dayjs(media.createdAt).format("MMM D, YYYY")} at{" "}
|
||||
{dayjs(media.createdAt).format("hh:mm A")}
|
||||
</div>
|
||||
@@ -331,57 +340,46 @@ export default function MediaPage() {
|
||||
|
||||
{promptData.hasToken && (
|
||||
<>
|
||||
<div className="relative space-y-1.5">
|
||||
<div className="text-sm font-medium text-white/90">
|
||||
Prompt
|
||||
<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 break-words p-3 rounded-lg cursor-pointer transition-colors duration-100 leading-relaxed bg-ui-controls/20 backdrop-blur-md"
|
||||
onMouseEnter={() => setIsPromptHovered(true)}
|
||||
onMouseLeave={() => setIsPromptHovered(false)}
|
||||
onClick={handleCopyPrompt}
|
||||
>
|
||||
|
||||
<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/80">
|
||||
<span className="text-sm text-white/60">
|
||||
Loading prompt...
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
promptData.text || (
|
||||
<span className="text-sm text-white">
|
||||
<span className="text-sm text-white/60">
|
||||
No prompt
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{!promptData.loading && (
|
||||
<div
|
||||
className={twMerge(
|
||||
"pointer-events-none absolute inset-0 flex items-end justify-end opacity-0 transition-opacity duration-50",
|
||||
(isPromptHovered || promptCopy.copied) && "opacity-100"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1 text-xs text-white backdrop-blur-md p-1.5 rounded-tl-lg rounded-br-lg bg-ui-controls/40">
|
||||
<FontAwesomeIcon
|
||||
icon={promptCopy.copied ? faCheck : faCopy}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
<span>
|
||||
{promptCopy.copied ? "Prompt copied" : "Copy prompt"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{promptData.contextImages && promptData.contextImages.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium text-white/90">
|
||||
Reference Images
|
||||
<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-6 gap-2">
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{promptData.contextImages.map((contextImage, index) => {
|
||||
const { thumbnail } = getContextImageThumbnail(
|
||||
contextImage,
|
||||
@@ -390,7 +388,7 @@ export default function MediaPage() {
|
||||
return (
|
||||
<div
|
||||
key={contextImage.media_token}
|
||||
className="glass relative aspect-square overflow-hidden rounded-lg w-14 border-2 border-white/30"
|
||||
className="glass relative aspect-square overflow-hidden rounded-lg border border-white/10 hover:border-white/40 transition-colors"
|
||||
>
|
||||
<img
|
||||
src={thumbnail}
|
||||
@@ -404,37 +402,63 @@ export default function MediaPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(promptData.provider || promptData.modelType) && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium text-white/90">
|
||||
Generation Details
|
||||
{(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 gap-1.5">
|
||||
|
||||
<div className="flex flex-col rounded-xl bg-black/20 border border-white/5 overflow-hidden">
|
||||
<InfoRow
|
||||
label="Feature"
|
||||
value={media.isVideo ? 'Create Video' : 'Create Image'}
|
||||
/>
|
||||
|
||||
{promptData.modelType && (
|
||||
<div className="flex items-center justify-between py-2 px-3 rounded-lg border border-ui-panel-border/40 bg-ui-controls/20 backdrop-blur-md">
|
||||
<span className="text-sm text-white/70 font-medium">
|
||||
Model
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{getModelCreatorIcon(promptData.modelType)}
|
||||
<span className="text-sm text-white rounded">
|
||||
{getModelDisplayName(promptData.modelType)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<InfoRow
|
||||
label="Model"
|
||||
value={
|
||||
<>
|
||||
{getModelCreatorIcon(promptData.modelType)}
|
||||
<span>{getModelDisplayName(promptData.modelType)}</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{promptData.provider && (
|
||||
<div className="flex items-center justify-between py-2 px-3 rounded-lg border border-ui-panel-border/40 bg-ui-controls/20 backdrop-blur-md">
|
||||
<span className="text-sm text-white/70 font-medium">
|
||||
Provider
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{getProviderIconByName(promptData.provider, "h-4 w-4 invert")}
|
||||
<span className="text-sm text-white rounded">
|
||||
{getProviderDisplayName(promptData.provider)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<InfoRow
|
||||
label="Provider"
|
||||
value={
|
||||
<>
|
||||
{getProviderIconByName(promptData.provider, "h-4 w-4 invert")}
|
||||
<span>{getProviderDisplayName(promptData.provider)}</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{media.width && media.height && (
|
||||
<>
|
||||
{getResolutionLabel(media.width) && (
|
||||
<InfoRow
|
||||
label="Resolution"
|
||||
value={getResolutionLabel(media.width)}
|
||||
/>
|
||||
)}
|
||||
<InfoRow
|
||||
label="Size"
|
||||
value={`${media.width} × ${media.height}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{media.createdAt && (
|
||||
<InfoRow
|
||||
label="Created"
|
||||
value={dayjs(media.createdAt).format("MMMM D, YYYY")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -445,36 +469,38 @@ export default function MediaPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-2.5">
|
||||
<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
|
||||
className="w-full border-0 shadow-none"
|
||||
icon={shareButtonIcon}
|
||||
variant="secondary"
|
||||
onClick={handleCopyShareLink}
|
||||
>
|
||||
{shareButtonText}
|
||||
</Button>
|
||||
<a
|
||||
className={twMerge(
|
||||
"w-full border-0 shadow-none inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||
media.url
|
||||
? "bg-ui-controls hover:bg-ui-controls/80 text-base-fg"
|
||||
: "bg-ui-controls text-base-fg 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 {media.isVideo ? 'Video' : 'Image'}
|
||||
</a>
|
||||
<Button
|
||||
icon={faArrowDownToLine}
|
||||
className="w-full col-span-2 border-0 shadow-none"
|
||||
icon={faArrowDownToLine}
|
||||
className="w-full shadow-lg shadow-brand-primary/20"
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
window.location.href = "/download";
|
||||
|
||||
Reference in new issue
Block a user