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:
parent
222ebe86ad
commit
66bafdcfa7
5 changed files with 487 additions and 165 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue