Improve UX: batch generate, ZIP fix, workflow guide, status badges

- Add "Generate All N Images" batch button with sequential processing and progress bar
- Fix ZIP folder collision: duplicate-named images get _2, _3 suffixes
- Add step-by-step workflow indicator in header (Upload → Ratios → Generate → Export)
- Show crop count badges on active image and thumbnail strip (✓ processed indicator)
- Add "no crops yet" placeholder with contextual hint
- Add summary bar showing processed image count, total crops, output files
- Improve CropPreviewCard: label header + pixel dimensions, better hover overlay
- Improve ImageUpload: hover-reveal remove buttons, + tile in thumbnail strip
- Raise muted-foreground lightness for better readability
- Soften border color and increase border-radius slightly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-13 17:02:01 +00:00
parent 222ebe86ad
commit 66bafdcfa7
5 changed files with 487 additions and 165 deletions

View file

@ -1,4 +1,4 @@
import React, { useRef, useEffect } from "react";
import React, { useRef, useEffect, useState } from "react";
import type { CropSuggestion } from "@/lib/crop-types";
import { Pencil } from "lucide-react";
@ -10,6 +10,7 @@ interface CropPreviewCardProps {
const CropPreviewCard: React.FC<CropPreviewCardProps> = ({ crop, imageUrl, onEdit }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [pixelSize, setPixelSize] = useState<{ w: number; h: number } | null>(null);
useEffect(() => {
const img = new Image();
@ -23,6 +24,8 @@ const CropPreviewCard: React.FC<CropPreviewCardProps> = ({ crop, imageUrl, onEdi
const sw = Math.round(crop.w * img.naturalWidth);
const sh = Math.round(crop.h * img.naturalHeight);
setPixelSize({ w: sw, h: sh });
const scale = Math.min(300 / sw, 300 / sh);
canvas.width = Math.round(sw * scale);
canvas.height = Math.round(sh * scale);
@ -37,16 +40,33 @@ const CropPreviewCard: React.FC<CropPreviewCardProps> = ({ crop, imageUrl, onEdi
return (
<div
onClick={onEdit}
className="group relative flex flex-col items-center gap-2 p-3 rounded border border-border bg-card cursor-pointer hover:border-primary transition-colors"
className="group relative flex flex-col rounded-lg border border-border bg-card cursor-pointer hover:border-primary transition-all overflow-hidden"
>
<canvas
ref={canvasRef}
className="rounded max-w-full"
style={{ maxHeight: 220 }}
/>
<span className="text-sm font-mono font-medium text-foreground">{crop.label}</span>
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-background/60 rounded">
<Pencil className="w-6 h-6 text-primary" />
{/* Header bar */}
<div className="flex items-center justify-between px-2.5 py-1.5 border-b border-border/60 bg-muted/20">
<span className="text-xs font-semibold text-foreground truncate">{crop.label}</span>
{pixelSize && (
<span className="text-[10px] text-muted-foreground shrink-0 ml-2 font-mono">
{pixelSize.w}×{pixelSize.h}
</span>
)}
</div>
{/* Canvas preview */}
<div className="flex items-center justify-center p-2 bg-muted/10 min-h-[100px]">
<canvas
ref={canvasRef}
className="rounded max-w-full"
style={{ maxHeight: 200 }}
/>
</div>
{/* Hover overlay */}
<div className="absolute inset-0 flex flex-col items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-background/70 rounded-lg">
<div className="w-9 h-9 rounded-full bg-primary/20 border border-primary/50 flex items-center justify-center mb-1.5">
<Pencil className="w-4 h-4 text-primary" />
</div>
<span className="text-xs font-medium text-foreground">Edit crop</span>
</div>
</div>
);

View file

@ -1,12 +1,13 @@
import React, { useCallback } from "react";
import { Upload, ImageIcon, X, Plus } from "lucide-react";
import type { ImageFile } from "@/lib/crop-types";
import { Upload, X, Plus, CheckCircle2 } from "lucide-react";
import type { CropSuggestion, ImageFile } from "@/lib/crop-types";
interface ImageUploadProps {
images: ImageFile[];
activeIndex: number;
onImagesChange: (images: ImageFile[]) => void;
onActiveChange: (index: number) => void;
cropsMap?: Map<string, CropSuggestion[]>;
}
const ImageUpload: React.FC<ImageUploadProps> = ({
@ -14,6 +15,7 @@ const ImageUpload: React.FC<ImageUploadProps> = ({
activeIndex,
onImagesChange,
onActiveChange,
cropsMap,
}) => {
const handleFiles = useCallback(
(files: FileList | File[]) => {
@ -82,72 +84,120 @@ const ImageUpload: React.FC<ImageUploadProps> = ({
onClick={handleClick}
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
className="flex flex-col items-center justify-center w-full h-64 rounded-xl border-2 border-dashed border-border bg-muted/20 hover:bg-muted/40 cursor-pointer transition-colors"
className="flex flex-col items-center justify-center w-full h-52 rounded-xl border-2 border-dashed border-border bg-muted/10 hover:bg-muted/20 hover:border-primary/50 cursor-pointer transition-all group"
>
<ImageIcon className="w-12 h-12 text-muted-foreground mb-3" />
<p className="text-lg font-medium text-foreground">
Drop images here or click to upload
</p>
<p className="text-sm text-muted-foreground mt-1">
JPG, PNG, or WEBP multiple files supported
</p>
<div className="w-11 h-11 rounded-xl bg-muted/40 flex items-center justify-center mb-3 group-hover:bg-primary/15 transition-colors">
<Upload className="w-5 h-5 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
<p className="text-sm font-semibold text-foreground mb-1">Drop images here or click to upload</p>
<p className="text-xs text-muted-foreground">JPG, PNG, WEBP multiple files supported</p>
</div>
);
}
const activeImage = images[activeIndex];
const activeCropCount = cropsMap?.get(activeImage?.id ?? "")?.length ?? 0;
return (
<div className="space-y-3">
<div className="space-y-2">
{/* Active image preview */}
<div className="relative w-full rounded-xl overflow-hidden border border-border bg-muted/30">
<div className="relative w-full rounded-xl overflow-hidden border border-border bg-muted/20">
<img
src={activeImage?.url}
alt={activeImage?.name}
className="w-full max-h-[500px] object-contain mx-auto block"
className="w-full max-h-[460px] object-contain mx-auto block"
/>
<div className="absolute bottom-3 right-3 flex items-center gap-2">
{/* Top-left: name + position */}
<div className="absolute top-3 left-3 flex items-center gap-1.5">
<span className="px-2 py-1 rounded bg-background/85 backdrop-blur text-xs font-mono text-foreground border border-border/60 max-w-[200px] truncate">
{activeImage?.name}
</span>
{images.length > 1 && (
<span className="px-2 py-1 rounded bg-background/85 backdrop-blur text-xs text-muted-foreground border border-border/60">
{activeIndex + 1}/{images.length}
</span>
)}
</div>
{/* Top-right: crop count badge */}
{activeCropCount > 0 && (
<div className="absolute top-3 right-3 flex items-center gap-1 px-2 py-1 rounded bg-primary/90 text-xs font-medium text-primary-foreground">
<CheckCircle2 className="w-3 h-3" />
{activeCropCount} crop{activeCropCount !== 1 ? "s" : ""}
</div>
)}
{/* Bottom-right: add more */}
<div className="absolute bottom-3 right-3">
<button
onClick={handleClick}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-background/80 backdrop-blur text-sm font-medium border border-border hover:bg-background transition-colors"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-background/85 backdrop-blur text-xs font-medium border border-border/60 hover:bg-background transition-colors"
>
<Plus className="w-4 h-4" /> Add More
<Plus className="w-3.5 h-3.5" />
Add More
</button>
</div>
<div className="absolute top-3 left-3 px-2 py-1 rounded bg-background/80 backdrop-blur text-xs font-mono text-foreground border border-border">
{activeIndex + 1}/{images.length} {activeImage?.name}
</div>
</div>
{/* Thumbnail strip */}
{images.length > 1 && (
<div className="flex gap-2 overflow-x-auto pb-1">
{images.map((img, i) => (
<div
key={img.id}
className={`relative shrink-0 w-16 h-16 rounded-lg border-2 cursor-pointer overflow-hidden transition-colors ${
i === activeIndex
? "border-primary"
: "border-border hover:border-muted-foreground"
}`}
onClick={() => onActiveChange(i)}
>
<img
src={img.url}
alt={img.name}
className="w-full h-full object-cover"
/>
<button
onClick={(e) => {
e.stopPropagation();
removeImage(i);
}}
className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity"
<div className="flex gap-2 overflow-x-auto pb-1 pt-0.5">
{images.map((img, i) => {
const cropCount = cropsMap?.get(img.id)?.length ?? 0;
const processed = cropCount > 0;
return (
<div
key={img.id}
className={`relative shrink-0 w-14 h-14 rounded-lg border-2 cursor-pointer overflow-hidden transition-all group/thumb ${
i === activeIndex
? "border-primary ring-1 ring-primary/30"
: processed
? "border-primary/40 hover:border-primary/70"
: "border-border hover:border-muted-foreground"
}`}
onClick={() => onActiveChange(i)}
title={img.name}
>
<X className="w-3 h-3" />
</button>
</div>
))}
<img src={img.url} alt={img.name} className="w-full h-full object-cover" />
{/* Crop done indicator */}
{processed && (
<div className="absolute bottom-0.5 right-0.5 w-4 h-4 rounded-full bg-primary flex items-center justify-center">
<CheckCircle2 className="w-2.5 h-2.5 text-primary-foreground" />
</div>
)}
{/* Remove button — appears on hover */}
<button
onClick={(e) => {
e.stopPropagation();
removeImage(i);
}}
className="absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-background/80 border border-border/60 flex items-center justify-center opacity-0 group-hover/thumb:opacity-100 hover:bg-destructive hover:text-destructive-foreground hover:border-destructive transition-all text-muted-foreground"
title="Remove"
>
<X className="w-2.5 h-2.5" />
</button>
{/* Index number */}
{i !== activeIndex && (
<div className="absolute top-0.5 right-0.5 w-4 h-4 rounded-full bg-background/75 text-[9px] font-bold text-muted-foreground flex items-center justify-center border border-border/40">
{i + 1}
</div>
)}
</div>
);
})}
{/* Add more tile */}
<button
onClick={handleClick}
className="shrink-0 w-14 h-14 rounded-lg border-2 border-dashed border-border hover:border-primary/50 hover:bg-muted/20 flex items-center justify-center transition-all"
title="Add more images"
>
<Plus className="w-4 h-4 text-muted-foreground" />
</button>
</div>
)}
</div>

View file

@ -20,7 +20,7 @@
--secondary-foreground: 46 100% 51%;
--muted: 40 10% 12%;
--muted-foreground: 42 40% 35%;
--muted-foreground: 42 35% 48%;
--accent: 40 20% 15%;
--accent-foreground: 46 100% 51%;
@ -28,11 +28,11 @@
--destructive: 0 80% 45%;
--destructive-foreground: 0 0% 100%;
--border: 42 30% 20%;
--input: 42 30% 20%;
--border: 42 25% 18%;
--input: 42 25% 18%;
--ring: 46 100% 51%;
--radius: 0.25rem;
--radius: 0.375rem;
--sidebar-background: 40 10% 6%;
--sidebar-foreground: 46 100% 51%;
@ -61,7 +61,7 @@
--secondary-foreground: 46 100% 51%;
--muted: 40 10% 12%;
--muted-foreground: 42 40% 35%;
--muted-foreground: 42 35% 48%;
--accent: 40 20% 15%;
--accent-foreground: 46 100% 51%;
@ -69,8 +69,8 @@
--destructive: 0 80% 45%;
--destructive-foreground: 0 0% 100%;
--border: 42 30% 20%;
--input: 42 30% 20%;
--border: 42 25% 18%;
--input: 42 25% 18%;
--ring: 46 100% 51%;
--sidebar-background: 40 10% 6%;
--sidebar-foreground: 46 100% 51%;

View file

@ -15,6 +15,15 @@ export async function exportCropsAsZip(
const zip = new JSZip();
const multiImage = images.length > 1;
// Build unique folder names to avoid collisions when images share the same name
const folderNameCounts = new Map<string, number>();
function uniqueFolderName(raw: string): string {
const base = sanitize(raw);
const count = folderNameCounts.get(base) ?? 0;
folderNameCounts.set(base, count + 1);
return count === 0 ? base : `${base}_${count + 1}`;
}
// Build lookup from crop label to preset (for pixel sizes)
const presetByLabel = new Map<string, AspectRatioPreset>();
selectedPresets.forEach((p) => presetByLabel.set(p.label, p));
@ -37,7 +46,7 @@ export async function exportCropsAsZip(
if (crops.length === 0) continue;
const img = await loadImage(image.url);
const folder = multiImage ? zip.folder(sanitize(image.name))! : zip;
const folder = multiImage ? zip.folder(uniqueFolderName(image.name))! : zip;
for (const crop of crops) {
const sx = Math.round(crop.x * img.naturalWidth);

View file

@ -7,7 +7,20 @@ import CropEditor from "@/components/CropEditor";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Scissors, Download, Loader2, Sparkles, Cpu, LogOut, User } from "lucide-react";
import {
Scissors,
Download,
Loader2,
Sparkles,
Cpu,
LogOut,
User,
ListChecks,
ImageIcon,
SlidersHorizontal,
Zap,
ChevronRight,
} from "lucide-react";
import { toast } from "sonner";
import type { AspectRatioPreset, CropSuggestion, ImageFile } from "@/lib/crop-types";
import { exportCropsAsZip } from "@/lib/export-zip";
@ -24,6 +37,8 @@ const Index = () => {
const [selectedRatios, setSelectedRatios] = useState<AspectRatioPreset[]>([]);
const [cropsMap, setCropsMap] = useState<Map<string, CropSuggestion[]>>(new Map());
const [analyzing, setAnalyzing] = useState(false);
const [batchAnalyzing, setBatchAnalyzing] = useState(false);
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null);
const [exporting, setExporting] = useState(false);
const [exportProgress, setExportProgress] = useState(0);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
@ -32,7 +47,6 @@ const Index = () => {
const activeImage = images[activeIndex] || null;
const activeCrops = activeImage ? cropsMap.get(activeImage.id) || [] : [];
// Count total output files (accounting for pixel sizes)
const totalOutputFiles = Array.from(cropsMap.values()).reduce((sum, crops) => {
return (
sum +
@ -45,27 +59,26 @@ const Index = () => {
}, 0);
const totalCrops = Array.from(cropsMap.values()).reduce((sum, c) => sum + c.length, 0);
const processedCount = Array.from(cropsMap.values()).filter((c) => c.length > 0).length;
const anyBusy = analyzing || batchAnalyzing;
const handleGenerate = async () => {
if (!activeImage || selectedRatios.length === 0) {
toast.error("Upload an image and select at least one ratio.");
return;
}
setAnalyzing(true);
try {
const resultCrops =
engine === "ai"
? await analyzeImage(activeImage.url, selectedRatios)
: await analyzeImageLocal(activeImage.url, selectedRatios);
setCropsMap((prev) => {
const next = new Map(prev);
next.set(activeImage.id, resultCrops);
return next;
});
toast.success(`Generated ${resultCrops.length} crop suggestions!`);
toast.success(`Generated ${resultCrops.length} crop suggestions`);
} catch (e: any) {
console.error(e);
toast.error(e.message || "Failed to analyze image.");
@ -74,6 +87,37 @@ const Index = () => {
}
};
const handleGenerateAll = async () => {
if (images.length === 0 || selectedRatios.length === 0) {
toast.error("Upload images and select at least one ratio.");
return;
}
setBatchAnalyzing(true);
setBatchProgress({ done: 0, total: images.length });
try {
for (let i = 0; i < images.length; i++) {
const img = images[i];
const crops =
engine === "ai"
? await analyzeImage(img.url, selectedRatios)
: await analyzeImageLocal(img.url, selectedRatios);
setCropsMap((prev) => {
const next = new Map(prev);
next.set(img.id, crops);
return next;
});
setBatchProgress({ done: i + 1, total: images.length });
}
toast.success(`Generated crops for ${images.length} images`);
} catch (e: any) {
console.error(e);
toast.error(e.message || "Batch analysis failed.");
} finally {
setBatchAnalyzing(false);
setBatchProgress(null);
}
};
const handleExport = async () => {
if (totalCrops === 0) return;
setExporting(true);
@ -114,14 +158,64 @@ const Index = () => {
});
};
// Determine current step for workflow indicator
const step = images.length === 0 ? 1 : selectedRatios.length === 0 ? 2 : totalCrops === 0 ? 3 : 4;
return (
<div className="min-h-screen bg-background">
<header className="border-b border-border px-6 py-4">
{/* Header */}
<header className="border-b border-border bg-card/50 px-6 py-3 sticky top-0 z-10 backdrop-blur">
<div className="max-w-7xl mx-auto flex items-center gap-3">
<Scissors className="w-6 h-6 text-primary" />
<h1 className="text-xl font-bold tracking-tight text-foreground uppercase">
SmartCrop
</h1>
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded bg-primary flex items-center justify-center">
<Scissors className="w-4 h-4 text-primary-foreground" />
</div>
<div>
<h1 className="text-sm font-bold tracking-widest text-foreground uppercase leading-none">
SmartCrop
</h1>
<p className="text-[10px] text-muted-foreground leading-none mt-0.5 tracking-wide uppercase">
AI Image Cropping
</p>
</div>
</div>
{/* Workflow steps — hidden on small screens */}
<div className="hidden md:flex items-center gap-1 ml-6">
{[
{ n: 1, label: "Upload" },
{ n: 2, label: "Ratios" },
{ n: 3, label: "Generate" },
{ n: 4, label: "Export" },
].map(({ n, label }, i, arr) => (
<React.Fragment key={n}>
<div className="flex items-center gap-1.5">
<div
className={`w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold transition-colors ${
step > n
? "bg-primary text-primary-foreground"
: step === n
? "bg-primary/30 text-primary border border-primary"
: "bg-muted text-muted-foreground"
}`}
>
{step > n ? "✓" : n}
</div>
<span
className={`text-xs font-medium transition-colors ${
step >= n ? "text-foreground" : "text-muted-foreground"
}`}
>
{label}
</span>
</div>
{i < arr.length - 1 && (
<ChevronRight className="w-3 h-3 text-muted-foreground/40" />
)}
</React.Fragment>
))}
</div>
<div className="ml-auto flex items-center gap-2">
<User className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground hidden sm:inline">
@ -139,110 +233,225 @@ const Index = () => {
</div>
</header>
<main className="max-w-7xl mx-auto px-6 py-8">
<div className="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-8">
<div className="space-y-6">
<ImageUpload
images={images}
activeIndex={activeIndex}
onImagesChange={handleImagesChange}
onActiveChange={setActiveIndex}
/>
<main className="max-w-7xl mx-auto px-6 py-6">
<div className="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-6">
{/* Main content */}
<div className="space-y-5">
{/* Step 1: Upload */}
<section>
<SectionLabel step={1} active={step === 1} done={step > 1} label="Upload Images" />
<ImageUpload
images={images}
activeIndex={activeIndex}
onImagesChange={handleImagesChange}
onActiveChange={setActiveIndex}
cropsMap={cropsMap}
/>
</section>
{activeImage && (
<div className="flex items-center gap-3 flex-wrap">
{/* Engine toggle */}
<div className="flex rounded-lg border border-border overflow-hidden">
<button
onClick={() => setEngine("ai")}
className={`flex items-center gap-1.5 px-3 py-2 text-sm transition-colors ${
engine === "ai"
? "bg-primary text-primary-foreground"
: "bg-card text-muted-foreground hover:text-foreground"
}`}
{/* Step 2+3: Engine + Generate (only when images are loaded) */}
{images.length > 0 && (
<section>
<SectionLabel step={3} active={step === 3} done={step > 3} label="Generate Crops" />
<div className="flex items-center gap-3 flex-wrap">
{/* Engine toggle */}
<div className="flex rounded-lg border border-border overflow-hidden shrink-0">
<button
onClick={() => setEngine("ai")}
disabled={anyBusy}
className={`flex items-center gap-1.5 px-3 py-2 text-sm transition-colors disabled:opacity-50 ${
engine === "ai"
? "bg-primary text-primary-foreground"
: "bg-card text-muted-foreground hover:text-foreground"
}`}
>
<Sparkles className="w-3.5 h-3.5" />
AI
</button>
<button
onClick={() => setEngine("local")}
disabled={anyBusy}
className={`flex items-center gap-1.5 px-3 py-2 text-sm transition-colors disabled:opacity-50 ${
engine === "local"
? "bg-primary text-primary-foreground"
: "bg-card text-muted-foreground hover:text-foreground"
}`}
>
<Cpu className="w-3.5 h-3.5" />
Local
</button>
</div>
{/* Generate current */}
<Button
onClick={handleGenerate}
disabled={anyBusy || selectedRatios.length === 0}
size="default"
className="gap-2 uppercase"
title={selectedRatios.length === 0 ? "Select at least one ratio first" : undefined}
>
<Sparkles className="w-3.5 h-3.5" />
AI
</button>
<button
onClick={() => setEngine("local")}
className={`flex items-center gap-1.5 px-3 py-2 text-sm transition-colors ${
engine === "local"
? "bg-primary text-primary-foreground"
: "bg-card text-muted-foreground hover:text-foreground"
}`}
>
<Cpu className="w-3.5 h-3.5" />
Local
</button>
{analyzing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Zap className="w-4 h-4" />
)}
{analyzing ? "Analyzing…" : "Generate This Image"}
</Button>
{/* Generate all — only with 2+ images */}
{images.length > 1 && (
<Button
onClick={handleGenerateAll}
disabled={anyBusy || selectedRatios.length === 0}
size="default"
variant="outline"
className="gap-2 uppercase"
title={selectedRatios.length === 0 ? "Select at least one ratio first" : undefined}
>
{batchAnalyzing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<ListChecks className="w-4 h-4" />
)}
{batchAnalyzing
? `Processing ${batchProgress?.done ?? 0} / ${batchProgress?.total ?? 0}`
: `Generate All ${images.length} Images`}
</Button>
)}
{/* Export — shown when crops exist */}
{totalCrops > 0 && (
<>
<div className="w-px h-8 bg-border hidden sm:block" />
<Button
onClick={handleExport}
disabled={exporting}
variant="secondary"
size="default"
className="gap-2 uppercase"
>
<Download className="w-4 h-4" />
{exporting
? "Exporting…"
: `Download ZIP (${totalOutputFiles} file${totalOutputFiles !== 1 ? "s" : ""})`}
</Button>
</>
)}
</div>
<Button
onClick={handleGenerate}
disabled={analyzing || selectedRatios.length === 0}
size="lg"
className="gap-2 uppercase"
title={selectedRatios.length === 0 ? "Select at least one aspect ratio" : undefined}
>
{analyzing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Scissors className="w-4 h-4" />
)}
{analyzing
? "Analyzing…"
: engine === "ai"
? "Generate Crops (AI)"
: "Generate Crops (Local)"}
</Button>
{totalCrops > 0 && (
<Button
onClick={handleExport}
disabled={exporting}
variant="secondary"
size="lg"
className="gap-2 uppercase"
>
<Download className="w-4 h-4" />
{exporting
? "Exporting…"
: `Download .ZIP (${totalOutputFiles} file${totalOutputFiles !== 1 ? "s" : ""})`}
</Button>
{/* Hint when ratios not selected */}
{selectedRatios.length === 0 && (
<p className="mt-2 text-xs text-muted-foreground flex items-center gap-1.5">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-primary/60" />
Select aspect ratios in the panel on the right first
</p>
)}
</div>
{/* Batch progress */}
{batchProgress && (
<div className="mt-3 space-y-1.5">
<Progress
value={Math.round((batchProgress.done / batchProgress.total) * 100)}
className="h-1.5"
/>
<p className="text-xs text-muted-foreground">
Processing image {batchProgress.done} of {batchProgress.total}
</p>
</div>
)}
{/* Export progress */}
{exporting && (
<div className="mt-3 space-y-1.5">
<Progress value={exportProgress} className="h-1.5" />
<p className="text-xs text-muted-foreground">Building ZIP {exportProgress}%</p>
</div>
)}
</section>
)}
{exporting && <Progress value={exportProgress} className="h-2" />}
{/* Step 4: Crops grid */}
{activeImage && (
<section>
{activeCrops.length > 0 ? (
<>
<div className="flex items-center justify-between mb-3">
<SectionLabel
step={4}
active={step === 4}
done={false}
label={`Crops for "${activeImage.name}"`}
/>
<span className="text-xs text-muted-foreground">
{activeCrops.length} crop{activeCrops.length !== 1 ? "s" : ""} click to edit
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{activeCrops.map((crop, i) => (
<CropPreviewCard
key={crop.label}
crop={crop}
imageUrl={activeImage.url}
onEdit={() => setEditingIndex(i)}
/>
))}
</div>
</>
) : (
/* No crops yet placeholder */
<div className="flex flex-col items-center justify-center py-10 rounded-xl border border-dashed border-border bg-muted/10 text-center">
<div className="w-12 h-12 rounded-full bg-muted/30 flex items-center justify-center mb-3">
<Scissors className="w-5 h-5 text-muted-foreground" />
</div>
<p className="text-sm font-medium text-foreground mb-1">No crops yet</p>
<p className="text-xs text-muted-foreground max-w-[220px]">
{selectedRatios.length === 0
? "Select aspect ratios on the right, then click Generate"
: 'Click "Generate This Image" to analyze and crop'}
</p>
</div>
)}
</section>
)}
{activeCrops.length > 0 && activeImage && (
<div>
<h2 className="text-lg font-bold text-foreground mb-4 uppercase">
Crops for "{activeImage.name}" click to edit
</h2>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{activeCrops.map((crop, i) => (
<CropPreviewCard
key={crop.label}
crop={crop}
imageUrl={activeImage.url}
onEdit={() => setEditingIndex(i)}
/>
))}
{/* Summary bar — when multiple images have crops */}
{processedCount > 0 && images.length > 1 && (
<div className="flex items-center gap-3 px-4 py-3 rounded-lg bg-card border border-border text-sm">
<div className="flex items-center gap-1.5 text-muted-foreground">
<ImageIcon className="w-4 h-4" />
<span>
<span className="text-foreground font-medium">{processedCount}</span> / {images.length} images processed
</span>
</div>
<div className="w-px h-4 bg-border" />
<div className="text-muted-foreground">
<span className="text-foreground font-medium">{totalCrops}</span> total crops
</div>
{totalCrops > 0 && (
<>
<div className="w-px h-4 bg-border" />
<div className="text-muted-foreground">
<span className="text-foreground font-medium">{totalOutputFiles}</span> output files
</div>
</>
)}
</div>
)}
</div>
<aside className="lg:border-l lg:border-border lg:pl-6">
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground mb-4">
Aspect Ratios & Sizes
</h2>
<ScrollArea className="h-[calc(100vh-12rem)]">
<RatioSelector
selected={selectedRatios}
onChange={setSelectedRatios}
/>
{/* Sidebar: Ratio selector */}
<aside className="lg:border-l lg:border-border lg:pl-5">
<div className="flex items-center gap-2 mb-3">
<SectionLabel step={2} active={step === 2} done={step > 2} label="Aspect Ratios" />
{selectedRatios.length > 0 && (
<span className="ml-auto text-xs font-medium px-1.5 py-0.5 rounded bg-primary/20 text-primary">
{selectedRatios.length} selected
</span>
)}
</div>
<ScrollArea className="h-[calc(100vh-14rem)]">
<RatioSelector selected={selectedRatios} onChange={setSelectedRatios} />
</ScrollArea>
</aside>
</div>
@ -260,4 +469,38 @@ const Index = () => {
);
};
/* Small inline step label component */
const SectionLabel = ({
step,
active,
done,
label,
}: {
step: number;
active: boolean;
done: boolean;
label: string;
}) => (
<div className="flex items-center gap-2 mb-2">
<div
className={`w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0 transition-colors ${
done
? "bg-primary text-primary-foreground"
: active
? "bg-primary/25 text-primary border border-primary"
: "bg-muted text-muted-foreground"
}`}
>
{done ? "✓" : step}
</div>
<h2
className={`text-xs font-bold uppercase tracking-wider transition-colors ${
active || done ? "text-foreground" : "text-muted-foreground"
}`}
>
{label}
</h2>
</div>
);
export default Index;