mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-11 00:16:47 -05:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0171cefa08 |
@@ -1,16 +1,28 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faArrowDownToLine,
|
||||
faArrowRotateRight,
|
||||
faCheck,
|
||||
faCube,
|
||||
faImage,
|
||||
faLink,
|
||||
faSpinnerThird,
|
||||
faVideo,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { PLACEHOLDER_IMAGES } from "@storyteller/common";
|
||||
import { addCorsParam, PLACEHOLDER_IMAGES } from "@storyteller/common";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
import {
|
||||
getModelCreatorIconPath,
|
||||
getModelDisplayName,
|
||||
} from "../../lib/omni-gen-hooks";
|
||||
import {
|
||||
applyRecreateFromMediaToken,
|
||||
type RecreateMediaClass,
|
||||
} from "../../lib/recreate";
|
||||
import { SHARE_URL_BASE } from "../lightbox/shared";
|
||||
import type { GalleryItem } from "./useGalleryData";
|
||||
|
||||
// ── Persistent aspect ratio cache ─────────────────────────────────────────
|
||||
@@ -91,15 +103,29 @@ const RETRY_INTERVAL = 5000;
|
||||
interface GalleryCardProps {
|
||||
item: GalleryItem;
|
||||
onClick: (item: GalleryItem) => void;
|
||||
// "auto" = dynamic aspect ratio from the loaded image (masonry layouts).
|
||||
// "square" = fixed 1:1; skips the ratio measurement path (uniform grids).
|
||||
shape?: "auto" | "square";
|
||||
}
|
||||
|
||||
export const GalleryCard = memo(function GalleryCard({
|
||||
item,
|
||||
onClick,
|
||||
shape = "auto",
|
||||
}: GalleryCardProps) {
|
||||
const isSquare = shape === "square";
|
||||
const navigate = useNavigate();
|
||||
const cached = aspectRatioCache.get(item.id);
|
||||
const [ratio, setRatio] = useState<number | undefined>(cached);
|
||||
const [shareCopied, setShareCopied] = useState(false);
|
||||
const [isRecreating, setIsRecreating] = useState(false);
|
||||
const isVideo = item.mediaClass === "video";
|
||||
const is3D = item.mediaClass === "dimensional";
|
||||
const recreateMediaClass: RecreateMediaClass | null = isVideo
|
||||
? "video"
|
||||
: is3D
|
||||
? null
|
||||
: "image";
|
||||
|
||||
// Video thumbnail retry — "retrying" flips to true only after the first
|
||||
// error, so videos with ready thumbnails render the normal <img> path
|
||||
@@ -147,11 +173,11 @@ export const GalleryCard = memo(function GalleryCard({
|
||||
|
||||
const displayRatio = ratio ? Math.min(ratio, MAX_RATIO) : 1;
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
aspectRatio: `1 / ${displayRatio}`,
|
||||
contentVisibility: "auto",
|
||||
containIntrinsicSize: "auto 200px",
|
||||
};
|
||||
// In square mode the wrapper sets the ratio via `aspect-square`; we only
|
||||
// compute the dynamic aspectRatio for masonry-style layouts.
|
||||
const outerStyle: React.CSSProperties | undefined = isSquare
|
||||
? undefined
|
||||
: { aspectRatio: `1 / ${displayRatio}` };
|
||||
|
||||
const modelDisplayName = item.modelId
|
||||
? getModelDisplayName(item.modelId)
|
||||
@@ -208,67 +234,174 @@ export const GalleryCard = memo(function GalleryCard({
|
||||
[isVideo, scheduleRetry],
|
||||
);
|
||||
|
||||
const handleRecreate = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!recreateMediaClass || isRecreating) return;
|
||||
setIsRecreating(true);
|
||||
try {
|
||||
await applyRecreateFromMediaToken(item.id, recreateMediaClass, navigate);
|
||||
} finally {
|
||||
setIsRecreating(false);
|
||||
}
|
||||
},
|
||||
[item.id, recreateMediaClass, navigate, isRecreating],
|
||||
);
|
||||
|
||||
const handleShare = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(`${SHARE_URL_BASE}${item.id}`);
|
||||
toast.success("Share link copied");
|
||||
setShareCopied(true);
|
||||
setTimeout(() => setShareCopied(false), 1500);
|
||||
} catch {
|
||||
toast.error("Unable to copy link");
|
||||
}
|
||||
},
|
||||
[item.id],
|
||||
);
|
||||
|
||||
const handleCardClick = useCallback(() => {
|
||||
onClick(item);
|
||||
}, [item, onClick]);
|
||||
|
||||
const handleCardKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onClick(item);
|
||||
}
|
||||
},
|
||||
[item, onClick],
|
||||
);
|
||||
|
||||
const downloadHref = item.fullImage
|
||||
? addCorsParam(item.fullImage) || item.fullImage
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<button
|
||||
className="group relative block w-full overflow-hidden rounded-lg bg-ui-controls/40 leading-none transition-shadow hover:ring-2 hover:ring-primary-400/60 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
style={style}
|
||||
onClick={() => onClick(item)}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`group relative block w-full rounded-lg bg-ui-controls/40 leading-none transition-shadow hover:ring-2 hover:ring-primary-400/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 cursor-pointer ${isSquare ? "aspect-square" : ""}`}
|
||||
style={outerStyle}
|
||||
onClick={handleCardClick}
|
||||
onKeyDown={handleCardKeyDown}
|
||||
>
|
||||
{retrying ? (
|
||||
<>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2">
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="animate-spin text-lg text-white/30"
|
||||
{/* Media layer — kept in its own overflow-hidden box so the hover
|
||||
overlay below (including tooltips from the action pill) can render
|
||||
outside the card's rounded corners without being clipped. */}
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden rounded-[inherit]"
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 200px" }}
|
||||
>
|
||||
{retrying ? (
|
||||
<>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2">
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="animate-spin text-lg text-white/30"
|
||||
/>
|
||||
<span className="text-[10px] text-white/30">
|
||||
Loading thumbnail…
|
||||
</span>
|
||||
</div>
|
||||
{/* Hidden img retries in background via ref — zero re-renders */}
|
||||
<img
|
||||
ref={retryImgRef}
|
||||
src={item.thumbnail!}
|
||||
alt=""
|
||||
className="absolute h-0 w-0 opacity-0"
|
||||
aria-hidden
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
<span className="text-[10px] text-white/30">
|
||||
Loading thumbnail…
|
||||
</span>
|
||||
</div>
|
||||
{/* Hidden img retries in background via ref — zero re-renders */}
|
||||
</>
|
||||
) : item.thumbnail ? (
|
||||
<img
|
||||
ref={retryImgRef}
|
||||
src={item.thumbnail!}
|
||||
alt=""
|
||||
className="absolute h-0 w-0 opacity-0"
|
||||
aria-hidden
|
||||
src={item.thumbnail}
|
||||
alt={item.label}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="block h-full w-full object-cover"
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
</>
|
||||
) : item.thumbnail ? (
|
||||
<img
|
||||
src={item.thumbnail}
|
||||
alt={item.label}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="block h-full w-full object-cover"
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<FontAwesomeIcon icon={mediaIcon} className="text-xl text-white/20" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover overlay with media type + model badges */}
|
||||
<div className="absolute inset-x-0 bottom-0 flex items-center gap-1.5 bg-gradient-to-t from-black/60 to-transparent px-2 pb-2 pt-6 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="flex items-center gap-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] text-white/80">
|
||||
<FontAwesomeIcon icon={mediaIcon} className="text-[8px]" />
|
||||
{mediaLabel}
|
||||
</div>
|
||||
{modelDisplayName && modelIconPath && (
|
||||
<div className="flex items-center gap-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] text-white/80">
|
||||
<img
|
||||
src={modelIconPath}
|
||||
alt=""
|
||||
className="h-3 w-3 icon-auto-contrast"
|
||||
/>
|
||||
<span className="max-w-[100px] truncate">{modelDisplayName}</span>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<FontAwesomeIcon icon={mediaIcon} className="text-xl text-white/20" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Hover overlay with media type + model badges and quick actions */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 bg-gradient-to-t from-black/70 to-transparent px-2 pb-2 pt-6 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="pointer-events-auto flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<div className="flex items-center gap-1.5 rounded-lg bg-black/60 px-2.5 py-1 text-xs font-medium text-white/90">
|
||||
<FontAwesomeIcon icon={mediaIcon} className="text-[10px]" />
|
||||
{mediaLabel}
|
||||
</div>
|
||||
{modelDisplayName && modelIconPath && (
|
||||
<div className="flex items-center gap-1 rounded-lg bg-black/60 px-2 py-1 text-[10px] text-white/80">
|
||||
<img
|
||||
src={modelIconPath}
|
||||
alt=""
|
||||
className="h-3 w-3 icon-auto-contrast"
|
||||
/>
|
||||
<span className="max-w-[100px] truncate">{modelDisplayName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-auto flex shrink-0 items-center gap-0.5 rounded-lg bg-black/60 p-1 backdrop-blur-sm">
|
||||
{recreateMediaClass && (
|
||||
<Tooltip content="Recreate" position="top">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60"
|
||||
onClick={handleRecreate}
|
||||
disabled={isRecreating}
|
||||
aria-label="Recreate"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isRecreating ? faSpinnerThird : faArrowRotateRight}
|
||||
className={`text-sm ${isRecreating ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content={shareCopied ? "Copied" : "Share"} position="top">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white"
|
||||
onClick={handleShare}
|
||||
aria-label="Share"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={shareCopied ? faCheck : faLink}
|
||||
className="text-sm"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{downloadHref && (
|
||||
<Tooltip content="Download" position="top">
|
||||
<a
|
||||
href={downloadHref}
|
||||
download={`artcraft-${item.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white"
|
||||
aria-label="Download"
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowDownToLine} className="text-sm" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
+6
-6
@@ -17,9 +17,9 @@ const BREAKPOINT_COLS = {
|
||||
};
|
||||
|
||||
// 12px gap on both axes (≈ Tailwind gap-3).
|
||||
// ml-[-12px] on container offsets the first column's pl-[12px].
|
||||
// ml-[-12px] on container offsets the first column's pl-[8px].
|
||||
const MASONRY_CLASS = "flex w-auto ml-[-12px]";
|
||||
const COLUMN_CLASS = "pl-[12px]";
|
||||
const COLUMN_CLASS = "pl-[8px]";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -82,7 +82,7 @@ export function GenerationGalleryGrid({
|
||||
columnClassName={COLUMN_CLASS}
|
||||
>
|
||||
{inProgressJobs.map((job) => (
|
||||
<div key={job.id} className="mb-[12px]">
|
||||
<div key={job.id} className="mb-[8px]">
|
||||
<PendingCard
|
||||
id={job.id}
|
||||
prompt={job.prompt}
|
||||
@@ -94,7 +94,7 @@ export function GenerationGalleryGrid({
|
||||
</div>
|
||||
))}
|
||||
{failedJobs.map((job) => (
|
||||
<div key={job.id} className="mb-[12px]">
|
||||
<div key={job.id} className="mb-[8px]">
|
||||
<FailedCard
|
||||
id={job.id}
|
||||
prompt={job.prompt}
|
||||
@@ -106,12 +106,12 @@ export function GenerationGalleryGrid({
|
||||
</div>
|
||||
))}
|
||||
{newlyCompletedItems.map((item) => (
|
||||
<div key={`new-${item.id}`} className="mb-[12px]">
|
||||
<div key={`new-${item.id}`} className="mb-[8px]">
|
||||
<GalleryCard item={item} onClick={onGalleryItemClick} />
|
||||
</div>
|
||||
))}
|
||||
{filteredGalleryItems.map((item) => (
|
||||
<div key={item.id} className="mb-[12px]">
|
||||
<div key={item.id} className="mb-[8px]">
|
||||
<GalleryCard item={item} onClick={onGalleryItemClick} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -16,24 +16,10 @@ import {
|
||||
faImage,
|
||||
faVideo,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
getMediaThumbnail,
|
||||
THUMBNAIL_SIZES,
|
||||
PLACEHOLDER_IMAGES,
|
||||
} from "@storyteller/common";
|
||||
import { getMediaThumbnail, THUMBNAIL_SIZES } from "@storyteller/common";
|
||||
import { Lightbox } from "../../components/lightbox/lightbox";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface GalleryItem {
|
||||
id: string;
|
||||
label: string;
|
||||
thumbnail: string | null;
|
||||
fullImage: string | null;
|
||||
createdAt: string;
|
||||
mediaClass: string;
|
||||
batchImageToken?: string;
|
||||
}
|
||||
import { GalleryCard } from "../../components/generation-gallery/GalleryCard";
|
||||
import type { GalleryItem } from "../../components/generation-gallery/useGalleryData";
|
||||
|
||||
const PAGE_SIZE = 60;
|
||||
|
||||
@@ -149,6 +135,7 @@ export default function Library() {
|
||||
fullImage: item.media_links?.cdn_url || null,
|
||||
createdAt: item.created_at,
|
||||
mediaClass: item.media_class || "image",
|
||||
modelId: item.maybe_model_type || undefined,
|
||||
batchImageToken: item.maybe_batch_token,
|
||||
};
|
||||
}, []);
|
||||
@@ -273,10 +260,15 @@ export default function Library() {
|
||||
setAllItems((prev) => prev.filter((item) => item.id !== id));
|
||||
}, []);
|
||||
|
||||
const handleCardClick = useCallback((item: GalleryItem) => {
|
||||
setLightboxItem(item);
|
||||
setLightboxOpen(true);
|
||||
}, []);
|
||||
|
||||
// Not logged in
|
||||
if (isLoggedIn === false) {
|
||||
return (
|
||||
<div className="relative min-h-screen w-full bg-dots flex items-center justify-center px-4">
|
||||
<div className="relative min-h-screen w-full bg-[#101014] flex items-center justify-center px-4">
|
||||
<div className="text-center space-y-6">
|
||||
<h1 className="text-3xl font-bold text-white">My Library</h1>
|
||||
<p className="text-white/60 text-lg max-w-md mx-auto">
|
||||
@@ -308,14 +300,14 @@ export default function Library() {
|
||||
// Loading auth
|
||||
if (isLoggedIn === null) {
|
||||
return (
|
||||
<div className="relative min-h-screen w-full bg-dots flex items-center justify-center">
|
||||
<div className="relative min-h-screen w-full bg-[#101014] flex items-center justify-center">
|
||||
<LoadingSpinner className="h-10 w-10 text-white/60" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen w-full bg-dots pt-14 sm:pt-20 pb-8 px-3 sm:px-4 md:px-8 lg:px-12">
|
||||
<div className="relative min-h-screen w-full bg-[#101014] pt-14 sm:pt-20 pb-8 px-3 sm:px-4 md:px-8 lg:px-12">
|
||||
<div className="mx-auto max-w-[1600px]">
|
||||
{/* Header — sticky below navbar */}
|
||||
<div className="sticky top-12 sm:top-16 z-10 -mx-3 sm:-mx-4 md:-mx-8 lg:-mx-12 px-3 sm:px-4 md:px-8 lg:px-12 pb-3 pt-3 bg-[#101014]">
|
||||
@@ -370,8 +362,8 @@ export default function Library() {
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="h-4 w-24 rounded bg-white/[0.06] mb-3" />
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2 sm:gap-3">
|
||||
{Array.from({ length: 18 }).map((_, i) => (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2 sm:gap-3">
|
||||
{Array.from({ length: 15 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="aspect-square rounded-lg overflow-hidden"
|
||||
@@ -419,65 +411,14 @@ export default function Library() {
|
||||
<h3 className="text-sm font-medium text-white/50 mb-2">
|
||||
{date}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2 sm:gap-3">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2 sm:gap-3">
|
||||
{dateItems.map((item) => (
|
||||
<button
|
||||
<GalleryCard
|
||||
key={item.id}
|
||||
className="group relative aspect-square overflow-hidden rounded-lg bg-ui-controls/40 transition-all hover:ring-2 hover:ring-primary-400/60 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
onClick={() => {
|
||||
setLightboxItem(item);
|
||||
setLightboxOpen(true);
|
||||
}}
|
||||
>
|
||||
{item.thumbnail ? (
|
||||
<img
|
||||
src={item.thumbnail}
|
||||
alt={item.label}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget;
|
||||
if (target.dataset.fallback) return;
|
||||
target.dataset.fallback = "1";
|
||||
target.src = PLACEHOLDER_IMAGES.DEFAULT;
|
||||
target.style.opacity = "0.3";
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
item.mediaClass === "video"
|
||||
? faVideo
|
||||
: item.mediaClass === "dimensional"
|
||||
? faCube
|
||||
: faImage
|
||||
}
|
||||
className="text-xl text-white/20"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Video badge */}
|
||||
{item.mediaClass === "video" && (
|
||||
<div className="absolute bottom-1.5 left-1.5 flex items-center gap-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] text-white/80">
|
||||
<FontAwesomeIcon
|
||||
icon={faVideo}
|
||||
className="text-[8px]"
|
||||
/>
|
||||
Video
|
||||
</div>
|
||||
)}
|
||||
{/* Mesh badge */}
|
||||
{item.mediaClass === "dimensional" && (
|
||||
<div className="absolute bottom-1.5 left-1.5 flex items-center gap-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] text-white/80">
|
||||
<FontAwesomeIcon
|
||||
icon={faCube}
|
||||
className="text-[8px]"
|
||||
/>
|
||||
3D
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
item={item}
|
||||
onClick={handleCardClick}
|
||||
shape="square"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user