website: media page update

This commit is contained in:
bflatastic committed 2026-01-13 13:27:30 +07:00
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";