Redesign ExtractorDialog with vibe reference workflow and light mode

- Refactor `ExtractorDialog.tsx` into modular stages: `UploadStage`, `AnalyzeStage`, `SelectionStage`, `RefineStage`.
- Implement new UI flow: Upload -> Analyze -> Select -> Refine.
- Adapt "vibe" reference visuals to application's Light Mode theme (Slate/Cyan/Amber).
- Implement real-time status updates for analysis and refinement steps using backend streams.
- Add drag-and-drop and paste support for image upload.
- Visualize candidate selection grid and refinement progress.
This commit is contained in:
google-labs-jules[bot] committed 2026-02-16 07:19:08 +00:00
1 parent 544d93b67b
commit c3752a1c93
20 files changed
+1047 -869

No files matched your search

+29 -29
View File
@@ -6,28 +6,28 @@
[![build-badge](https://img.shields.io/badge/build-wip-yellow)](https://github.com/yourname/forgery) [![license-mit](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
Forge characters, not chaos. `forgery` helps you extract equipments (props, clothing, accessories) from character images, save them as reusable items in a DB, and then generate new character *looks* by applying selected equipments + pose/expression controls using AI image generation.
Forge characters, not chaos. `forgery` helps you extract equipments (props, clothing, accessories) from character images, save them as reusable items in a DB, and then generate new character _looks_ by applying selected equipments + pose/expression controls using AI image generation.
---
## Why Forgery?
* **Modular Assets**: Turn screenshots, cosplay photos, or movie stills into modular assets.
* **Rapid Prototyping**: Quickly prototype many character looks by mixing and matching equipments.
* **Fine-Grained Control**: Add expression & pose control to generated looks for richer results.
* **Local-First & Private**: All data, including your image library and generated results, is stored locally on your machine.
* **Flexible AI Integration**: Seamlessly switch between Google Gemini and OpenAI models for different stages of the pipeline.
- **Modular Assets**: Turn screenshots, cosplay photos, or movie stills into modular assets.
- **Rapid Prototyping**: Quickly prototype many character looks by mixing and matching equipments.
- **Fine-Grained Control**: Add expression & pose control to generated looks for richer results.
- **Local-First & Private**: All data, including your image library and generated results, is stored locally on your machine.
- **Flexible AI Integration**: Seamlessly switch between Google Gemini and OpenAI models for different stages of the pipeline.
---
## Highlights
* 🔍 **Equipment extraction** — detect & crop equipment regions, normalize and store metadata.
* 🧩 **Equipment library** — structured DB with visual preview, masks, tags, anchors, and compatibility hints.
* 🖼️ **Apply equipments** — generate a new image of a character wearing selected equipments.
* 🎭 **Pose & expression controls** — specify expression and pose priors to steer generation.
* 🔌 **Model-agnostic** — plug in your favorite segmentation or image-generation models (OpenAI, Gemini supported out-of-the-box).
* ♻️ **Open-source** — clear data model & contribution guidelines.
- 🔍 **Equipment extraction** — detect & crop equipment regions, normalize and store metadata.
- 🧩 **Equipment library** — structured DB with visual preview, masks, tags, anchors, and compatibility hints.
- 🖼️ **Apply equipments** — generate a new image of a character wearing selected equipments.
- 🎭 **Pose & expression controls** — specify expression and pose priors to steer generation.
- 🔌 **Model-agnostic** — plug in your favorite segmentation or image-generation models (OpenAI, Gemini supported out-of-the-box).
- ♻️ **Open-source** — clear data model & contribution guidelines.
---
@@ -43,11 +43,11 @@ Forge characters, not chaos. `forgery` helps you extract equipments (props, clot
## Core Concepts
* **Equipment** — any wearable or attachable asset extracted from images (hat, glasses, sword, jacket). Stored with mask, anchor points, metadata.
* **Character** — a user-created profile representing a base person/portrait used as the generation canvas.
* **Look** — a generated image (or variant) that applies a set of equipments to a character, optionally altered by pose/expression.
* **Extraction** — computer vision process that detects equipment regions and outputs masks, crops, and suggested anchors.
* **Application/Generation** — image generation pipeline that composes character + selected equipments + conditioning (pose/expression) into a final render.
- **Equipment** — any wearable or attachable asset extracted from images (hat, glasses, sword, jacket). Stored with mask, anchor points, metadata.
- **Character** — a user-created profile representing a base person/portrait used as the generation canvas.
- **Look** — a generated image (or variant) that applies a set of equipments to a character, optionally altered by pose/expression.
- **Extraction** — computer vision process that detects equipment regions and outputs masks, crops, and suggested anchors.
- **Application/Generation** — image generation pipeline that composes character + selected equipments + conditioning (pose/expression) into a final render.
---
@@ -55,9 +55,9 @@ Forge characters, not chaos. `forgery` helps you extract equipments (props, clot
### Requirements
* [Bun](https://bun.sh) (v1.0.0 or later)
* **OR** [Docker](https://www.docker.com/)
* API Keys for OpenAI or Google Gemini (for AI generation)
- [Bun](https://bun.sh) (v1.0.0 or later)
- **OR** [Docker](https://www.docker.com/)
- API Keys for OpenAI or Google Gemini (for AI generation)
### Local Quickstart
@@ -95,17 +95,17 @@ Forgery requires API keys for AI generation services (Google Gemini and/or OpenA
A modular pipeline built on a modern stack:
1. **Frontend** (React + Shadcn UI + Tailwind)
* Upload UI, equipment library browser, look composer (Fitting Room).
- Upload UI, equipment library browser, look composer (Fitting Room).
2. **API Server** (Hono on Bun)
* REST API for extraction, equipment CRUD, character CRUD, generation.
- REST API for extraction, equipment CRUD, character CRUD, generation.
3. **Extraction Service**
* Orchestrates AI models (OpenAI/Gemini) to analyze images and generate masks.
- Orchestrates AI models (OpenAI/Gemini) to analyze images and generate masks.
4. **Asset Store**
* Local file system (`data/files`) for storing optimized WebP images.
- Local file system (`data/files`) for storing optimized WebP images.
5. **Generation Service**
* Composes prompts and conditioning for image generation models.
- Composes prompts and conditioning for image generation models.
6. **DB** (SQLite + Prisma)
* Stores metadata, relationships, settings, and generation history.
- Stores metadata, relationships, settings, and generation history.
---
@@ -113,9 +113,9 @@ A modular pipeline built on a modern stack:
Love forks, fixes, and wild experiments.
* Run tests: `bun test`
* Lint code: `bun run lint`
* Create clear PRs with reproducible steps and small commits.
- Run tests: `bun test`
- Lint code: `bun run lint`
- Create clear PRs with reproducible steps and small commits.
---
+74 -72
View File
@@ -1,63 +1,65 @@
import React, { useState, useCallback } from 'react';
import { UploadStage } from './components/UploadStage';
import { AnalyzeStage } from './components/AnalyzeStage';
import { SplitStage } from './components/SplitStage';
import { RefineStage } from './components/RefineStage';
import { sliceImage } from './utils/imageProcessor';
import { AppStage, ImageSegment } from './types';
import React, { useState, useCallback } from 'react'
import { UploadStage } from './components/UploadStage'
import { AnalyzeStage } from './components/AnalyzeStage'
import { SplitStage } from './components/SplitStage'
import { RefineStage } from './components/RefineStage'
import { sliceImage } from './utils/imageProcessor'
import { AppStage, ImageSegment } from './types'
const App: React.FC = () => {
const [stage, setStage] = useState<AppStage>('upload');
const [uploadedImage, setUploadedImage] = useState<string | null>(null);
const [segments, setSegments] = useState<ImageSegment[]>([]);
const [stage, setStage] = useState<AppStage>('upload')
const [uploadedImage, setUploadedImage] = useState<string | null>(null)
const [segments, setSegments] = useState<ImageSegment[]>([])
// 1. Handle Upload
const handleImageUpload = useCallback((imageDataUrl: string) => {
setUploadedImage(imageDataUrl);
setStage('analyze');
}, []);
setUploadedImage(imageDataUrl)
setStage('analyze')
}, [])
// 2. Handle Analysis Completion
const handleAnalysisComplete = useCallback(async () => {
if (!uploadedImage) return;
if (!uploadedImage) return
try {
// Slice image into 3x3 grid
const slicedImages = await sliceImage(uploadedImage, 3, 3);
const slicedImages = await sliceImage(uploadedImage, 3, 3)
const newSegments: ImageSegment[] = slicedImages.map((src, index) => ({
id: `seg-${index}`,
src,
selected: true, // Default all selected
status: 'pending'
}));
status: 'pending',
}))
setSegments(newSegments);
setStage('split');
setSegments(newSegments)
setStage('split')
} catch (error) {
console.error("Failed to slice image", error);
console.error('Failed to slice image', error)
// Fallback or error handling could go here
}
}, [uploadedImage]);
}, [uploadedImage])
// 3. Handle Split Confirmation
const handleSplitConfirm = useCallback((selectedIds: string[]) => {
// Filter out unselected items for the refine view
const selectedSegments = segments.filter(seg => selectedIds.includes(seg.id));
setSegments(selectedSegments);
setStage('refine');
}, [segments]);
const handleSplitConfirm = useCallback(
(selectedIds: string[]) => {
// Filter out unselected items for the refine view
const selectedSegments = segments.filter((seg) => selectedIds.includes(seg.id))
setSegments(selectedSegments)
setStage('refine')
},
[segments],
)
// 4. Reset
const handleReset = useCallback(() => {
setUploadedImage(null);
setSegments([]);
setStage('upload');
}, []);
setUploadedImage(null)
setSegments([])
setStage('upload')
}, [])
return (
<div className="min-h-screen bg-slate-950 text-slate-100 selection:bg-sky-500/30 selection:text-sky-200 overflow-hidden">
{/* Background Ambience */}
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-purple-900/20 rounded-full blur-[120px]" />
@@ -67,56 +69,56 @@ const App: React.FC = () => {
<main className="relative z-10 w-full h-screen flex flex-col">
{/* Navigation / Header (Minimal) */}
<header className="px-6 py-4 flex items-center justify-between border-b border-white/5 bg-slate-900/50 backdrop-blur-sm">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-gradient-to-tr from-sky-500 to-purple-600 rounded-lg flex items-center justify-center shadow-lg">
<span className="font-bold text-white text-lg">AI</span>
</div>
<h1 className="font-semibold text-lg tracking-tight text-slate-200">Refine<span className="text-sky-400">Lab</span></h1>
</div>
{/* Progress Steps */}
{stage !== 'upload' && (
<div className="hidden sm:flex items-center gap-2 text-xs font-medium bg-black/20 p-1 rounded-full border border-white/5">
<div className={`px-3 py-1 rounded-full transition-colors ${stage === 'analyze' ? 'bg-sky-500/20 text-sky-400' : 'text-slate-500'}`}>Analyze</div>
<div className="w-4 h-[1px] bg-slate-800"></div>
<div className={`px-3 py-1 rounded-full transition-colors ${stage === 'split' ? 'bg-sky-500/20 text-sky-400' : 'text-slate-500'}`}>Split</div>
<div className="w-4 h-[1px] bg-slate-800"></div>
<div className={`px-3 py-1 rounded-full transition-colors ${stage === 'refine' ? 'bg-purple-500/20 text-purple-400' : 'text-slate-500'}`}>Refine</div>
</div>
)}
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-gradient-to-tr from-sky-500 to-purple-600 rounded-lg flex items-center justify-center shadow-lg">
<span className="font-bold text-white text-lg">AI</span>
</div>
<h1 className="font-semibold text-lg tracking-tight text-slate-200">
Refine<span className="text-sky-400">Lab</span>
</h1>
</div>
{/* Progress Steps */}
{stage !== 'upload' && (
<div className="hidden sm:flex items-center gap-2 text-xs font-medium bg-black/20 p-1 rounded-full border border-white/5">
<div
className={`px-3 py-1 rounded-full transition-colors ${stage === 'analyze' ? 'bg-sky-500/20 text-sky-400' : 'text-slate-500'}`}
>
Analyze
</div>
<div className="w-4 h-[1px] bg-slate-800"></div>
<div
className={`px-3 py-1 rounded-full transition-colors ${stage === 'split' ? 'bg-sky-500/20 text-sky-400' : 'text-slate-500'}`}
>
Split
</div>
<div className="w-4 h-[1px] bg-slate-800"></div>
<div
className={`px-3 py-1 rounded-full transition-colors ${stage === 'refine' ? 'bg-purple-500/20 text-purple-400' : 'text-slate-500'}`}
>
Refine
</div>
</div>
)}
</header>
{/* Content Area */}
<div className="flex-1 relative overflow-hidden">
{stage === 'upload' && (
<UploadStage onImageUpload={handleImageUpload} />
)}
{stage === 'upload' && <UploadStage onImageUpload={handleImageUpload} />}
{stage === 'analyze' && uploadedImage && (
<AnalyzeStage
imageSrc={uploadedImage}
onAnalysisComplete={handleAnalysisComplete}
/>
<AnalyzeStage imageSrc={uploadedImage} onAnalysisComplete={handleAnalysisComplete} />
)}
{stage === 'split' && (
<SplitStage
segments={segments}
onConfirm={handleSplitConfirm}
onBack={handleReset}
/>
<SplitStage segments={segments} onConfirm={handleSplitConfirm} onBack={handleReset} />
)}
{stage === 'refine' && (
<RefineStage
initialSegments={segments}
onDone={handleReset}
/>
)}
{stage === 'refine' && <RefineStage initialSegments={segments} onDone={handleReset} />}
</div>
</main>
</div>
);
};
)
}
export default App;
export default App
+1 -2
View File
@@ -10,8 +10,7 @@ View your app in AI Studio: https://ai.studio/apps/drive/1P0JLZhnyDHgV03_9rLIzEi
## Run Locally
**Prerequisites:** Node.js
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
+16 -24
View File
@@ -1,24 +1,24 @@
import React, { useEffect } from 'react';
import { Scan, BrainCircuit } from 'lucide-react';
import React, { useEffect } from 'react'
import { Scan, BrainCircuit } from 'lucide-react'
interface AnalyzeStageProps {
imageSrc: string;
onAnalysisComplete: () => void;
imageSrc: string
onAnalysisComplete: () => void
}
export const AnalyzeStage: React.FC<AnalyzeStageProps> = ({ imageSrc, onAnalysisComplete }) => {
useEffect(() => {
const timer = setTimeout(() => {
onAnalysisComplete();
}, 5000);
onAnalysisComplete()
}, 5000)
return () => clearTimeout(timer);
}, [onAnalysisComplete]);
return () => clearTimeout(timer)
}, [onAnalysisComplete])
return (
<div className="relative flex flex-col items-center justify-center w-full h-full overflow-hidden bg-slate-900">
{/* Background Frosted Glass Layer */}
<div
<div
className="absolute inset-0 w-full h-full z-0 opacity-40 blur-3xl scale-110 pointer-events-none transition-opacity duration-1000"
style={{
backgroundImage: `url(${imageSrc})`,
@@ -26,21 +26,16 @@ export const AnalyzeStage: React.FC<AnalyzeStageProps> = ({ imageSrc, onAnalysis
backgroundSize: 'cover',
}}
/>
{/* Main Container */}
<div className="relative z-10 flex flex-col items-center justify-center w-full max-w-4xl h-[80vh] p-8">
{/* Image Container with Scanning Effect */}
<div className="relative w-full h-full rounded-2xl overflow-hidden shadow-2xl ring-1 ring-white/10 bg-black/40 backdrop-blur-sm group">
<img
src={imageSrc}
alt="Analyzing"
className="w-full h-full object-contain"
/>
<img src={imageSrc} alt="Analyzing" className="w-full h-full object-contain" />
{/* Scan Line Animation */}
<div className="scan-line"></div>
{/* Grid Overlay Effect (Subtle) */}
<div className="absolute inset-0 bg-[linear-gradient(rgba(56,189,248,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(56,189,248,0.03)_1px,transparent_1px)] bg-[size:40px_40px] pointer-events-none"></div>
@@ -54,17 +49,14 @@ export const AnalyzeStage: React.FC<AnalyzeStageProps> = ({ imageSrc, onAnalysis
{/* Status Text */}
<div className="mt-8 flex items-center space-x-3 px-6 py-3 rounded-full bg-slate-800/80 backdrop-blur-md border border-white/10 shadow-xl">
<BrainCircuit className="w-5 h-5 text-sky-400 animate-pulse" />
<span className="text-slate-200 font-medium tracking-wide">
Analyzing Structure...
</span>
<span className="text-slate-200 font-medium tracking-wide">Analyzing Structure...</span>
<div className="flex space-x-1 ml-2">
<div className="w-1.5 h-1.5 rounded-full bg-sky-400 animate-bounce [animation-delay:-0.3s]"></div>
<div className="w-1.5 h-1.5 rounded-full bg-sky-400 animate-bounce [animation-delay:-0.15s]"></div>
<div className="w-1.5 h-1.5 rounded-full bg-sky-400 animate-bounce"></div>
</div>
</div>
</div>
</div>
);
};
)
}
+57 -53
View File
@@ -1,49 +1,49 @@
import React, { useEffect, useState, useRef } from 'react';
import { ImageSegment } from '../types';
import { Loader2, CheckCircle2, Sparkles, RefreshCw } from 'lucide-react';
import React, { useEffect, useState, useRef } from 'react'
import { ImageSegment } from '../types'
import { Loader2, CheckCircle2, Sparkles, RefreshCw } from 'lucide-react'
interface RefineStageProps {
initialSegments: ImageSegment[];
onDone: () => void;
initialSegments: ImageSegment[]
onDone: () => void
}
export const RefineStage: React.FC<RefineStageProps> = ({ initialSegments, onDone }) => {
const [segments, setSegments] = useState<ImageSegment[]>(initialSegments);
const [processingIndex, setProcessingIndex] = useState(0);
const [isAllDone, setIsAllDone] = useState(false);
const processingRef = useRef(false);
const [segments, setSegments] = useState<ImageSegment[]>(initialSegments)
const [processingIndex, setProcessingIndex] = useState(0)
const [isAllDone, setIsAllDone] = useState(false)
const processingRef = useRef(false)
useEffect(() => {
// We only process segments that were selected
const selectedSegments = segments.filter(s => s.selected);
const selectedSegments = segments.filter((s) => s.selected)
if (processingIndex >= selectedSegments.length) {
setIsAllDone(true);
return;
setIsAllDone(true)
return
}
if (processingRef.current) return;
processingRef.current = true;
if (processingRef.current) return
processingRef.current = true
const currentSegmentId = selectedSegments[processingIndex].id;
const currentSegmentId = selectedSegments[processingIndex].id
// Set current to processing
setSegments(prev => prev.map(s =>
s.id === currentSegmentId ? { ...s, status: 'processing' } : s
));
setSegments((prev) =>
prev.map((s) => (s.id === currentSegmentId ? { ...s, status: 'processing' } : s)),
)
const processTimer = setTimeout(() => {
// Mark as completed and move next
setSegments(prev => prev.map(s =>
s.id === currentSegmentId ? { ...s, status: 'completed' } : s
));
setProcessingIndex(prev => prev + 1);
processingRef.current = false;
}, 2000);
setSegments((prev) =>
prev.map((s) => (s.id === currentSegmentId ? { ...s, status: 'completed' } : s)),
)
return () => clearTimeout(processTimer);
}, [processingIndex, segments]);
setProcessingIndex((prev) => prev + 1)
processingRef.current = false
}, 2000)
return () => clearTimeout(processTimer)
}, [processingIndex, segments])
return (
<div className="flex flex-col h-full w-full max-w-6xl mx-auto px-4 py-6">
@@ -55,13 +55,13 @@ export const RefineStage: React.FC<RefineStageProps> = ({ initialSegments, onDon
Refining Images
</h2>
<p className="text-slate-400 text-sm mt-1">
{isAllDone
? "All selected segments have been processed successfully."
: `Processing item ${processingIndex + 1} of ${segments.length}...`}
{isAllDone
? 'All selected segments have been processed successfully.'
: `Processing item ${processingIndex + 1} of ${segments.length}...`}
</p>
</div>
{isAllDone && (
<button
<button
onClick={onDone}
className="flex items-center gap-2 px-6 py-2.5 rounded-lg bg-green-500 hover:bg-green-400 text-white font-medium shadow-lg shadow-green-500/20 transition-all"
>
@@ -75,19 +75,21 @@ export const RefineStage: React.FC<RefineStageProps> = ({ initialSegments, onDon
<div className="flex-1 overflow-y-auto pr-2 pb-10">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
{segments.map((seg) => (
<div
<div
key={seg.id}
className={`
relative aspect-square rounded-2xl overflow-hidden border-2 transition-all duration-500
${seg.status === 'processing'
? 'border-purple-500 shadow-xl shadow-purple-500/20 scale-105 z-10'
: seg.status === 'completed'
? 'border-green-500/50 shadow-md'
: 'border-slate-800 opacity-60 grayscale'}
${
seg.status === 'processing'
? 'border-purple-500 shadow-xl shadow-purple-500/20 scale-105 z-10'
: seg.status === 'completed'
? 'border-green-500/50 shadow-md'
: 'border-slate-800 opacity-60 grayscale'
}
`}
>
<img src={seg.src} alt="segment" className="w-full h-full object-cover" />
{/* Overlay states */}
<div className="absolute inset-0 flex items-center justify-center bg-black/40 backdrop-blur-[2px] transition-opacity duration-300">
{seg.status === 'pending' && (
@@ -95,37 +97,39 @@ export const RefineStage: React.FC<RefineStageProps> = ({ initialSegments, onDon
Pending
</div>
)}
{seg.status === 'processing' && (
<div className="flex flex-col items-center gap-3">
<div className="relative">
<div className="absolute inset-0 bg-purple-500 rounded-full blur-md opacity-50 animate-pulse"></div>
<div className="relative bg-slate-900 rounded-full p-3 border border-purple-500/50">
<Loader2 className="w-8 h-8 text-purple-400 animate-spin" />
</div>
<div className="absolute inset-0 bg-purple-500 rounded-full blur-md opacity-50 animate-pulse"></div>
<div className="relative bg-slate-900 rounded-full p-3 border border-purple-500/50">
<Loader2 className="w-8 h-8 text-purple-400 animate-spin" />
</div>
</div>
<span className="text-purple-200 text-sm font-medium animate-pulse">Refining...</span>
<span className="text-purple-200 text-sm font-medium animate-pulse">
Refining...
</span>
</div>
)}
{seg.status === 'completed' && (
<div className="absolute inset-0 flex items-center justify-center bg-transparent backdrop-blur-none">
{/* Clean view for completed, just a subtle indicator */}
<div className="absolute top-3 right-3 bg-green-500 text-white p-1.5 rounded-full shadow-lg animate-scale-in">
<CheckCircle2 className="w-5 h-5" />
</div>
{/* Clean view for completed, just a subtle indicator */}
<div className="absolute top-3 right-3 bg-green-500 text-white p-1.5 rounded-full shadow-lg animate-scale-in">
<CheckCircle2 className="w-5 h-5" />
</div>
</div>
)}
</div>
{/* Scanning effect for processing item only */}
{seg.status === 'processing' && (
<div className="absolute inset-0 w-full h-full bg-gradient-to-b from-transparent via-purple-500/10 to-transparent -translate-y-full animate-[scan_1.5s_linear_infinite]" />
<div className="absolute inset-0 w-full h-full bg-gradient-to-b from-transparent via-purple-500/10 to-transparent -translate-y-full animate-[scan_1.5s_linear_infinite]" />
)}
</div>
))}
</div>
</div>
</div>
);
};
)
}
+57 -47
View File
@@ -1,29 +1,29 @@
import React, { useState } from 'react';
import { ImageSegment } from '../types';
import { Check, Grid2X2, ArrowRight } from 'lucide-react';
import React, { useState } from 'react'
import { ImageSegment } from '../types'
import { Check, Grid2X2, ArrowRight } from 'lucide-react'
interface SplitStageProps {
segments: ImageSegment[];
onConfirm: (selectedIds: string[]) => void;
onBack: () => void;
segments: ImageSegment[]
onConfirm: (selectedIds: string[]) => void
onBack: () => void
}
export const SplitStage: React.FC<SplitStageProps> = ({ segments, onConfirm, onBack }) => {
const [localSegments, setLocalSegments] = useState<ImageSegment[]>(segments);
const [localSegments, setLocalSegments] = useState<ImageSegment[]>(segments)
const toggleSelection = (id: string) => {
setLocalSegments(prev =>
prev.map(seg => seg.id === id ? { ...seg, selected: !seg.selected } : seg)
);
};
setLocalSegments((prev) =>
prev.map((seg) => (seg.id === id ? { ...seg, selected: !seg.selected } : seg)),
)
}
const selectedCount = localSegments.filter(s => s.selected).length;
const selectedCount = localSegments.filter((s) => s.selected).length
const handleConfirm = () => {
if (selectedCount === 0) return;
const selectedIds = localSegments.filter(s => s.selected).map(s => s.id);
onConfirm(selectedIds);
};
if (selectedCount === 0) return
const selectedIds = localSegments.filter((s) => s.selected).map((s) => s.id)
onConfirm(selectedIds)
}
return (
<div className="flex flex-col h-full w-full max-w-6xl mx-auto px-4 py-6">
@@ -48,32 +48,40 @@ export const SplitStage: React.FC<SplitStageProps> = ({ segments, onConfirm, onB
<div className="flex-1 overflow-y-auto min-h-0 pr-2">
<div className="grid grid-cols-3 gap-4 pb-20">
{localSegments.map((seg) => (
<div
<div
key={seg.id}
onClick={() => toggleSelection(seg.id)}
className={`
group relative aspect-square rounded-xl overflow-hidden cursor-pointer transition-all duration-300
border-2
${seg.selected
? 'border-sky-500 shadow-lg shadow-sky-500/20 scale-[1.01]'
: 'border-slate-700 opacity-60 hover:opacity-100 hover:border-slate-500'}
${
seg.selected
? 'border-sky-500 shadow-lg shadow-sky-500/20 scale-[1.01]'
: 'border-slate-700 opacity-60 hover:opacity-100 hover:border-slate-500'
}
`}
>
<img src={seg.src} alt="segment" className="w-full h-full object-cover" />
{/* Selection Overlay */}
<div className={`
<div
className={`
absolute inset-0 bg-sky-900/20 transition-opacity duration-200
${seg.selected ? 'opacity-100' : 'opacity-0'}
`} />
`}
/>
{/* Checkbox Indicator */}
<div className={`
<div
className={`
absolute top-3 right-3 w-8 h-8 rounded-full flex items-center justify-center transition-all duration-200
${seg.selected
? 'bg-sky-500 text-white shadow-lg scale-100'
: 'bg-black/50 border border-white/30 text-transparent scale-90 group-hover:border-white/60'}
`}>
${
seg.selected
? 'bg-sky-500 text-white shadow-lg scale-100'
: 'bg-black/50 border border-white/30 text-transparent scale-90 group-hover:border-white/60'
}
`}
>
<Check className="w-5 h-5" />
</div>
</div>
@@ -84,27 +92,29 @@ export const SplitStage: React.FC<SplitStageProps> = ({ segments, onConfirm, onB
{/* Footer Actions */}
<div className="fixed bottom-0 left-0 right-0 p-6 bg-slate-900/90 backdrop-blur-md border-t border-slate-800 z-50">
<div className="max-w-6xl mx-auto flex justify-end items-center gap-4">
<button
onClick={onBack}
className="px-6 py-3 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={selectedCount === 0}
className={`
<button
onClick={onBack}
className="px-6 py-3 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={selectedCount === 0}
className={`
flex items-center gap-2 px-8 py-3 rounded-xl font-semibold transition-all shadow-lg
${selectedCount > 0
? 'bg-sky-500 hover:bg-sky-400 text-white shadow-sky-500/25 hover:shadow-sky-500/40 translate-y-0'
: 'bg-slate-800 text-slate-500 cursor-not-allowed'}
${
selectedCount > 0
? 'bg-sky-500 hover:bg-sky-400 text-white shadow-sky-500/25 hover:shadow-sky-500/40 translate-y-0'
: 'bg-slate-800 text-slate-500 cursor-not-allowed'
}
`}
>
Start Refining
<ArrowRight className="w-4 h-4" />
</button>
>
Start Refining
<ArrowRight className="w-4 h-4" />
</button>
</div>
</div>
</div>
);
};
)
}
+51 -52
View File
@@ -1,61 +1,69 @@
import React, { useCallback, useState } from 'react';
import { Upload, Image as ImageIcon } from 'lucide-react';
import { readFileAsDataURL } from '../utils/imageProcessor';
import React, { useCallback, useState } from 'react'
import { Upload, Image as ImageIcon } from 'lucide-react'
import { readFileAsDataURL } from '../utils/imageProcessor'
interface UploadStageProps {
onImageUpload: (imageDataUrl: string) => void;
onImageUpload: (imageDataUrl: string) => void
}
export const UploadStage: React.FC<UploadStageProps> = ({ onImageUpload }) => {
const [isDragging, setIsDragging] = useState(false);
const [isDragging, setIsDragging] = useState(false)
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
e.preventDefault()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
e.preventDefault()
setIsDragging(false)
}, [])
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
const file = e.dataTransfer.files[0];
if (file.type.startsWith('image/')) {
try {
const dataUrl = await readFileAsDataURL(file);
onImageUpload(dataUrl);
} catch (error) {
console.error("Error reading file:", error);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
const file = e.dataTransfer.files[0]
if (file.type.startsWith('image/')) {
try {
const dataUrl = await readFileAsDataURL(file)
onImageUpload(dataUrl)
} catch (error) {
console.error('Error reading file:', error)
}
}
}
}
}, [onImageUpload]);
},
[onImageUpload],
)
const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
try {
const dataUrl = await readFileAsDataURL(e.target.files[0]);
onImageUpload(dataUrl);
} catch (error) {
console.error("Error reading file:", error);
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
try {
const dataUrl = await readFileAsDataURL(e.target.files[0])
onImageUpload(dataUrl)
} catch (error) {
console.error('Error reading file:', error)
}
}
}
}, [onImageUpload]);
},
[onImageUpload],
)
return (
<div className="flex flex-col items-center justify-center h-full w-full p-8 animate-fade-in">
<div
<div
className={`
relative flex flex-col items-center justify-center w-full max-w-2xl h-96
rounded-3xl border-2 border-dashed transition-all duration-300 ease-in-out
${isDragging
? 'border-sky-400 bg-sky-400/10 scale-[1.02]'
: 'border-slate-600 bg-slate-800/50 hover:border-slate-500 hover:bg-slate-800/80'}
${
isDragging
? 'border-sky-400 bg-sky-400/10 scale-[1.02]'
: 'border-slate-600 bg-slate-800/50 hover:border-slate-500 hover:bg-slate-800/80'
}
`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@@ -66,20 +74,13 @@ export const UploadStage: React.FC<UploadStageProps> = ({ onImageUpload }) => {
<Upload className="w-8 h-8 text-sky-400" />
</div>
<div className="space-y-2">
<h3 className="text-xl font-semibold text-slate-200">
Upload an Image
</h3>
<h3 className="text-xl font-semibold text-slate-200">Upload an Image</h3>
<p className="text-sm text-slate-400 max-w-xs mx-auto">
Drag and drop your image here, or click to browse files
</p>
</div>
<label className="cursor-pointer">
<input
type="file"
className="hidden"
accept="image/*"
onChange={handleFileChange}
/>
<input type="file" className="hidden" accept="image/*" onChange={handleFileChange} />
<span className="inline-flex items-center px-6 py-2.5 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-medium transition-colors shadow-lg shadow-sky-500/20">
<ImageIcon className="w-4 h-4 mr-2" />
Select File
@@ -87,9 +88,7 @@ export const UploadStage: React.FC<UploadStageProps> = ({ onImageUpload }) => {
</label>
</div>
</div>
<p className="mt-8 text-slate-500 text-sm">
Supports JPG, PNG, WEBP up to 10MB
</p>
<p className="mt-8 text-slate-500 text-sm">Supports JPG, PNG, WEBP up to 10MB</p>
</div>
);
};
)
}
+29 -19
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
@@ -7,7 +7,7 @@
<script src="https://cdn.tailwindcss.com"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', sans-serif;
background-color: #0f172a; /* Slate 900 */
@@ -31,10 +31,20 @@
/* Scan Animation */
@keyframes scan {
0% { top: 0%; opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { top: 100%; opacity: 0; }
0% {
top: 0%;
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
top: 100%;
opacity: 0;
}
}
.scan-line {
position: absolute;
@@ -47,20 +57,20 @@
z-index: 10;
}
</style>
<script type="importmap">
{
"imports": {
"react/": "https://esm.sh/react@^19.2.4/",
"react": "https://esm.sh/react@^19.2.4",
"lucide-react": "https://esm.sh/lucide-react@^0.564.0",
"react-dom/": "https://esm.sh/react-dom@^19.2.4/"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<script type="importmap">
{
"imports": {
"react/": "https://esm.sh/react@^19.2.4/",
"react": "https://esm.sh/react@^19.2.4",
"lucide-react": "https://esm.sh/lucide-react@^0.564.0",
"react-dom/": "https://esm.sh/react-dom@^19.2.4/"
}
}
</script>
<link rel="stylesheet" href="/index.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>
</html>
+8 -8
View File
@@ -1,15 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
const rootElement = document.getElementById('root');
const rootElement = document.getElementById('root')
if (!rootElement) {
throw new Error("Could not find root element to mount to");
throw new Error('Could not find root element to mount to')
}
const root = ReactDOM.createRoot(rootElement);
const root = ReactDOM.createRoot(rootElement)
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
</React.StrictMode>,
)
+1 -1
View File
@@ -2,4 +2,4 @@
"name": "Image Split & Refine Tool",
"description": "A sophisticated image processing tool that analyzes, splits, and refines images with a modern frosted glass UI.",
"requestFramePermissions": []
}
}
+4 -12
View File
@@ -4,26 +4,18 @@
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"types": [
"node"
],
"types": ["node"],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
"@/*": ["./*"]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}
}
+8 -8
View File
@@ -1,13 +1,13 @@
export type AppStage = 'upload' | 'analyze' | 'split' | 'refine' | 'completed';
export type AppStage = 'upload' | 'analyze' | 'split' | 'refine' | 'completed'
export interface ImageSegment {
id: string;
src: string;
selected: boolean;
status: 'pending' | 'processing' | 'completed';
id: string
src: string
selected: boolean
status: 'pending' | 'processing' | 'completed'
}
export interface ProcessingState {
isProcessing: boolean;
currentId: string | null;
}
isProcessing: boolean
currentId: string | null
}
+35 -31
View File
@@ -5,31 +5,35 @@
* @param cols Number of columns.
* @returns A promise resolving to an array of data URLs for the segments.
*/
export const sliceImage = (imageSrc: string, rows: number = 3, cols: number = 3): Promise<string[]> => {
export const sliceImage = (
imageSrc: string,
rows: number = 3,
cols: number = 3,
): Promise<string[]> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "Anonymous";
const img = new Image()
img.crossOrigin = 'Anonymous'
img.onload = () => {
const segments: string[] = [];
const segmentWidth = Math.floor(img.width / cols);
const segmentHeight = Math.floor(img.height / rows);
const segments: string[] = []
const segmentWidth = Math.floor(img.width / cols)
const segmentHeight = Math.floor(img.height / rows)
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) {
reject(new Error("Could not get canvas context"));
return;
reject(new Error('Could not get canvas context'))
return
}
canvas.width = segmentWidth;
canvas.height = segmentHeight;
canvas.width = segmentWidth
canvas.height = segmentHeight
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
// Clear canvas for next segment
ctx.clearRect(0, 0, segmentWidth, segmentHeight);
ctx.clearRect(0, 0, segmentWidth, segmentHeight)
// Draw the specific slice
ctx.drawImage(
img,
@@ -40,33 +44,33 @@ export const sliceImage = (imageSrc: string, rows: number = 3, cols: number = 3)
0,
0,
segmentWidth,
segmentHeight
);
segmentHeight,
)
segments.push(canvas.toDataURL('image/png'));
segments.push(canvas.toDataURL('image/png'))
}
}
resolve(segments);
};
img.onerror = (err) => reject(err);
img.src = imageSrc;
});
};
resolve(segments)
}
img.onerror = (err) => reject(err)
img.src = imageSrc
})
}
/**
* Reads a file input as a Data URL.
*/
export const readFileAsDataURL = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
const reader = new FileReader()
reader.onload = (e) => {
if (e.target?.result) {
resolve(e.target.result as string);
resolve(e.target.result as string)
} else {
reject(new Error("Failed to read file"));
reject(new Error('Failed to read file'))
}
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}
+20 -20
View File
@@ -1,23 +1,23 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path'
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
const env = loadEnv(mode, '.', '')
return {
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});
},
}
})
+3 -1
View File
@@ -92,7 +92,9 @@ describe('EquipmentService', () => {
await equipmentService.deleteEquipment('1')
expect(mockPrisma.equipment.findUnique).toHaveBeenCalledWith({ where: { id: '1' } })
expect(mockPrisma.generationEquipment.deleteMany).toHaveBeenCalledWith({ where: { equipmentId: '1' } })
expect(mockPrisma.generationEquipment.deleteMany).toHaveBeenCalledWith({
where: { equipmentId: '1' },
})
expect(mockPrisma.equipment.delete).toHaveBeenCalledWith({ where: { id: '1' } })
expect(mockAssetService.deleteAsset).toHaveBeenCalledWith('asset-1')
})
+165 -490
View File
@@ -1,9 +1,12 @@
import { AlertCircle, Check, Loader2 } from 'lucide-react'
import React, { useEffect, useState } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { client } from '../client'
import { CandidateAsset, ExtractedAsset, ExtractionStatus } from '../types'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog'
import { cn } from '../lib/utils'
import { CandidateAsset, ExtractedAsset } from '../types'
import { AnalyzeStage } from './extractor/AnalyzeStage'
import { RefineStage } from './extractor/RefineStage'
import { SelectionStage } from './extractor/SelectionStage'
import { UploadStage } from './extractor/UploadStage'
import { Dialog, DialogContent, DialogTitle } from './ui/dialog'
interface ExtractorDialogProps {
open: boolean
@@ -11,153 +14,147 @@ interface ExtractorDialogProps {
onSuccess: (assets: ExtractedAsset[]) => void
}
interface GridDimensions {
rows: number
cols: number
}
type Stage = 'upload' | 'analyze' | 'selection' | 'refine'
export const ExtractorDialog: React.FC<ExtractorDialogProps> = ({
open,
onOpenChange,
onSuccess,
}) => {
const [stage, setStage] = useState<Stage>('upload')
const [file, setFile] = useState<File | null>(null)
const [preview, setPreview] = useState<string | null>(null)
const [status, setStatus] = useState<ExtractionStatus>('idle')
const [statusMessage, setStatusMessage] = useState<string>('')
const [error, setError] = useState<string | null>(null)
// Intermediate candidates
// Data
const [candidates, setCandidates] = useState<CandidateAsset[]>([])
const [selectedIndices, setSelectedIndices] = useState<number[]>([])
// Final results
const [results, setResults] = useState<ExtractedAsset[]>([])
const [isRefineComplete, setIsRefineComplete] = useState(false)
const [textureSheet, setTextureSheet] = useState<string | null>(null)
const [gridDimensions, setGridDimensions] = useState<GridDimensions | null>(null)
// Refs for cleanup
const analyzeController = useRef<AbortController | null>(null)
const refineController = useRef<AbortController | null>(null)
const processFile = (f: File) => {
setFile(f)
setPreview(URL.createObjectURL(f))
setResults([])
const resetState = () => {
setStage('upload')
setFile(null)
if (preview) URL.revokeObjectURL(preview)
setPreview(null)
setStatusMessage('')
setCandidates([])
setSelectedIndices([])
setError(null)
setStatus('idle')
setTextureSheet(null)
setGridDimensions(null)
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
processFile(e.target.files[0])
}
setResults([])
setIsRefineComplete(false)
analyzeController.current?.abort()
refineController.current?.abort()
}
// Handle dialog close
useEffect(() => {
const handlePaste = (e: ClipboardEvent) => {
if (!open) return
if (e.clipboardData && e.clipboardData.files.length > 0) {
const f = e.clipboardData.files[0]
if (f.type.startsWith('image/')) {
processFile(f)
}
}
if (!open) {
// Small delay to allow animation to finish if needed, or just reset immediately
// resetting immediately might flash content if closing animation plays
// But for now, we reset on close to ensure fresh state next open
const t = setTimeout(resetState, 300)
return () => clearTimeout(t)
}
window.addEventListener('paste', handlePaste)
return () => window.removeEventListener('paste', handlePaste)
}, [open])
const handleAnalyze = async () => {
if (!file) return
const handleImageUpload = (f: File) => {
setFile(f)
setPreview(URL.createObjectURL(f))
setStage('analyze')
}
setCandidates([])
setResults([])
setError(null)
setStatus('analyzing')
setStatusMessage('Starting analysis...')
setTextureSheet(null)
setGridDimensions(null)
// Auto-start analysis when entering analyze stage
useEffect(() => {
let active = true
const analyze = async () => {
if (!file) return
try {
const response = await client.extract.analyze.$post({
form: {
image: file,
},
})
setStatusMessage('Initializing analysis...')
analyzeController.current = new AbortController()
if (!response.ok) throw new Error('Server error')
if (!response.body) throw new Error('No response body')
try {
const response = await client.extract.analyze.$post({
form: { image: file },
})
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
if (!response.ok) throw new Error('Server error')
if (!response.body) throw new Error('No response body')
while (true) {
const { done, value } = await reader.read()
if (done) break
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n\n')
buffer = lines.pop() || ''
while (active) {
const { done, value } = await reader.read()
if (done) break
for (const line of lines) {
if (line.startsWith('event: ')) {
const eventMatch = line.match(/^event: (.+)$/m)
const dataMatch = line.match(/^data: (.+)$/m)
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n\n')
buffer = lines.pop() || ''
if (eventMatch && dataMatch) {
const event = eventMatch[1].trim()
const data = JSON.parse(dataMatch[1])
for (const line of lines) {
if (line.startsWith('event: ')) {
const eventMatch = line.match(/^event: (.+)$/m)
const dataMatch = line.match(/^data: (.+)$/m)
if (event === 'status') {
setStatus(data.status)
setStatusMessage(data.message)
} else if (event === 'texture_generated') {
setTextureSheet(data.image)
setGridDimensions(data.grid)
} else if (event === 'complete') {
setStatus('selection')
setStatusMessage('Select items to refine')
const assets = data.assets as CandidateAsset[]
setCandidates(assets)
// Select all by default
setSelectedIndices(assets.map((_, i) => i))
} else if (event === 'error') {
throw new Error(data.message)
if (eventMatch && dataMatch) {
const event = eventMatch[1].trim()
const data = JSON.parse(dataMatch[1])
if (event === 'status') {
if (active) setStatusMessage(data.message)
} else if (event === 'complete') {
if (active) {
setCandidates(data.assets as CandidateAsset[])
setStage('selection')
}
} else if (event === 'error') {
throw new Error(data.message)
}
}
}
}
}
} catch (e: unknown) {
if (active) {
console.error(e)
const err = e instanceof Error ? e : new Error(String(e))
toast.error(err.message || 'Analysis failed')
setStage('upload')
}
}
} catch (e: unknown) {
console.error(e)
const err = e instanceof Error ? e : new Error(String(e))
setError(err.message || 'Analysis failed')
setStatus('error')
}
if (stage === 'analyze' && file) {
analyze()
}
return () => {
active = false
analyzeController.current?.abort()
}
}, [stage, file])
const handleRefineConfirm = (indices: number[]) => {
setSelectedIndices(indices)
setStage('refine')
handleRefine(indices)
}
const handleRefine = async () => {
if (selectedIndices.length === 0) {
setError('Please select at least one item to refine.')
return
}
const handleRefine = async (indices: number[]) => {
const selectedCandidates = candidates.filter((_, i) => indices.includes(i))
const selectedCandidates = candidates.filter((_, i) => selectedIndices.includes(i))
setStatus('refining')
setStatusMessage('Refining selected assets...')
setResults([])
setError(null)
setIsRefineComplete(false)
setStatusMessage('Starting refinement...')
try {
const response = await client.extract.refine.$post({
json: {
assets: selectedCandidates,
},
json: { assets: selectedCandidates },
})
if (!response.ok) throw new Error('Server error')
@@ -185,14 +182,11 @@ export const ExtractorDialog: React.FC<ExtractorDialogProps> = ({
const data = JSON.parse(dataMatch[1])
if (event === 'status') {
setStatus(data.status)
setStatusMessage(data.message)
} else if (event === 'asset_refined') {
setResults((prev) => [...prev, data.asset])
} else if (event === 'complete') {
setStatus('complete')
setStatusMessage('Refinement complete!')
setResults(data.assets)
setIsRefineComplete(true)
onSuccess(data.assets)
} else if (event === 'error') {
throw new Error(data.message)
@@ -204,397 +198,78 @@ export const ExtractorDialog: React.FC<ExtractorDialogProps> = ({
} catch (e: unknown) {
console.error(e)
const err = e instanceof Error ? e : new Error(String(e))
setError(err.message || 'Refinement failed')
setStatus('error')
toast.error(err.message || 'Refinement failed')
// Don't change stage automatically on error here, let user see what happened or retry?
// For now, staying on refine stage allows them to see partial results.
// But we might want a 'retry' button or back.
}
}
const toggleSelection = (index: number) => {
setSelectedIndices((prev) =>
prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index],
)
}
const handleReset = () => {
setFile(null)
if (preview) URL.revokeObjectURL(preview)
setPreview(null)
setCandidates([])
setResults([])
setSelectedIndices([])
setStatus('idle')
setError(null)
setTextureSheet(null)
setGridDimensions(null)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-y-auto bg-stone-50/50 backdrop-blur-sm border-stone-200 shadow-2xl">
<DialogHeader className="mb-4">
<DialogTitle className="text-2xl font-black text-stone-800 uppercase tracking-tighter">
Equipment Extractor
</DialogTitle>
<DialogDescription className="text-stone-500 font-medium">
Upload a character image to identify and extract equipment sprites.
</DialogDescription>
</DialogHeader>
<DialogContent className="max-w-[95vw] w-full h-[90vh] p-0 overflow-hidden bg-slate-50 border-none shadow-2xl rounded-2xl flex flex-col">
<DialogTitle className="sr-only">Equipment Extractor</DialogTitle>
<div className="space-y-6">
{/* Upload Area */}
{!file ? (
<div className="border-4 border-dashed border-stone-300 bg-white rounded-3xl p-12 text-center hover:border-cyan-400 hover:bg-cyan-50/30 transition-all group relative cursor-pointer shadow-sm">
<input
type="file"
accept="image/*"
onChange={handleFileChange}
className="absolute inset-0 opacity-0 cursor-pointer z-10"
/>
<div className="w-20 h-20 bg-cyan-100 text-cyan-600 rounded-full flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform shadow-inner">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-10 w-10"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
<h2 className="text-2xl font-black text-stone-800 mb-2 uppercase">Upload Source</h2>
<p className="text-stone-500 mb-4 max-w-sm mx-auto">
Drop character image to identify and extract equipment sprites.
</p>
<span className="inline-block px-8 py-4 bg-stone-900 text-white font-bold uppercase tracking-wider rounded-xl shadow-xl hover:bg-stone-800 transition-colors pointer-events-none">
Select Image
</span>
{/* Top Navigation / Branding Bar (Optional, matches App feel) */}
<div className="flex items-center justify-between px-6 py-4 bg-white border-b border-slate-200 shrink-0">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-gradient-to-tr from-cyan-500 to-blue-600 rounded-lg flex items-center justify-center shadow-lg shadow-cyan-500/20">
<span className="font-bold text-white text-lg">AI</span>
</div>
) : (
<div className="space-y-6 animate-fade-in-up">
{/* Staging Area */}
<div className="bg-white rounded-2xl shadow-xl border border-stone-200 p-6 relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-amber-500 to-orange-600"></div>
<h1 className="font-bold text-lg tracking-tight text-slate-800">
Refine<span className="text-cyan-500">Lab</span>
</h1>
</div>
<div className="flex justify-between items-center mb-6 pb-6 border-b border-stone-100">
<div>
<h3 className="font-bold text-stone-700 uppercase tracking-wider flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse"></span>
Staging Area
</h3>
</div>
<button
onClick={handleReset}
className="text-red-500 font-bold text-xs uppercase hover:text-red-700 hover:underline"
>
Remove / Reset
</button>
</div>
<div className="flex flex-col md:flex-row gap-8">
{/* Source Image */}
<div className="w-full md:w-1/3 shrink-0 space-y-4">
<div className="aspect-[3/4] rounded-xl overflow-hidden border-2 border-stone-100 shadow-inner bg-stone-50 relative group">
<img src={preview || ''} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<span className="text-white text-xs font-bold uppercase tracking-widest bg-black/50 px-3 py-1 rounded-full backdrop-blur-sm">
Source
</span>
</div>
</div>
</div>
{/* Generated Texture Sheet (with Grid Overlay) */}
{textureSheet && gridDimensions && (
<div className="w-full md:w-1/3 shrink-0 space-y-4 animate-fade-in">
<div className="aspect-square rounded-xl overflow-hidden border-2 border-amber-200 shadow-inner bg-stone-50 relative group">
<img src={textureSheet} className="w-full h-full object-contain" />
{/* Grid Overlay */}
<div
className="absolute inset-0 pointer-events-none"
style={{
display: 'grid',
gridTemplateRows: `repeat(${gridDimensions.rows}, 1fr)`,
gridTemplateColumns: `repeat(${gridDimensions.cols}, 1fr)`,
}}
>
{Array.from({ length: gridDimensions.rows * gridDimensions.cols }).map(
(_, i) => (
<div
key={i}
className="border border-red-500/30 bg-red-500/5 text-[10px] text-red-500 p-1 font-mono"
>
{i + 1}
</div>
),
)}
</div>
<div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<span className="text-white text-xs font-bold uppercase tracking-widest bg-black/50 px-3 py-1 rounded-full backdrop-blur-sm">
Generated Sheet
</span>
</div>
</div>
</div>
)}
{/* Controls & Status */}
<div className="flex-1 flex flex-col justify-center space-y-6">
{status === 'idle' || status === 'error' ? (
<div className="text-center md:text-left">
<h4 className="text-lg font-bold text-stone-800 mb-2">Ready to Process</h4>
<p className="text-stone-500 text-sm mb-6">
The image is ready for analysis. The system will identify equipment pieces
and extract them as individual assets.
</p>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-100 text-red-600 rounded-xl text-sm flex items-start gap-3 shadow-sm">
<AlertCircle className="w-5 h-5 shrink-0 mt-0.5" />
<div>
<span className="font-bold block mb-1">Extraction Failed</span>
{error}
</div>
</div>
)}
<button
onClick={handleAnalyze}
className="w-full py-4 text-white font-black uppercase tracking-widest text-lg rounded-xl shadow-lg hover:shadow-xl hover:-translate-y-1 transition-all bg-gradient-to-r from-amber-500 to-orange-600 flex items-center justify-center gap-3"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
Begin Extraction
</button>
</div>
) : (
<div className="w-full flex flex-col items-center">
{/* Progress Status */}
{status !== 'selection' && (
<div className="w-full py-8 px-6 bg-stone-50 rounded-xl border border-stone-100 text-center mb-6">
<Loader2 className="w-10 h-10 text-amber-500 animate-spin mx-auto mb-4" />
<h4 className="text-lg font-bold text-stone-800 mb-1">
{statusMessage}
</h4>
<p className="text-xs text-stone-400 font-mono uppercase tracking-widest">
Processing...
</p>
</div>
)}
{/* Selection Step Actions */}
{status === 'selection' && (
<div className="w-full text-center md:text-left">
<h4 className="text-lg font-bold text-stone-800 mb-2">
Review & Select
</h4>
<p className="text-stone-500 text-sm mb-6">
Select the items you want to keep. They will be refined and added to
your collection.
</p>
<button
onClick={handleRefine}
disabled={selectedIndices.length === 0}
className="w-full py-4 text-white font-black uppercase tracking-widest text-lg rounded-xl shadow-lg hover:shadow-xl hover:-translate-y-1 transition-all bg-gradient-to-r from-green-500 to-emerald-600 flex items-center justify-center gap-3 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Check className="w-6 h-6" />
Refine ({selectedIndices.length}) Items
</button>
</div>
)}
</div>
)}
<div className="text-center">
<p className="text-[10px] text-stone-300 font-mono uppercase tracking-widest">
Powered by Gemini 2.0 Flash
</p>
</div>
</div>
</div>
{/* Progress Steps */}
{stage !== 'upload' && (
<div className="hidden sm:flex items-center gap-2 text-xs font-bold bg-slate-100 p-1 rounded-full border border-slate-200">
<div
className={`px-3 py-1 rounded-full transition-colors ${stage === 'analyze' ? 'bg-white text-cyan-600 shadow-sm' : 'text-slate-400'}`}
>
Analyze
</div>
<div className="w-4 h-[1px] bg-slate-300"></div>
<div
className={`px-3 py-1 rounded-full transition-colors ${stage === 'selection' ? 'bg-white text-cyan-600 shadow-sm' : 'text-slate-400'}`}
>
Select
</div>
<div className="w-4 h-[1px] bg-slate-300"></div>
<div
className={`px-3 py-1 rounded-full transition-colors ${stage === 'refine' ? 'bg-white text-amber-600 shadow-sm' : 'text-slate-400'}`}
>
Refine
</div>
{/* Selection Grid */}
{status === 'selection' && candidates.length > 0 && (
<div className="bg-white p-6 rounded-2xl border border-stone-200 shadow-xl animate-fade-in-up delay-100">
<div className="flex justify-between items-center mb-6">
<div>
<h2 className="text-xl font-black text-stone-800 flex items-center gap-2 uppercase tracking-tighter">
<span className="text-amber-500">✦</span> Candidates Found
</h2>
<p className="text-stone-500 text-xs font-bold tracking-[0.2em] uppercase mt-1">
Select items to refine
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => setSelectedIndices(candidates.map((_, i) => i))}
className="text-xs font-bold text-cyan-600 hover:text-cyan-800 uppercase tracking-wider"
>
Select All
</button>
<span className="text-stone-300">|</span>
<button
onClick={() => setSelectedIndices([])}
className="text-xs font-bold text-stone-400 hover:text-stone-600 uppercase tracking-wider"
>
Select None
</button>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4 max-h-[400px] overflow-y-auto custom-scrollbar pr-2">
{candidates.map((item, i) => {
const isSelected = selectedIndices.includes(i)
return (
<div
key={i}
onClick={() => toggleSelection(i)}
className={cn(
'group relative rounded-lg border-2 cursor-pointer transition-all duration-300',
isSelected
? 'bg-stone-50 border-amber-500 shadow-md scale-[1.02]'
: 'bg-white border-stone-100 opacity-60 hover:opacity-100 hover:border-stone-300',
)}
>
{/* Selection Checkbox Overlay */}
<div
className={cn(
'absolute top-2 right-2 z-10 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-colors',
isSelected
? 'bg-amber-500 border-amber-500 text-white'
: 'bg-white/80 border-stone-300 text-transparent',
)}
>
<Check className="w-3.5 h-3.5" strokeWidth={3} />
</div>
<div className="aspect-square p-4 flex items-center justify-center relative overflow-hidden rounded-t-lg">
{/* Checkerboard bg */}
<div
className="absolute inset-0 opacity-5"
style={{
backgroundImage: 'radial-gradient(#000 1px, transparent 1px)',
backgroundSize: '8px 8px',
}}
></div>
<img
src={item.base64}
className="max-w-full max-h-full object-contain drop-shadow-md"
/>
</div>
<div
className={cn(
'p-2 border-t rounded-b-lg transition-colors',
isSelected
? 'bg-white border-amber-100'
: 'bg-stone-50 border-stone-100',
)}
>
<div className="text-xs font-bold text-stone-700 truncate">
{item.name}
</div>
<div className="text-[10px] text-stone-400 truncate uppercase">
{item.category}
</div>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Final Results Grid */}
{(results.length > 0 || status === 'complete') && (
<div className="bg-white p-6 rounded-2xl border border-stone-200 shadow-xl animate-fade-in-up delay-100">
<div className="flex justify-between items-center mb-6">
<div>
<h2 className="text-xl font-black text-stone-800 flex items-center gap-2 uppercase tracking-tighter">
<span className="text-green-500">✦</span> Loot Acquired
</h2>
<p className="text-stone-500 text-xs font-bold tracking-[0.2em] uppercase mt-1">
{results.length} items refined & stashed
</p>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4 max-h-[300px] overflow-y-auto custom-scrollbar pr-2">
{results.map((item, i) => (
<div
key={i}
className="group relative bg-stone-50 rounded-lg border-2 border-stone-200 hover:border-green-400 hover:shadow-lg transition-all duration-300"
>
<div className="aspect-square p-4 flex items-center justify-center relative overflow-hidden rounded-t-lg">
{/* Checkerboard bg */}
<div
className="absolute inset-0 opacity-5"
style={{
backgroundImage: 'radial-gradient(#000 1px, transparent 1px)',
backgroundSize: '8px 8px',
}}
></div>
<img
src={item.imageUrl}
className="max-w-full max-h-full object-contain drop-shadow-md group-hover:scale-110 transition-transform"
/>
{/* Download Overlay */}
<a
href={item.imageUrl}
download={`${item.name}.webp`}
className="absolute inset-0 bg-stone-900/40 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity backdrop-blur-[1px]"
title="Download Asset"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-8 w-8 text-white drop-shadow-md"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</a>
</div>
<div className="p-2 bg-white border-t border-stone-200 rounded-b-lg">
<div className="text-xs font-bold text-stone-700 truncate">
{item.name}
</div>
<div className="text-[10px] text-stone-400 truncate uppercase">
{item.category}
</div>
<div className="w-6 h-1 bg-green-400 rounded-full mt-2"></div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
{/* Content Area */}
<div className="flex-1 overflow-hidden relative">
{stage === 'upload' && <UploadStage onImageUpload={handleImageUpload} />}
{stage === 'analyze' && preview && (
<AnalyzeStage imageSrc={preview} statusMessage={statusMessage} />
)}
{stage === 'selection' && (
<SelectionStage
candidates={candidates}
onConfirm={handleRefineConfirm}
onCancel={() => setStage('upload')}
/>
)}
{stage === 'refine' && (
<RefineStage
selectedCandidates={candidates.filter((_, i) => selectedIndices.includes(i))}
results={results}
isComplete={isRefineComplete}
onDone={resetState}
/>
)}
</div>
</DialogContent>
</Dialog>
)
@@ -0,0 +1,66 @@
import { BrainCircuit } from 'lucide-react'
import React from 'react'
interface AnalyzeStageProps {
imageSrc: string
statusMessage: string
}
export const AnalyzeStage: React.FC<AnalyzeStageProps> = ({ imageSrc, statusMessage }) => {
return (
<div className="relative flex flex-col items-center justify-center w-full h-full overflow-hidden bg-slate-50">
{/* Background Frosted Glass Layer */}
<div
className="absolute inset-0 w-full h-full z-0 opacity-20 blur-3xl scale-110 pointer-events-none transition-opacity duration-1000"
style={{
backgroundImage: `url(${imageSrc})`,
backgroundPosition: 'center',
backgroundSize: 'cover',
}}
/>
{/* Main Container */}
<div className="relative z-10 flex flex-col items-center justify-center w-full max-w-4xl h-[70vh] p-8">
{/* Image Container with Scanning Effect */}
<div className="relative w-full h-full rounded-2xl overflow-hidden shadow-2xl ring-1 ring-slate-200 bg-white/50 backdrop-blur-sm group">
<img src={imageSrc} alt="Analyzing" className="w-full h-full object-contain p-8" />
{/* Scan Line Animation */}
<div className="absolute inset-0 pointer-events-none z-20 overflow-hidden">
<div className="w-full h-[2px] bg-cyan-400 shadow-[0_0_20px_rgba(34,211,238,0.8)] animate-[scanline_2s_linear_infinite]"></div>
</div>
{/* Grid Overlay Effect (Subtle) */}
<div className="absolute inset-0 bg-[linear-gradient(rgba(6,182,212,0.05)_1px,transparent_1px),linear-gradient(90deg,rgba(6,182,212,0.05)_1px,transparent_1px)] bg-[size:40px_40px] pointer-events-none z-10"></div>
{/* Corner Brackets UI */}
<div className="absolute top-4 left-4 w-8 h-8 border-t-2 border-l-2 border-cyan-500 rounded-tl-lg z-20"></div>
<div className="absolute top-4 right-4 w-8 h-8 border-t-2 border-r-2 border-cyan-500 rounded-tr-lg z-20"></div>
<div className="absolute bottom-4 left-4 w-8 h-8 border-b-2 border-l-2 border-cyan-500 rounded-bl-lg z-20"></div>
<div className="absolute bottom-4 right-4 w-8 h-8 border-b-2 border-r-2 border-cyan-500 rounded-br-lg z-20"></div>
</div>
{/* Status Text */}
<div className="mt-8 flex items-center space-x-3 px-6 py-3 rounded-full bg-white/80 backdrop-blur-md border border-slate-200 shadow-xl">
<BrainCircuit className="w-5 h-5 text-cyan-500 animate-pulse" />
<span className="text-slate-700 font-medium tracking-wide">{statusMessage}</span>
<div className="flex space-x-1 ml-2">
<div className="w-1.5 h-1.5 rounded-full bg-cyan-500 animate-bounce [animation-delay:-0.3s]"></div>
<div className="w-1.5 h-1.5 rounded-full bg-cyan-500 animate-bounce [animation-delay:-0.15s]"></div>
<div className="w-1.5 h-1.5 rounded-full bg-cyan-500 animate-bounce"></div>
</div>
</div>
</div>
<style>{`
@keyframes scanline {
0% {
transform: translateY(-100%);
}
100% {
transform: translateY(100vh); /* Adjust based on container height if needed, but 100vh ensures full sweep */
}
}
`}</style>
</div>
)
}
+161
View File
@@ -0,0 +1,161 @@
import { CheckCircle2, Loader2, RefreshCw, Sparkles } from 'lucide-react'
import React from 'react'
import { cn } from '../../lib/utils'
import { CandidateAsset, ExtractedAsset } from '../../types'
interface RefineStageProps {
selectedCandidates: CandidateAsset[]
results: ExtractedAsset[]
isComplete: boolean
onDone: () => void
}
export const RefineStage: React.FC<RefineStageProps> = ({
selectedCandidates,
results,
isComplete,
onDone,
}) => {
return (
<div className="flex flex-col h-full w-full max-w-6xl mx-auto px-4 py-6">
{/* Header */}
<div className="flex items-center justify-between mb-8 animate-fade-in-up">
<div>
<h2 className="text-2xl font-bold text-slate-800 flex items-center gap-2">
<Sparkles className="text-amber-500" />
Refining Assets
</h2>
<p className="text-slate-500 text-sm mt-1">
{isComplete
? 'All selected assets have been refined and saved.'
: `Processing item ${Math.min(results.length + 1, selectedCandidates.length)} of ${selectedCandidates.length}...`}
</p>
</div>
{isComplete && (
<button
onClick={onDone}
className="flex items-center gap-2 px-6 py-2.5 rounded-lg bg-green-500 hover:bg-green-600 text-white font-medium shadow-lg shadow-green-200 transition-all animate-bounce"
>
<RefreshCw className="w-4 h-4" />
Start Over
</button>
)}
</div>
{/* Grid */}
<div className="flex-1 overflow-y-auto pr-2 pb-10 custom-scrollbar">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{selectedCandidates.map((candidate, index) => {
const isFinished = index < results.length
const isProcessing = index === results.length && !isComplete
const isPending = index > results.length
// The asset to display: result if finished, candidate base64 otherwise
const displayImage = isFinished ? results[index].imageUrl : candidate.base64
return (
<div
key={index}
className={cn(
'relative aspect-square rounded-2xl overflow-hidden border-2 transition-all duration-500 bg-white',
isProcessing
? 'border-amber-400 shadow-xl shadow-amber-100 scale-105 z-10'
: isFinished
? 'border-green-400 shadow-sm'
: 'border-slate-200 opacity-60 grayscale',
)}
>
{/* Background Checkerboard */}
<div
className="absolute inset-0 opacity-10 pointer-events-none"
style={{
backgroundImage: 'radial-gradient(#000 1px, transparent 1px)',
backgroundSize: '8px 8px',
}}
></div>
<img
src={displayImage}
alt={candidate.name}
className="w-full h-full object-contain p-4"
/>
{/* Overlay States */}
<div
className={cn(
'absolute inset-0 flex items-center justify-center transition-all duration-300',
isPending ? 'bg-slate-50/50 backdrop-blur-[1px]' : '',
isProcessing ? 'bg-white/60 backdrop-blur-sm' : '',
isFinished ? 'bg-transparent' : '',
)}
>
{isPending && (
<div className="px-3 py-1 rounded-full bg-white text-xs font-bold text-slate-400 border border-slate-200 shadow-sm">
Pending
</div>
)}
{isProcessing && (
<div className="flex flex-col items-center gap-3">
<div className="relative">
<div className="absolute inset-0 bg-amber-400 rounded-full blur-md opacity-30 animate-pulse"></div>
<div className="relative bg-white rounded-full p-3 border border-amber-200 shadow-md">
<Loader2 className="w-8 h-8 text-amber-500 animate-spin" />
</div>
</div>
<span className="text-amber-600 text-sm font-bold animate-pulse">
Refining...
</span>
</div>
)}
{isFinished && (
<div className="absolute top-3 right-3">
<div className="bg-green-500 text-white p-1.5 rounded-full shadow-lg animate-fade-in-up">
<CheckCircle2 className="w-5 h-5" />
</div>
</div>
)}
</div>
{/* Scanning effect for processing item only */}
{isProcessing && (
<div className="absolute inset-0 w-full h-full pointer-events-none overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent via-amber-400 to-transparent shadow-[0_0_15px_rgba(251,191,36,1)] animate-[scanline_1.5s_linear_infinite]"></div>
</div>
)}
{/* Info Bar */}
<div
className={cn(
'absolute bottom-0 left-0 right-0 p-3 border-t transition-colors',
isFinished
? 'bg-green-50/90 border-green-100'
: 'bg-slate-50/90 border-slate-100',
)}
>
<p className="text-xs font-bold text-slate-700 truncate">
{isFinished ? results[index].name : candidate.name}
</p>
<p className="text-[10px] text-slate-400 truncate uppercase">
{candidate.category}
</p>
</div>
</div>
)
})}
</div>
</div>
<style>{`
@keyframes scanline {
0% {
transform: translateY(-100%);
}
100% {
transform: translateY(400px);
}
}
`}</style>
</div>
)
}
@@ -0,0 +1,139 @@
import { ArrowRight, Check, Grid2X2 } from 'lucide-react'
import React, { useState } from 'react'
import { cn } from '../../lib/utils'
import { CandidateAsset } from '../../types'
interface SelectionStageProps {
candidates: CandidateAsset[]
onConfirm: (selectedIndices: number[]) => void
onCancel: () => void
}
export const SelectionStage: React.FC<SelectionStageProps> = ({
candidates,
onConfirm,
onCancel,
}) => {
// Initialize with all selected by default
const [selectedIndices, setSelectedIndices] = useState<number[]>(candidates.map((_, i) => i))
const toggleSelection = (index: number) => {
setSelectedIndices((prev) =>
prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index],
)
}
const handleConfirm = () => {
if (selectedIndices.length === 0) return
onConfirm(selectedIndices)
}
return (
<div className="flex flex-col h-full w-full max-w-6xl mx-auto px-4 py-6">
{/* Header */}
<div className="flex items-center justify-between mb-6 animate-fade-in-up">
<div>
<h2 className="text-2xl font-bold text-slate-800 flex items-center gap-2">
<Grid2X2 className="text-cyan-500" />
Select Candidates
</h2>
<p className="text-slate-500 text-sm mt-1">
Unselect any items you wish to exclude from refinement.
</p>
</div>
<div className="px-4 py-2 bg-white rounded-lg border border-slate-200 shadow-sm flex items-center gap-2">
<span className="text-cyan-600 font-bold text-lg">{selectedIndices.length}</span>
<span className="text-slate-500 text-sm">Selected</span>
</div>
</div>
{/* Grid */}
<div className="flex-1 overflow-y-auto min-h-0 pr-2 custom-scrollbar">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 pb-24">
{candidates.map((item, index) => {
const isSelected = selectedIndices.includes(index)
return (
<div
key={index}
onClick={() => toggleSelection(index)}
className={cn(
'group relative aspect-square rounded-xl overflow-hidden cursor-pointer transition-all duration-300 border-2 bg-white animate-fade-in-up',
isSelected
? 'border-cyan-500 shadow-lg shadow-cyan-100 scale-[1.02]'
: 'border-slate-200 opacity-80 hover:opacity-100 hover:border-slate-300',
)}
style={{ animationDelay: `${index * 50}ms` }}
>
{/* Checkerboard bg */}
<div
className="absolute inset-0 opacity-10 pointer-events-none"
style={{
backgroundImage: 'radial-gradient(#000 1px, transparent 1px)',
backgroundSize: '8px 8px',
}}
></div>
<img
src={item.base64}
alt={item.name}
className="w-full h-full object-contain p-4"
/>
{/* Selection Overlay */}
<div
className={cn(
'absolute inset-0 bg-cyan-900/10 transition-opacity duration-200',
isSelected ? 'opacity-100' : 'opacity-0',
)}
/>
{/* Checkbox Indicator */}
<div
className={cn(
'absolute top-2 right-2 w-6 h-6 rounded-full flex items-center justify-center transition-all duration-200',
isSelected
? 'bg-cyan-500 text-white shadow-md scale-100'
: 'bg-white/80 border border-slate-300 text-transparent scale-90 group-hover:border-slate-400',
)}
>
<Check className="w-4 h-4" strokeWidth={3} />
</div>
{/* Label */}
<div className="absolute bottom-0 left-0 right-0 p-2 bg-white/90 backdrop-blur-sm border-t border-slate-100">
<p className="text-xs font-bold text-slate-700 truncate">{item.name}</p>
<p className="text-[10px] text-slate-400 truncate uppercase">{item.category}</p>
</div>
</div>
)
})}
</div>
</div>
{/* Footer Actions */}
<div className="absolute bottom-0 left-0 right-0 p-6 bg-white/80 backdrop-blur-md border-t border-slate-200 z-50">
<div className="max-w-6xl mx-auto flex justify-end items-center gap-4">
<button
onClick={onCancel}
className="px-6 py-3 rounded-xl text-slate-500 font-medium hover:text-slate-800 hover:bg-slate-100 transition-colors"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={selectedIndices.length === 0}
className={cn(
'flex items-center gap-2 px-8 py-3 rounded-xl font-bold uppercase tracking-wide transition-all shadow-lg',
selectedIndices.length > 0
? 'bg-gradient-to-r from-cyan-500 to-cyan-600 text-white shadow-cyan-200 hover:shadow-cyan-300 hover:-translate-y-0.5'
: 'bg-slate-200 text-slate-400 cursor-not-allowed',
)}
>
Start Refining
<ArrowRight className="w-5 h-5" />
</button>
</div>
</div>
</div>
)
}
+123
View File
@@ -0,0 +1,123 @@
import { Image as ImageIcon, Upload } from 'lucide-react'
import React, { useCallback, useState } from 'react'
import { cn } from '../../lib/utils'
interface UploadStageProps {
onImageUpload: (file: File) => void
}
export const UploadStage: React.FC<UploadStageProps> = ({ onImageUpload }) => {
const [isDragging, setIsDragging] = useState(false)
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
const file = e.dataTransfer.files[0]
if (file.type.startsWith('image/')) {
onImageUpload(file)
}
}
},
[onImageUpload],
)
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
onImageUpload(e.target.files[0])
}
},
[onImageUpload],
)
React.useEffect(() => {
const handlePaste = (e: ClipboardEvent) => {
if (e.clipboardData && e.clipboardData.files.length > 0) {
const file = e.clipboardData.files[0]
if (file.type.startsWith('image/')) {
onImageUpload(file)
}
}
}
window.addEventListener('paste', handlePaste)
return () => window.removeEventListener('paste', handlePaste)
}, [onImageUpload])
return (
<div className="flex flex-col items-center justify-center h-full w-full p-8 animate-fade-in-up">
<div
className={cn(
'relative flex flex-col items-center justify-center w-full max-w-2xl h-96 rounded-3xl border-2 border-dashed transition-all duration-300 ease-in-out cursor-pointer group',
isDragging
? 'border-cyan-500 bg-cyan-50 scale-[1.02]'
: 'border-slate-300 bg-slate-50 hover:border-cyan-400 hover:bg-slate-100',
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => document.getElementById('file-upload')?.click()}
>
<div className="flex flex-col items-center justify-center space-y-4 text-center p-6 pointer-events-none">
<div
className={cn(
'p-4 rounded-full ring-1 transition-all duration-300',
isDragging
? 'bg-cyan-100 ring-cyan-200'
: 'bg-white ring-slate-200 group-hover:ring-cyan-200 group-hover:bg-cyan-50',
)}
>
<Upload
className={cn(
'w-8 h-8 transition-colors duration-300',
isDragging ? 'text-cyan-600' : 'text-slate-400 group-hover:text-cyan-500',
)}
/>
</div>
<div className="space-y-2">
<h3 className="text-xl font-bold text-slate-700 group-hover:text-slate-900 transition-colors">
Upload an Image
</h3>
<p className="text-sm text-slate-500 max-w-xs mx-auto group-hover:text-slate-600 transition-colors">
Drag and drop your image here, or click to browse files
</p>
</div>
<div className="pt-4">
<span
className={cn(
'inline-flex items-center px-6 py-2.5 rounded-xl font-medium transition-all shadow-sm',
isDragging
? 'bg-cyan-600 text-white shadow-cyan-200'
: 'bg-white text-slate-700 border border-slate-200 group-hover:border-cyan-200 group-hover:text-cyan-600 group-hover:shadow-md',
)}
>
<ImageIcon className="w-4 h-4 mr-2" />
Select File
</span>
</div>
</div>
<input
id="file-upload"
type="file"
className="hidden"
accept="image/*"
onChange={handleFileChange}
/>
</div>
<p className="mt-8 text-slate-400 text-sm font-medium">Supports JPG, PNG, WEBP up to 10MB</p>
</div>
)
}