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:
1 parent
544d93b67b
commit
c3752a1c93
20 files changed
+1047
-869
No files matched your search
@@ -6,28 +6,28 @@
|
||||
|
||||
[](https://github.com/yourname/forgery) [](./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
@@ -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
|
||||
@@ -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`
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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, '.'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user