diff --git a/README.md b/README.md
index 6fbdff7..8c6b087 100644
--- a/README.md
+++ b/README.md
@@ -23,8 +23,8 @@
## Features
-- ๐จ **13+ AI Models** โ Choose from Nano Banana Pro, GPT 1.5, Seedream, Veo 3.1, Seedance and more
-- โก **Generous Limits** โ 5000~ Z-image turbo generations per day
+- ๐จ **6 AI Models** โ Choose from Flux Schnell, Z-Image Turbo, GPT Image, FLUX.2 Klein, and Seedance
+- โก **Generous Limits** โ Up to 900,000 Flux Schnell generations per month (180 high-quality images)
- ๐ **Full Dimension Control** โ Create logos, banners, or any custom image size
- ๐พ **Cloud History** โ Access your generated images from any device
- ๐ **Secure Auth** โ Powered by Clerk with SSO support
diff --git a/app/layout.tsx b/app/layout.tsx
index ba427ad..251b590 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -39,7 +39,7 @@ export const metadata: Metadata = {
template: "%s | Bloom Studio",
},
description:
- "Bloom Studio, a cheap and powerful AI image generator and video generator studio. Featuring Nano Banana Pro, Veo, GPT 1.5, Seedream 4.5 and more. Try for free.",
+ "Bloom Studio, a cheap and powerful AI image generator and video generator studio. Featuring Flux Schnell, Z-Image Turbo, GPT Image, FLUX.2 Klein, and Seedance. Try for free.",
metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || "https://bloomstudio.fun"),
applicationName: "Bloom Studio",
authors: [{ name: "Bloom Studio Team" }],
@@ -85,7 +85,7 @@ export const metadata: Metadata = {
url: "/",
title: "Bloom Studio - Cheap and Powerful AI Image & Video Generator",
description:
- "Bloom Studio, a cheap and powerful AI image generator and video generator studio. Featuring Nano Banana Pro, Veo, GPT 1.5, Seedream 4.5 and more. Try for free.",
+ "Bloom Studio, a cheap and powerful AI image generator and video generator studio. Featuring Flux Schnell, Z-Image Turbo, GPT Image, FLUX.2 Klein, and Seedance. Try for free.",
siteName: "Bloom Studio",
images: [
{
@@ -100,7 +100,7 @@ export const metadata: Metadata = {
card: "summary_large_image",
title: "Bloom Studio - Cheap and Powerful AI Image & Video Generator",
description:
- "Bloom Studio, a cheap and powerful AI image generator and video generator studio. Featuring Nano Banana Pro, Veo, GPT 1.5, Seedream 4.5 and more. Try for free.",
+ "Bloom Studio, a cheap and powerful AI image generator and video generator studio. Featuring Flux Schnell, Z-Image Turbo, GPT Image, FLUX.2 Klein, and Seedance. Try for free.",
images: ["/branding/bloom-studio_logo.png"],
creator: "@bloomstudio",
},
diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx
index 44b57a1..68431b1 100644
--- a/app/pricing/page.tsx
+++ b/app/pricing/page.tsx
@@ -47,7 +47,7 @@ export default function PricingPage() {
"@context": "https://schema.org",
"@type": "Product",
name: "Bloom Studio Pro",
- description: "Professional AI image and video generation subscription. Includes 180 images/month, all models (Flux, Veo, etc), and private generations.",
+ description: "Professional AI image and video generation subscription. Includes 180 images/month, all active models (Flux, GPT Image, Z-Image, Klein, Seedance), and private generations.",
image: "https://bloomstudio.fun/branding/bloom-studio_logo.png",
offers: {
"@type": "Offer",
@@ -121,26 +121,7 @@ export default function PricingPage() {
-
- {tier.description.includes("Nano Banana Pro") ? (
-
- {tier.description.split("Nano Banana Pro")[0]}
-
-
- Nano Banana Pro
-
- {tier.description.split("Nano Banana Pro")[1]}
-
- ) : (
- tier.description
- )}
-
+
{tier.description}
{tier.poweredBy && tier.name !== "Pro" && (
- ) : feature.includes("Nano Banana Pro") ? (
-
- {feature.split("Nano Banana Pro")[0]}
-
-
- Nano Banana Pro
-
- {feature.split("Nano Banana Pro")[1]}
-
) : (
feature
)}
diff --git a/app/pricing/pricing-data.tsx b/app/pricing/pricing-data.tsx
index 7f995ba..1d5ea27 100644
--- a/app/pricing/pricing-data.tsx
+++ b/app/pricing/pricing-data.tsx
@@ -36,8 +36,8 @@ export const pricingTiers: PricingTier[] = [
name: "Pro",
description: "Access to every feature and model",
poweredBy: {
- name: "Nano Banana Pro",
- logo: "/image-models/google.svg",
+ name: "Flux Schnell",
+ logo: "/image-models/flux.svg",
},
price: 3,
icon: Crown,
@@ -46,9 +46,9 @@ export const pricingTiers: PricingTier[] = [
cta: "Upgrade to Pro",
ctaVariant: "default",
features: [
- "180 Nano Banana Pro images/month",
+ "180 high-quality images/month",
"Daily resets",
- "13+ AI models included",
+ "6 AI models included (Flux, GPT Image, Z-Image, Klein, Seedance)",
"Advanced prompt enhancement",
"Custom prompt library",
"1,000 image batch queue",
@@ -79,7 +79,7 @@ export const pricingFaqs = [
{
question: "How does the free plan work?",
answer:
- "Sign up and get 180 Nano Banana Pro images every month for free. Your quota resets daily (approx. 6 images/day). No credit card required.",
+ "Sign up and get 180 high-quality images every month for free. Your quota resets daily (approx. 6 images/day). No credit card required.",
},
{
question: "What happens if I need more images?",
@@ -90,7 +90,7 @@ export const pricingFaqs = [
question: "What AI models are included?",
answer: (
- We offer 13+ cutting-edge and popular models including Flux, GPT-4 Image, Seedream, and more. Both Free and Pro users get access to all models.{" "}
+ We offer 6 high-quality AI models including Flux Schnell, GPT Image, Z-Image Turbo, FLUX.2 Klein, and Seedance. Both Free and Pro users get access to all active models.{" "}
View the full list of supported models here.
@@ -128,7 +128,7 @@ export const featureComparison = [
},
},
{ feature: "Batch queue size", starter: "1,000", pro: "1,000", competitors: "10-20" },
- { feature: "AI Models", starter: "13+", pro: "13+", competitors: "13+" },
+ { feature: "AI Models", starter: "6", pro: "6", competitors: "Varies" },
{ feature: "Prompt library", starter: true, pro: true, competitors: false },
{ feature: "Prompt enhancement", starter: true, pro: true, competitors: "Basic" },
{ feature: "Private gallery", starter: true, pro: true, competitors: "Varies" },
diff --git a/biome.json b/biome.json
new file mode 100644
index 0000000..c8cf796
--- /dev/null
+++ b/biome.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
+ "files": {
+ "ignore": ["convex/_generated/**", ".next/**", "node_modules/**"]
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "recommended": true
+ }
+ },
+ "formatter": {
+ "enabled": true
+ }
+}
diff --git a/components/gallery/history-filters.test.tsx b/components/gallery/history-filters.test.tsx
index 017e809..97207e1 100644
--- a/components/gallery/history-filters.test.tsx
+++ b/components/gallery/history-filters.test.tsx
@@ -47,7 +47,7 @@ if (!global.PointerEvent) {
this.isPrimary = params.isPrimary || false
}
}
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
+
global.PointerEvent = PointerEvent as any
}
diff --git a/components/gallery/history-filters.tsx b/components/gallery/history-filters.tsx
index debaadb..0a8a257 100644
--- a/components/gallery/history-filters.tsx
+++ b/components/gallery/history-filters.tsx
@@ -44,39 +44,25 @@ interface HistoryFiltersProps {
const IMAGE_MODELS: ModelDefinition[] = Object.values(MODEL_REGISTRY).filter(
(model): model is ModelDefinition => model.type === "image",
);
-
// Get all video models from the registry
const VIDEO_MODELS: ModelDefinition[] = Object.values(MODEL_REGISTRY).filter(
(model): model is ModelDefinition => model.type === "video",
);
-/**
- * Legacy models that are no longer available for generation but may exist in user history.
- * These are shown separately in the filter dropdown so users can still filter by them.
- */
-const LEGACY_FILTER_MODELS: { id: string; displayName: string }[] = [
- // Currently empty - 'flux' was moved back to MODEL_REGISTRY as an active model
-];
-
-/** Combined list of all filterable models (image + video + legacy), sorted alphabetically */
+/** Combined list of all filterable models (image + video), sorted alphabetically */
const ALL_FILTERABLE_MODELS = [
...IMAGE_MODELS.map((m) => ({
id: m.id,
displayName: m.displayName,
- isLegacy: false,
+ isLegacy: m.isLegacy ?? false,
type: "image" as const,
})),
...VIDEO_MODELS.map((m) => ({
id: m.id,
displayName: m.displayName,
- isLegacy: false,
+ isLegacy: m.isLegacy ?? false,
type: "video" as const,
})),
- ...LEGACY_FILTER_MODELS.map((m) => ({
- ...m,
- isLegacy: true,
- type: "image" as const,
- })),
].sort((a, b) =>
a.displayName.localeCompare(b.displayName, undefined, { numeric: true }),
);
@@ -294,7 +280,7 @@ export function HistoryFiltersDropdown({
handleModelToggle(model.id)}
- className="gap-2"
+ className={cn("gap-2", model.isLegacy && "opacity-60")}
>
({
// Mock Next.js Image
vi.mock("next/image", () => ({
default: ({ src, alt, ...props }: React.ImgHTMLAttributes & { fill?: boolean }) => {
- // eslint-disable-next-line @next/next/no-img-element
+
return
},
diff --git a/components/images/image-lightbox.test.tsx b/components/images/image-lightbox.test.tsx
index f595881..d1a9f9f 100644
--- a/components/images/image-lightbox.test.tsx
+++ b/components/images/image-lightbox.test.tsx
@@ -7,23 +7,66 @@ import { ImageLightbox } from "./image-lightbox";
// Mock Clerk auth
vi.mock("@clerk/nextjs", () => ({
- useAuth: () => ({ isSignedIn: true }),
+ useAuth: () => ({ isSignedIn: true }),
+}));
+
+vi.mock("@/hooks/use-image-edit", () => ({
+ useImageEdit: () => ({
+ isEditPanelOpen: false,
+ openEditPanel: vi.fn(),
+ closeEditPanel: vi.fn(),
+ editPrompt: "",
+ setEditPrompt: vi.fn(),
+ selectedModel: "kontext",
+ setSelectedModel: vi.fn(),
+ isGenerating: false,
+ error: null,
+ submitEdit: vi.fn(),
+ cancelGeneration: vi.fn(),
+ reset: vi.fn(),
+ editModels: [],
+ canSubmit: false,
+ selectedAspectRatio: "1:1",
+ setSelectedAspectRatio: vi.fn(),
+ selectedResolutionTier: "hd",
+ setSelectedResolutionTier: vi.fn(),
+ outputWidth: 1024,
+ outputHeight: 1024,
+ sourceFormatInfo: null,
+ availableAspectRatios: [],
+ initializeFromSource: vi.fn(),
+ }),
}));
// Mock react-zoom-pan-pinch
interface TransformWrapperChildrenArgs {
- zoomIn: () => void;
- zoomOut: () => void;
- resetTransform: () => void;
- state: { scale: number };
+ zoomIn: () => void;
+ zoomOut: () => void;
+ resetTransform: () => void;
+ state: { scale: number };
}
vi.mock("react-zoom-pan-pinch", () => ({
- TransformWrapper: ({ children }: { children: (args: TransformWrapperChildrenArgs) => ReactNode }) => {
- // Simulate zoom callback availability if needed
- return {children({ zoomIn: vi.fn(), zoomOut: vi.fn(), resetTransform: vi.fn(), state: { scale: 1 } })}
;
- },
- TransformComponent: ({ children }: { children?: ReactNode }) => {children}
,
+ TransformWrapper: ({
+ children,
+ }: {
+ children: (args: TransformWrapperChildrenArgs) => ReactNode;
+ }) => {
+ // Simulate zoom callback availability if needed
+ return (
+
+ {children({
+ zoomIn: vi.fn(),
+ zoomOut: vi.fn(),
+ resetTransform: vi.fn(),
+ state: { scale: 1 },
+ })}
+
+ );
+ },
+ TransformComponent: ({ children }: { children?: ReactNode }) => (
+ {children}
+ ),
}));
// Mock React hooks that the component uses
@@ -33,441 +76,744 @@ const mockHandleInsert = vi.fn();
let capturedOnInsertComplete: (() => void) | undefined;
interface UsePromptLibraryMockProps {
- onInsertComplete?: () => void;
- onInsert: (content: string) => void;
- onClose: () => void;
+ onInsertComplete?: () => void;
+ onInsert: (content: string) => void;
+ onClose: () => void;
}
interface PromptMock {
- _id: string;
- title: string;
- content: string;
- type: string;
- tags: string[];
+ _id: string;
+ title: string;
+ content: string;
+ type: string;
+ tags: string[];
}
vi.mock("@/hooks/use-prompt-library", () => ({
- usePromptLibrary: (props: UsePromptLibraryMockProps) => {
- capturedOnInsertComplete = props.onInsertComplete;
- return {
- searchQuery: "",
- setSearchQuery: vi.fn(),
- searchInputRef: { current: null },
- viewState: "list",
- setViewState: vi.fn(),
- selectedPrompt: null,
- selectPrompt: vi.fn(),
- typeFilter: "all",
- setTypeFilter: vi.fn(),
- prompts: [{ _id: "1", title: "Test Prompt", content: "Test Content", type: "positive", tags: [] } satisfies PromptMock],
- isLoading: false,
- handleCopy: vi.fn(),
- handleInsert: (content: string) => {
- mockHandleInsert(content);
- props.onInsert(content);
- props.onClose();
- props.onInsertComplete?.();
- },
- handleRemove: vi.fn(),
- showSaveForm: vi.fn(),
- goBackToList: vi.fn(),
- };
- },
+ usePromptLibrary: (props: UsePromptLibraryMockProps) => {
+ capturedOnInsertComplete = props.onInsertComplete;
+ return {
+ searchQuery: "",
+ setSearchQuery: vi.fn(),
+ searchInputRef: { current: null },
+ viewState: "list",
+ setViewState: vi.fn(),
+ selectedPrompt: null,
+ selectPrompt: vi.fn(),
+ typeFilter: "all",
+ setTypeFilter: vi.fn(),
+ prompts: [
+ {
+ _id: "1",
+ title: "Test Prompt",
+ content: "Test Content",
+ type: "positive",
+ tags: [],
+ } satisfies PromptMock,
+ ],
+ isLoading: false,
+ handleCopy: vi.fn(),
+ handleInsert: (content: string) => {
+ mockHandleInsert(content);
+ props.onInsert(content);
+ props.onClose();
+ props.onInsertComplete?.();
+ },
+ handleRemove: vi.fn(),
+ showSaveForm: vi.fn(),
+ goBackToList: vi.fn(),
+ };
+ },
}));
// Mock the useImageLightbox hook
vi.mock("@/hooks/use-image-lightbox", () => ({
- useImageLightbox: () => ({
- copied: false,
- isZoomed: false,
- toggleZoom: vi.fn(),
- handleCopyPrompt: vi.fn(),
- handleImageLoad: vi.fn(),
- canZoom: true,
- isHovering: true,
- setIsHovering: vi.fn(),
- // Legacy/unused props that might be destructured
- naturalSize: { width: 1000, height: 1000 },
- isDragging: false,
- scrollContainerRef: { current: null },
- handleMouseDown: vi.fn(),
- handleMouseMove: vi.fn(),
- handleMouseUp: vi.fn(),
- handleMouseLeave: vi.fn(),
- }),
+ useImageLightbox: () => ({
+ copied: false,
+ isZoomed: false,
+ toggleZoom: vi.fn(),
+ handleCopyPrompt: vi.fn(),
+ handleImageLoad: vi.fn(),
+ canZoom: true,
+ isHovering: true,
+ setIsHovering: vi.fn(),
+ naturalSize: { width: 1000, height: 1000 },
+ isDragging: false,
+ scrollContainerRef: { current: null },
+ handleMouseDown: vi.fn(),
+ handleMouseMove: vi.fn(),
+ handleMouseUp: vi.fn(),
+ handleMouseLeave: vi.fn(),
+ hasDragged: { current: false },
+ }),
}));
// Mock the image details query
interface ImageDetailsMock {
- url: string;
- prompt: string;
- model: string;
- width?: number;
- height?: number;
- contentType?: string;
- _id?: string;
+ url: string;
+ prompt: string;
+ model: string;
+ width?: number;
+ height?: number;
+ contentType?: string;
+ _id?: string;
}
// Use vi.hoisted() to ensure the mock is available before the hoisted vi.mock runs
-const useImageDetailsMock = vi.hoisted(() => vi.fn<(imageId: string | null) => ImageDetailsMock | null | undefined>(() => null));
+const useImageDetailsMock = vi.hoisted(() =>
+ vi.fn<(imageId: string | null) => ImageDetailsMock | null | undefined>(
+ () => null,
+ ),
+);
vi.mock("@/hooks/queries/use-image-history", () => ({
- useImageDetails: useImageDetailsMock,
+ useImageDetails: useImageDetailsMock,
}));
// Mock MediaPlayer
vi.mock("@/components/ui/media-player", () => ({
- MediaPlayer: ({
- url,
- alt,
- contentType,
- onLoadedMetadata,
- onLoad,
- }: {
- url: string;
- alt: string;
- contentType?: string;
- onLoadedMetadata?: ReactEventHandler;
- onLoad?: ReactEventHandler;
- }) => {
- const isVideo = contentType?.startsWith("video/") || url?.match(/\.(mp4|webm|mov)$/i);
- if (isVideo) {
- return ;
- }
- // eslint-disable-next-line @next/next/no-img-element
- return
;
- },
- isVideoContent: (contentType: string | undefined, url: string) => contentType?.startsWith("video/") || url?.match(/\.(mp4|webm|mov)$/i),
+ MediaPlayer: ({
+ url,
+ alt,
+ contentType,
+ onLoadedMetadata,
+ onLoad,
+ }: {
+ url: string;
+ alt: string;
+ contentType?: string;
+ onLoadedMetadata?: ReactEventHandler;
+ onLoad?: ReactEventHandler;
+ }) => {
+ const isVideo =
+ contentType?.startsWith("video/") || url?.match(/\.(mp4|webm|mov)$/i);
+ if (isVideo) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ },
+ isVideoContent: (contentType: string | undefined, url: string) =>
+ contentType?.startsWith("video/") || url?.match(/\.(mp4|webm|mov)$/i),
}));
// Mock Next.js Image - not used anymore but keep for compatibility if needed
vi.mock("next/image", () => ({
- // eslint-disable-next-line @next/next/no-img-element
- default: ({ src, alt, ...props }: { src: string; alt: string; [key: string]: unknown }) =>
,
+ default: ({
+ src,
+ alt,
+ priority: _priority,
+ unoptimized: _unoptimized,
+ fill: _fill,
+ loader: _loader,
+ placeholder: _placeholder,
+ blurDataURL: _blurDataURL,
+ sizes: _sizes,
+ quality: _quality,
+ ...props
+ }: {
+ src: string;
+ alt: string;
+ priority?: boolean;
+ unoptimized?: boolean;
+ fill?: boolean;
+ loader?: unknown;
+ placeholder?: "blur" | "empty";
+ blurDataURL?: string;
+ sizes?: string;
+ quality?: number;
+ [key: string]: unknown;
+ }) =>
,
}));
// Mock framer-motion
vi.mock("framer-motion", () => ({
- AnimatePresence: ({ children }: { children: ReactNode }) => <>{children}>,
- motion: {
- div: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => {children}
,
- },
+ AnimatePresence: ({ children }: { children: ReactNode }) => <>{children}>,
+ motion: {
+ div: ({
+ children,
+ layout: _layout,
+ ...props
+ }: {
+ children?: ReactNode;
+ layout?: boolean;
+ [key: string]: unknown;
+ }) => {children}
,
+ },
}));
// Mock Dialog components
vi.mock("@/components/ui/dialog", () => ({
- Dialog: ({ children, open }: { children: ReactNode; open?: boolean }) => (open ? {children}
: null),
- DialogContent: ({ children }: { children: ReactNode }) => {children}
,
- DialogTitle: ({ children }: { children: ReactNode }) => {children},
- DialogDescription: ({ children }: { children: ReactNode }) => {children},
- DialogOverlay: () => ,
- DialogPortal: ({ children }: { children: ReactNode }) => {children}
,
+ Dialog: ({ children, open }: { children: ReactNode; open?: boolean }) =>
+ open ? {children}
: null,
+ DialogContent: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ DialogTitle: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ DialogDescription: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ DialogOverlay: () => ,
+ DialogPortal: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
}));
// Mock Radix Dialog Primitives for PromptLibrary
vi.mock("@radix-ui/react-dialog", () => ({
- Content: ({ children }: { children: ReactNode }) => {children}
,
- Close: ({ children }: { children: ReactNode }) => ,
- Root: ({ children }: { children: ReactNode }) => {children}
,
- Portal: ({ children }: { children: ReactNode }) => {children}
,
- Overlay: () => ,
+ Content: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ Close: ({ children }: { children: ReactNode }) => (
+
+ ),
+ Root: ({ children }: { children: ReactNode }) => {children}
,
+ Portal: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ Overlay: () => ,
}));
// Mock VisuallyHidden
vi.mock("@radix-ui/react-visually-hidden", () => ({
- VisuallyHidden: ({ children }: { children: ReactNode }) => {children},
+ VisuallyHidden: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
}));
// Mock Tooltip components
vi.mock("@/components/ui/tooltip", () => ({
- Tooltip: ({ children }: { children: ReactNode }) => <>{children}>,
- TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}>,
- TooltipContent: ({ children }: { children: ReactNode }) => {children},
+ Tooltip: ({ children }: { children: ReactNode }) => <>{children}>,
+ TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}>,
+ TooltipContent: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
}));
// Mock Button
vi.mock("@/components/ui/button", () => ({
- Button: ({ children, onClick, disabled, ...props }: ButtonHTMLAttributes) => (
-
- ),
+ Button: ({
+ children,
+ onClick,
+ disabled,
+ ...props
+ }: ButtonHTMLAttributes) => (
+
+ ),
}));
// Mock the PromptLibrary's child components
vi.mock("@/components/studio/features/prompt-library/prompt-list-view", () => ({
- PromptListView: ({ onInsertPrompt, prompts }: { onInsertPrompt: (content: string) => void; prompts?: readonly PromptMock[] }) => (
-
- {prompts?.map((p) => (
-
- ))}
-
- ),
+ PromptListView: ({
+ onInsertPrompt,
+ prompts,
+ }: {
+ onInsertPrompt: (content: string) => void;
+ prompts?: readonly PromptMock[];
+ }) => (
+
+ {prompts?.map((p) => (
+
+ ))}
+
+ ),
}));
vi.mock("@/components/studio/features/prompt-library/save-prompt-form", () => ({
- SavePromptForm: () => Save Form
,
+ SavePromptForm: () => Save Form
,
}));
vi.mock("@/components/studio/features/prompt-library/prompt-detail", () => ({
- PromptDetail: () => Detail
,
+ PromptDetail: () => Detail
,
}));
-// Mock lucide-react icons
vi.mock("lucide-react", () => ({
- BookmarkPlus: () => ๐ฅ,
- Check: () => โ,
- Copy: () => ๐,
- Loader2: () => โณ,
- LogIn: () => ๐,
- ZoomIn: () => ๐,
- X: () => โ,
+ BookmarkPlus: () => ๐ฅ,
+ Check: () => โ,
+ Copy: () => ๐,
+ Loader2: () => โณ,
+ LogIn: () => ๐,
+ ZoomIn: () => ๐,
+ X: () => โ,
+ Wand2: () => ๐ช,
}));
// Mock model config
-vi.mock("@/lib/config/models", () => ({
- getModelDisplayName: (model: string) => model,
-}));
+vi.mock("@/lib/config/models", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ getModelDisplayName: (model: string) => model,
+ };
+});
describe("ImageLightbox - Prompt Library Integration", () => {
- const mockImage = {
- url: "https://example.com/test-image.jpg",
- prompt: "A beautiful landscape",
- model: "test-model",
- width: 1024,
- height: 1024,
- };
-
- beforeEach(() => {
- vi.clearAllMocks();
- capturedOnInsertComplete = undefined;
- });
-
- it("renders the lightbox with the image when open", () => {
- render();
-
- expect(screen.getByTestId("dialog")).toBeInTheDocument();
- });
-
- it("shows the save to library button when prompt is available", () => {
- render();
-
- expect(screen.getByTestId("bookmark-icon")).toBeInTheDocument();
- });
-
- it("opens the prompt library when save to library button is clicked", async () => {
- const user = userEvent.setup();
- render();
-
- // Find and click the save to library button
- const saveButton = screen.getByTestId("bookmark-icon").closest("button");
- await user.click(saveButton!);
-
- // The prompt library should be open (showing prompt list view)
- await waitFor(() => {
- expect(screen.getByTestId("prompt-list-view")).toBeInTheDocument();
- });
- });
-
- it("closes the lightbox when insert prompt is clicked from the library", async () => {
- const user = userEvent.setup();
- const onClose = vi.fn();
- render();
-
- // Open the library
- const saveButton = screen.getByTestId("bookmark-icon").closest("button");
- await user.click(saveButton!);
-
- // Wait for library to open
- await waitFor(() => {
- expect(screen.getByTestId("prompt-list-view")).toBeInTheDocument();
- });
-
- // Click insert on a prompt
- const insertButton = screen.getByTestId("insert-prompt-btn");
- await user.click(insertButton);
-
- // The lightbox's onClose should have been called (via onInsertComplete)
- expect(onClose).toHaveBeenCalled();
- });
-
- it("passes onClose as onInsertComplete to PromptLibrary", async () => {
- const user = userEvent.setup();
- const onClose = vi.fn();
- render();
-
- // Open the library
- const saveButton = screen.getByTestId("bookmark-icon").closest("button");
- await user.click(saveButton!);
-
- // Verify the onInsertComplete callback was captured and is the onClose function
- // This tests that the prop is correctly wired
- await waitFor(() => {
- expect(capturedOnInsertComplete).toBeDefined();
- });
-
- // Invoking onInsertComplete should call onClose
- capturedOnInsertComplete?.();
- expect(onClose).toHaveBeenCalled();
- });
-
- describe("prompt insertion from lightbox", () => {
- it("should call onInsertPrompt with prompt content when insert is triggered", async () => {
- const user = userEvent.setup();
- const onInsertPrompt = vi.fn();
-
- render();
-
- // Open the library by clicking the save to library button
- const saveButton = screen.getByTestId("bookmark-icon").closest("button");
- await user.click(saveButton!);
-
- // Wait for library to open
- await waitFor(() => {
- expect(screen.getByTestId("prompt-list-view")).toBeInTheDocument();
- });
-
- // Click insert on a prompt
- const insertButton = screen.getByTestId("insert-prompt-btn");
- await user.click(insertButton);
-
- // The onInsertPrompt callback should be called with the inserted content
- expect(onInsertPrompt).toHaveBeenCalledWith("Test Content");
- });
-
- it("does not fail if onInsertPrompt is not provided", async () => {
- const user = userEvent.setup();
-
- // Render without onInsertPrompt - should not throw
- render();
-
- // Open the library
- const saveButton = screen.getByTestId("bookmark-icon").closest("button");
- await user.click(saveButton!);
-
- // Wait for library to open
- await waitFor(() => {
- expect(screen.getByTestId("prompt-list-view")).toBeInTheDocument();
- });
-
- // Click insert - should not throw even without the callback
- const insertButton = screen.getByTestId("insert-prompt-btn");
- await expect(user.click(insertButton)).resolves.not.toThrow();
- });
- });
-
- describe("video support", () => {
- const mockVideo = {
- url: "https://example.com/test-video.mp4",
- prompt: "A beautiful video",
- model: "veo",
- contentType: "video/mp4",
- };
-
- it("renders a video player when content is video", () => {
- render();
-
- expect(screen.getByTestId("video-player")).toBeInTheDocument();
- expect(screen.getByTestId("video-player")).toHaveAttribute("src", mockVideo.url);
- expect(screen.queryByTestId("image-player")).not.toBeInTheDocument();
- });
-
- it("does not show zoom indicator for video content", () => {
- render();
-
- expect(screen.queryByTestId("zoom-indicator")).not.toBeInTheDocument();
- expect(screen.queryByText("๐")).not.toBeInTheDocument();
- });
-
- it("prioritizes full video URL from details over thumbnail URL", async () => {
- // Setup useImageDetails to return a different URL (the original video)
- const mockFullDetails = {
- ...mockVideo,
- url: "https://example.com/original-full-video.mp4",
- };
- useImageDetailsMock.mockReturnValue(mockFullDetails);
-
- const thumbnailVideo = {
- ...mockVideo,
- url: "https://example.com/thumbnail.jpg", // Thumbnail passed in props
- _id: "test-id",
- };
-
- render();
-
- // Should render video player with the FULL URL, not the thumbnail URL
- expect(screen.getByTestId("video-player")).toBeInTheDocument();
- expect(screen.getByTestId("video-player")).toHaveAttribute("src", mockFullDetails.url);
-
- // Clean up mock
- useImageDetailsMock.mockReturnValue(null);
- });
- });
-
- describe("copy prompt auth-gating", () => {
- const mockImage = {
- url: "https://example.com/test-image.jpg",
- prompt: "A beautiful landscape",
- model: "test-model",
- width: 1024,
- height: 1024,
- };
-
- it("shows copy prompt button when authenticated", async () => {
- // Default mock already has isSignedIn: true
- render();
-
- // Copy button should be present (not a link)
- const copyButton = screen.getAllByRole("button")[1]; // Second button after save to library
- expect(copyButton).toBeInTheDocument();
- });
-
- it("shows sign-in link for copy prompt when not authenticated", async () => {
- // Reset modules to clear cache
- vi.resetModules();
-
- // Set up mocks before importing the component
- vi.doMock("@clerk/nextjs", () => ({
- useAuth: () => ({ isSignedIn: false }),
- }));
-
- // Re-mock all the dependencies that the component needs
- vi.doMock("@/hooks/use-image-lightbox", () => ({
- useImageLightbox: () => ({
- copied: false,
- isZoomed: false,
- toggleZoom: vi.fn(),
- handleCopyPrompt: vi.fn(),
- handleImageLoad: vi.fn(),
- canZoom: true,
- isHovering: true,
- setIsHovering: vi.fn(),
- naturalSize: { width: 1000, height: 1000 },
- isDragging: false,
- scrollContainerRef: { current: null },
- handleMouseDown: vi.fn(),
- handleMouseMove: vi.fn(),
- handleMouseUp: vi.fn(),
- handleMouseLeave: vi.fn(),
- hasDragged: { current: false },
- }),
- }));
-
- vi.doMock("@/hooks/queries/use-image-history", () => ({
- useImageDetails: vi.fn(() => null),
- }));
-
- // Import fresh module with new mocks
- const { ImageLightbox: UnauthLightbox } = await import("./image-lightbox");
-
- const { container } = render();
-
- // Should have links to sign-in for both the save to library and copy prompt buttons
- const signInLinks = container.querySelectorAll('a[href="/sign-in"]');
- // There should be 2 sign-in links now - one for save to library and one for copy prompt
- expect(signInLinks.length).toBe(2);
- });
- });
+ const mockImage = {
+ url: "https://example.com/test-image.jpg",
+ prompt: "A beautiful landscape",
+ model: "test-model",
+ width: 1024,
+ height: 1024,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ capturedOnInsertComplete = undefined;
+ });
+
+ it("renders the lightbox with the image when open", () => {
+ render();
+
+ expect(screen.getByTestId("dialog")).toBeInTheDocument();
+ });
+
+ it("shows the save to library button when prompt is available", () => {
+ render();
+
+ expect(screen.getByTestId("bookmark-icon")).toBeInTheDocument();
+ });
+
+ it("opens the prompt library when save to library button is clicked", async () => {
+ const user = userEvent.setup();
+ render();
+
+ // Find and click the save to library button
+ const saveButton = screen.getByTestId("bookmark-icon").closest("button");
+ await user.click(saveButton!);
+
+ // The prompt library should be open (showing prompt list view)
+ await waitFor(() => {
+ expect(screen.getByTestId("prompt-list-view")).toBeInTheDocument();
+ });
+ });
+
+ it("closes the lightbox when insert prompt is clicked from the library", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+ render();
+
+ // Open the library
+ const saveButton = screen.getByTestId("bookmark-icon").closest("button");
+ await user.click(saveButton!);
+
+ // Wait for library to open
+ await waitFor(() => {
+ expect(screen.getByTestId("prompt-list-view")).toBeInTheDocument();
+ });
+
+ // Click insert on a prompt
+ const insertButton = screen.getByTestId("insert-prompt-btn");
+ await user.click(insertButton);
+
+ // The lightbox's onClose should have been called (via onInsertComplete)
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it("passes onClose as onInsertComplete to PromptLibrary", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+ render();
+
+ // Open the library
+ const saveButton = screen.getByTestId("bookmark-icon").closest("button");
+ await user.click(saveButton!);
+
+ // Verify the onInsertComplete callback was captured and is the onClose function
+ // This tests that the prop is correctly wired
+ await waitFor(() => {
+ expect(capturedOnInsertComplete).toBeDefined();
+ });
+
+ // Invoking onInsertComplete should call onClose
+ capturedOnInsertComplete?.();
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ describe("prompt insertion from lightbox", () => {
+ it("should call onInsertPrompt with prompt content when insert is triggered", async () => {
+ const user = userEvent.setup();
+ const onInsertPrompt = vi.fn();
+
+ render(
+ ,
+ );
+
+ // Open the library by clicking the save to library button
+ const saveButton = screen.getByTestId("bookmark-icon").closest("button");
+ await user.click(saveButton!);
+
+ // Wait for library to open
+ await waitFor(() => {
+ expect(screen.getByTestId("prompt-list-view")).toBeInTheDocument();
+ });
+
+ // Click insert on a prompt
+ const insertButton = screen.getByTestId("insert-prompt-btn");
+ await user.click(insertButton);
+
+ // The onInsertPrompt callback should be called with the inserted content
+ expect(onInsertPrompt).toHaveBeenCalledWith("Test Content");
+ });
+
+ it("does not fail if onInsertPrompt is not provided", async () => {
+ const user = userEvent.setup();
+
+ // Render without onInsertPrompt - should not throw
+ render(
+ ,
+ );
+
+ // Open the library
+ const saveButton = screen.getByTestId("bookmark-icon").closest("button");
+ await user.click(saveButton!);
+
+ // Wait for library to open
+ await waitFor(() => {
+ expect(screen.getByTestId("prompt-list-view")).toBeInTheDocument();
+ });
+
+ // Click insert - should not throw even without the callback
+ const insertButton = screen.getByTestId("insert-prompt-btn");
+ await expect(user.click(insertButton)).resolves.not.toThrow();
+ });
+ });
+
+ describe("video support", () => {
+ const mockVideo = {
+ url: "https://example.com/test-video.mp4",
+ prompt: "A beautiful video",
+ model: "veo",
+ contentType: "video/mp4",
+ };
+
+ it("renders a video player when content is video", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("video-player")).toBeInTheDocument();
+ expect(screen.getByTestId("video-player")).toHaveAttribute(
+ "src",
+ mockVideo.url,
+ );
+ expect(screen.queryByTestId("image-player")).not.toBeInTheDocument();
+ });
+
+ it("does not show zoom indicator for video content", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByTestId("zoom-indicator")).not.toBeInTheDocument();
+ expect(screen.queryByText("๐")).not.toBeInTheDocument();
+ });
+
+ it("prioritizes full video URL from details over thumbnail URL", async () => {
+ // Setup useImageDetails to return a different URL (the original video)
+ const mockFullDetails = {
+ ...mockVideo,
+ url: "https://example.com/original-full-video.mp4",
+ };
+ useImageDetailsMock.mockReturnValue(mockFullDetails);
+
+ const thumbnailVideo = {
+ ...mockVideo,
+ url: "https://example.com/thumbnail.jpg", // Thumbnail passed in props
+ _id: "test-id",
+ };
+
+ render(
+ ,
+ );
+
+ // Should render video player with the FULL URL, not the thumbnail URL
+ expect(screen.getByTestId("video-player")).toBeInTheDocument();
+ expect(screen.getByTestId("video-player")).toHaveAttribute(
+ "src",
+ mockFullDetails.url,
+ );
+
+ // Clean up mock
+ useImageDetailsMock.mockReturnValue(null);
+ });
+ });
+
+ describe("copy prompt auth-gating", () => {
+ const mockImage = {
+ url: "https://example.com/test-image.jpg",
+ prompt: "A beautiful landscape",
+ model: "test-model",
+ width: 1024,
+ height: 1024,
+ };
+
+ it("shows copy prompt button when authenticated", async () => {
+ // Default mock already has isSignedIn: true
+ render(
+ ,
+ );
+
+ // Copy button should be present (not a link)
+ const copyButton = screen.getAllByRole("button")[1]; // Second button after save to library
+ expect(copyButton).toBeInTheDocument();
+ });
+
+ it("shows sign-in link for copy prompt when not authenticated", async () => {
+ // Reset modules to clear cache
+ vi.resetModules();
+
+ // Set up mocks before importing the component
+ vi.doMock("@clerk/nextjs", () => ({
+ useAuth: () => ({ isSignedIn: false }),
+ }));
+
+ // Re-mock all the dependencies that the component needs
+ vi.doMock("@/hooks/use-image-lightbox", () => ({
+ useImageLightbox: () => ({
+ copied: false,
+ isZoomed: false,
+ toggleZoom: vi.fn(),
+ handleCopyPrompt: vi.fn(),
+ handleImageLoad: vi.fn(),
+ canZoom: true,
+ isHovering: true,
+ setIsHovering: vi.fn(),
+ naturalSize: { width: 1000, height: 1000 },
+ isDragging: false,
+ scrollContainerRef: { current: null },
+ handleMouseDown: vi.fn(),
+ handleMouseMove: vi.fn(),
+ handleMouseUp: vi.fn(),
+ handleMouseLeave: vi.fn(),
+ hasDragged: { current: false },
+ }),
+ }));
+
+ vi.doMock("@/hooks/queries/use-image-history", () => ({
+ useImageDetails: vi.fn(() => null),
+ }));
+
+ // Import fresh module with new mocks
+ const { ImageLightbox: UnauthLightbox } = await import(
+ "./image-lightbox"
+ );
+
+ const { container } = render(
+ ,
+ );
+
+ const signInLinks = container.querySelectorAll('a[href="/sign-in"]');
+ // 3 sign-in links: save to library, copy prompt, and edit image
+ expect(signInLinks.length).toBe(3);
+ });
+ });
+});
+
+describe("ImageLightbox - Single Image Mode", () => {
+ const mockImage = {
+ url: "https://example.com/test-image.jpg",
+ prompt: "A beautiful landscape",
+ model: "test-model",
+ width: 1024,
+ height: 1024,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders single centered image when no edits exist", () => {
+ render();
+
+ // Should NOT render comparison labels
+ expect(screen.queryByText("Original")).not.toBeInTheDocument();
+ expect(screen.queryByText("Current")).not.toBeInTheDocument();
+ });
+
+ it("does not render side-by-side comparison layout without edits", () => {
+ const { container } = render(
+ ,
+ );
+
+ // The grid-cols-2 class is used by LightboxCompareView for side-by-side
+ const compareGrid = container.querySelector(".md\\:grid-cols-2");
+ expect(compareGrid).not.toBeInTheDocument();
+ });
+
+ it("does not show version strip when no edits exist", () => {
+ render();
+
+ // Version strip has aria-label "Image versions"
+ expect(screen.queryByLabelText("Image versions")).not.toBeInTheDocument();
+ });
+
+ it("shows edit button for image content", () => {
+ render();
+
+ expect(screen.getByTestId("wand-icon")).toBeInTheDocument();
+ });
+
+ it("renders the image with correct URL", () => {
+ render();
+
+ // Find the image element
+ const images = screen.getAllByRole("img");
+ expect(images.length).toBeGreaterThan(0);
+ // At least one image should have our URL
+ const hasCorrectUrl = images.some(
+ (img) =>
+ img.getAttribute("src") === mockImage.url ||
+ img.getAttribute("src")?.includes("test-image.jpg"),
+ );
+ expect(hasCorrectUrl).toBe(true);
+ });
+});
+
+describe("ImageLightbox - Edit Flow", () => {
+ const mockImage = {
+ url: "https://example.com/test-image.jpg",
+ prompt: "A beautiful landscape",
+ model: "test-model",
+ width: 1024,
+ height: 1024,
+ };
+
+ const mockVideo = {
+ url: "https://example.com/test-video.mp4",
+ prompt: "A beautiful video",
+ model: "veo",
+ contentType: "video/mp4",
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("hides edit button for video content", () => {
+ render();
+
+ // Edit button (wand icon) should not be visible for video
+ // The button exists but is disabled/hidden based on canEdit prop
+ const wandIcons = screen.queryAllByTestId("wand-icon");
+ // Either no wand icon, or the button should be a sign-in link (for unauth users)
+ // Based on the code, the edit button is conditionally rendered based on canEditImage
+ // which is false for videos
+ expect(wandIcons.length).toBe(0);
+ });
+
+ // Note: Testing isGenerating state requires module reset and re-mocking all dependencies
+ // including lucide-react icons like Sparkles. This is complex and the component behavior
+ // is verified through other integration tests. The key behavior (showing loading overlay)
+ // is handled by GenerationLoadingOverlay which has its own unit tests.
+});
+
+describe("ImageLightbox - Comparison View", () => {
+ // To test comparison view, we need to mock a state where editChain has items
+ // This is challenging because editChain is internal state
+ // The best approach is to test the integration by simulating a successful edit
+
+ const mockImage = {
+ url: "https://example.com/test-image.jpg",
+ prompt: "A beautiful landscape",
+ model: "test-model",
+ width: 1024,
+ height: 1024,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("version strip returns null when only one version exists", () => {
+ // This tests that LightboxVersionStrip correctly hides itself
+ render();
+
+ // Version strip should not render when versions.length <= 1
+ expect(screen.queryByLabelText("Image versions")).not.toBeInTheDocument();
+ });
+});
+
+describe("ImageLightbox - State Reset", () => {
+ const mockImage = {
+ url: "https://example.com/test-image.jpg",
+ prompt: "A beautiful landscape",
+ model: "test-model",
+ width: 1024,
+ height: 1024,
+ };
+
+ const differentImage = {
+ url: "https://example.com/different-image.jpg",
+ prompt: "A different landscape",
+ model: "different-model",
+ width: 512,
+ height: 512,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("maintains single image mode when different image is opened", () => {
+ const { rerender } = render(
+ ,
+ );
+
+ // Re-render with different image
+ rerender(
+ ,
+ );
+
+ // Should still be in single mode (no comparison labels)
+ expect(screen.queryByText("Original")).not.toBeInTheDocument();
+ expect(screen.queryByText("Current")).not.toBeInTheDocument();
+ });
+
+ it("resets to single image mode when lightbox is closed and reopened", () => {
+ const { rerender } = render(
+ ,
+ );
+
+ // Close the lightbox
+ rerender(
+ ,
+ );
+
+ // Reopen the lightbox
+ rerender(
+ ,
+ );
+
+ // Should be in single mode (no comparison labels)
+ expect(screen.queryByText("Original")).not.toBeInTheDocument();
+ expect(screen.queryByText("Current")).not.toBeInTheDocument();
+ });
});
diff --git a/components/images/image-lightbox.tsx b/components/images/image-lightbox.tsx
index dcb0990..7bfe575 100644
--- a/components/images/image-lightbox.tsx
+++ b/components/images/image-lightbox.tsx
@@ -1,461 +1,643 @@
-"use client"
-
-import { PromptLibrary } from "@/components/studio/features/prompt-library"
-import { Button } from "@/components/ui/button"
-import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
-import { isVideoContent, MediaPlayer } from "@/components/ui/media-player"
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
-import type { Id } from "@/convex/_generated/dataModel"
-import { useImageDetails } from "@/hooks/queries/use-image-history"
-import { useImageLightbox, type LightboxImage } from "@/hooks/use-image-lightbox"
-import { getModelDisplayName } from "@/lib/config/models"
-import { cn } from "@/lib/utils"
-import { useAuth } from "@clerk/nextjs"
-import { VisuallyHidden } from "@radix-ui/react-visually-hidden"
-import { AnimatePresence, motion } from "framer-motion"
-import { BookmarkPlus, Check, Copy, Loader2, LogIn, ZoomIn } from "lucide-react"
-import Link from "next/link"
-import NextImage from "next/image"
-import * as React from "react"
-
-export type { LightboxImage }
+"use client";
+
+import { useAuth } from "@clerk/nextjs";
+import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
+import * as React from "react";
+import { toast } from "sonner";
+import { PromptLibrary } from "@/components/studio/features/prompt-library";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { isVideoContent, MediaPlayer } from "@/components/ui/media-player";
+import type { Id } from "@/convex/_generated/dataModel";
+import { useImageDetails } from "@/hooks/queries/use-image-history";
+import { useImageEdit } from "@/hooks/use-image-edit";
+import {
+ type LightboxImage,
+ useImageLightbox,
+} from "@/hooks/use-image-lightbox";
+import { getModelConstraints } from "@/lib/config/models";
+import type { GeneratedImage } from "@/lib/schemas/pollinations.schema";
+import {
+ EditImagePanel,
+ LightboxActions,
+ LightboxCompareView,
+ LightboxInfoOverlay,
+ LightboxMediaDisplay,
+ LightboxVersionStrip,
+ type SourceImageDisplayInfo,
+} from "./lightbox";
+
+export type { LightboxImage };
interface ImageLightboxProps {
- image: LightboxImage | null
- isOpen: boolean
- onClose: () => void
- /** Optional callback when a prompt is inserted from the library (used to update prompt input) */
- onInsertPrompt?: (content: string) => void
+ image: LightboxImage | null;
+ isOpen: boolean;
+ onClose: () => void;
+ /** Optional callback when a prompt is inserted from the library (used to update prompt input) */
+ onInsertPrompt?: (content: string) => void;
}
-export function ImageLightbox({ image, isOpen, onClose, onInsertPrompt }: ImageLightboxProps) {
- // Fetch full image details if we only have thumbnail data (no prompt)
- // This happens when opening from gallery which now returns lightweight data
- const imageId = image?._id as Id<"generatedImages"> | undefined
- const needsFullData = image && !image.prompt && imageId
- const fullImageData = useImageDetails(needsFullData ? imageId : null)
-
- // Merge thumbnail data with full data when available
- const displayImage: LightboxImage | null = image ? {
- ...image,
- // Use full data if available, otherwise fall back to what we have
- url: fullImageData?.url ?? image.url,
- prompt: fullImageData?.prompt ?? image.prompt ?? "",
- model: fullImageData?.model ?? image.model,
- width: fullImageData?.width ?? image.width,
- height: fullImageData?.height ?? image.height,
- seed: fullImageData?.seed ?? image.seed,
- contentType: fullImageData?.contentType ?? image.contentType,
- params: image.params ?? (fullImageData ? {
- model: fullImageData.model,
- width: fullImageData.width,
- height: fullImageData.height,
- seed: fullImageData.seed,
- } : undefined),
- } : null
-
- const isVideo = displayImage ? isVideoContent(displayImage.contentType, displayImage.url) : false
-
- const isLoadingDetails = needsFullData && fullImageData === undefined
-
- const {
- copied,
- isZoomed,
- naturalSize,
- isDragging,
- scrollContainerRef,
- canZoom,
- handleCopyPrompt,
- handleImageLoad,
- toggleZoom,
- handleMouseDown,
- handleMouseMove,
- handleMouseUp,
- handleMouseLeave,
- hasDragged: hasDraggedRef,
- isHovering,
- setIsHovering
- } = useImageLightbox({ image: displayImage, isOpen })
-
- // Auth state for gating features
- const { isSignedIn } = useAuth()
-
- // Prompt library state for saving prompts
- const [libraryOpen, setLibraryOpen] = React.useState(false)
- const [saveContent, setSaveContent] = React.useState(undefined)
-
- // Track loading states for thumbnail-to-full-res crossfade
- const [isThumbnailLoaded, setIsThumbnailLoaded] = React.useState(false)
- const [isFullResLoaded, setIsFullResLoaded] = React.useState(false)
-
- // Determine URLs for progressive loading
- // If originalUrl exists and differs from url, we have a thumbnail/full-res pair
- const thumbnailUrl = displayImage?.url
- const fullResUrl = displayImage?.originalUrl || displayImage?.url
- const hasSeparateThumbnail = displayImage?.originalUrl && displayImage.originalUrl !== displayImage.url
-
- // Reset loading states when image changes
- React.useEffect(() => {
- setIsThumbnailLoaded(false)
- setIsFullResLoaded(false)
- }, [displayImage?.url, displayImage?.originalUrl])
-
- return (
- <>
-