From 66bafdcfa7023c2b95119f2f1ea96b72bf61a286 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Fri, 13 Mar 2026 17:02:01 +0000 Subject: [PATCH] Improve UX: batch generate, ZIP fix, workflow guide, status badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/CropPreviewCard.tsx | 40 ++- src/components/ImageUpload.tsx | 140 ++++++--- src/index.css | 14 +- src/lib/export-zip.ts | 11 +- src/pages/Index.tsx | 447 ++++++++++++++++++++++------- 5 files changed, 487 insertions(+), 165 deletions(-) diff --git a/src/components/CropPreviewCard.tsx b/src/components/CropPreviewCard.tsx index bc0cde4..932bc3d 100644 --- a/src/components/CropPreviewCard.tsx +++ b/src/components/CropPreviewCard.tsx @@ -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 = ({ crop, imageUrl, onEdit }) => { const canvasRef = useRef(null); + const [pixelSize, setPixelSize] = useState<{ w: number; h: number } | null>(null); useEffect(() => { const img = new Image(); @@ -23,6 +24,8 @@ const CropPreviewCard: React.FC = ({ 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 = ({ crop, imageUrl, onEdi return (
- - {crop.label} -
- + {/* Header bar */} +
+ {crop.label} + {pixelSize && ( + + {pixelSize.w}×{pixelSize.h} + + )} +
+ + {/* Canvas preview */} +
+ +
+ + {/* Hover overlay */} +
+
+ +
+ Edit crop
); diff --git a/src/components/ImageUpload.tsx b/src/components/ImageUpload.tsx index 57470e3..dfa3944 100644 --- a/src/components/ImageUpload.tsx +++ b/src/components/ImageUpload.tsx @@ -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; } const ImageUpload: React.FC = ({ @@ -14,6 +15,7 @@ const ImageUpload: React.FC = ({ activeIndex, onImagesChange, onActiveChange, + cropsMap, }) => { const handleFiles = useCallback( (files: FileList | File[]) => { @@ -82,72 +84,120 @@ const ImageUpload: React.FC = ({ 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" > - -

- Drop images here or click to upload -

-

- JPG, PNG, or WEBP — multiple files supported -

+
+ +
+

Drop images here or click to upload

+

JPG, PNG, WEBP — multiple files supported

); } const activeImage = images[activeIndex]; + const activeCropCount = cropsMap?.get(activeImage?.id ?? "")?.length ?? 0; return ( -
+
{/* Active image preview */} -
+
{activeImage?.name} -
+ + {/* Top-left: name + position */} +
+ + {activeImage?.name} + + {images.length > 1 && ( + + {activeIndex + 1}/{images.length} + + )} +
+ + {/* Top-right: crop count badge */} + {activeCropCount > 0 && ( +
+ + {activeCropCount} crop{activeCropCount !== 1 ? "s" : ""} +
+ )} + + {/* Bottom-right: add more */} +
-
- {activeIndex + 1}/{images.length} — {activeImage?.name} -
{/* Thumbnail strip */} {images.length > 1 && ( -
- {images.map((img, i) => ( -
onActiveChange(i)} - > - {img.name} - -
- ))} + {img.name} + + {/* Crop done indicator */} + {processed && ( +
+ +
+ )} + + {/* Remove button — appears on hover */} + + + {/* Index number */} + {i !== activeIndex && ( +
+ {i + 1} +
+ )} +
+ ); + })} + + {/* Add more tile */} +
)}
diff --git a/src/index.css b/src/index.css index 6413ece..54d09b6 100644 --- a/src/index.css +++ b/src/index.css @@ -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%; diff --git a/src/lib/export-zip.ts b/src/lib/export-zip.ts index cba6e52..1e51a7d 100644 --- a/src/lib/export-zip.ts +++ b/src/lib/export-zip.ts @@ -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(); + 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(); 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); diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 20df465..7333fdc 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -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([]); const [cropsMap, setCropsMap] = useState>(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(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 (
-
+ {/* Header */} +
- -

- SmartCrop -

+
+
+ +
+
+

+ SmartCrop +

+

+ AI Image Cropping +

+
+
+ + {/* Workflow steps — hidden on small screens */} +
+ {[ + { n: 1, label: "Upload" }, + { n: 2, label: "Ratios" }, + { n: 3, label: "Generate" }, + { n: 4, label: "Export" }, + ].map(({ n, label }, i, arr) => ( + +
+
n + ? "bg-primary text-primary-foreground" + : step === n + ? "bg-primary/30 text-primary border border-primary" + : "bg-muted text-muted-foreground" + }`} + > + {step > n ? "✓" : n} +
+ = n ? "text-foreground" : "text-muted-foreground" + }`} + > + {label} + +
+ {i < arr.length - 1 && ( + + )} +
+ ))} +
+
@@ -139,110 +233,225 @@ const Index = () => {
-
-
-
- +
+
+ {/* Main content */} +
+ {/* Step 1: Upload */} +
+ 1} label="Upload Images" /> + +
- {activeImage && ( -
- {/* Engine toggle */} -
- + +
+ + {/* Generate current */} + - + {analyzing ? ( + + ) : ( + + )} + {analyzing ? "Analyzing…" : "Generate This Image"} + + + {/* Generate all — only with 2+ images */} + {images.length > 1 && ( + + )} + + {/* Export — shown when crops exist */} + {totalCrops > 0 && ( + <> +
+ + + )}
- - - {totalCrops > 0 && ( - + {/* Hint when ratios not selected */} + {selectedRatios.length === 0 && ( +

+ + Select aspect ratios in the panel on the right first +

)} -
+ + {/* Batch progress */} + {batchProgress && ( +
+ +

+ Processing image {batchProgress.done} of {batchProgress.total}… +

+
+ )} + + {/* Export progress */} + {exporting && ( +
+ +

Building ZIP… {exportProgress}%

+
+ )} + )} - {exporting && } + {/* Step 4: Crops grid */} + {activeImage && ( +
+ {activeCrops.length > 0 ? ( + <> +
+ + + {activeCrops.length} crop{activeCrops.length !== 1 ? "s" : ""} — click to edit + +
+
+ {activeCrops.map((crop, i) => ( + setEditingIndex(i)} + /> + ))} +
+ + ) : ( + /* No crops yet placeholder */ +
+
+ +
+

No crops yet

+

+ {selectedRatios.length === 0 + ? "Select aspect ratios on the right, then click Generate" + : 'Click "Generate This Image" to analyze and crop'} +

+
+ )} +
+ )} - {activeCrops.length > 0 && activeImage && ( -
-

- Crops for "{activeImage.name}" — click to edit -

-
- {activeCrops.map((crop, i) => ( - setEditingIndex(i)} - /> - ))} + {/* Summary bar — when multiple images have crops */} + {processedCount > 0 && images.length > 1 && ( +
+
+ + + {processedCount} / {images.length} images processed +
+
+
+ {totalCrops} total crops +
+ {totalCrops > 0 && ( + <> +
+
+ {totalOutputFiles} output files +
+ + )}
)}
-
@@ -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; +}) => ( +
+
+ {done ? "✓" : step} +
+

+ {label} +

+
+); + export default Index;