Merge pull request #36 from Yiling-J/redesign-extractor-ui-vibe-60020766140160295

Redesign ExtractorDialog UI
This commit is contained in:
Yiling-J authored and GitHub committed 2026-02-16 16:46:47 +08:00
commit 123cc2cccd
22 files changed
+638 -1306

No files matched your search

+29 -29
View File
@@ -6,28 +6,28 @@
[![build-badge](https://img.shields.io/badge/build-wip-yellow)](https://github.com/yourname/forgery) [![license-mit](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
Forge characters, not chaos. `forgery` helps you extract equipments (props, clothing, accessories) from character images, save them as reusable items in a DB, and then generate new character *looks* by applying selected equipments + pose/expression controls using AI image generation.
Forge characters, not chaos. `forgery` helps you extract equipments (props, clothing, accessories) from character images, save them as reusable items in a DB, and then generate new character _looks_ by applying selected equipments + pose/expression controls using AI image generation.
---
## Why Forgery?
* **Modular Assets**: Turn screenshots, cosplay photos, or movie stills into modular assets.
* **Rapid Prototyping**: Quickly prototype many character looks by mixing and matching equipments.
* **Fine-Grained Control**: Add expression & pose control to generated looks for richer results.
* **Local-First & Private**: All data, including your image library and generated results, is stored locally on your machine.
* **Flexible AI Integration**: Seamlessly switch between Google Gemini and OpenAI models for different stages of the pipeline.
- **Modular Assets**: Turn screenshots, cosplay photos, or movie stills into modular assets.
- **Rapid Prototyping**: Quickly prototype many character looks by mixing and matching equipments.
- **Fine-Grained Control**: Add expression & pose control to generated looks for richer results.
- **Local-First & Private**: All data, including your image library and generated results, is stored locally on your machine.
- **Flexible AI Integration**: Seamlessly switch between Google Gemini and OpenAI models for different stages of the pipeline.
---
## Highlights
* 🔍 **Equipment extraction** — detect & crop equipment regions, normalize and store metadata.
* 🧩 **Equipment library** — structured DB with visual preview, masks, tags, anchors, and compatibility hints.
* 🖼️ **Apply equipments** — generate a new image of a character wearing selected equipments.
* 🎭 **Pose & expression controls** — specify expression and pose priors to steer generation.
* 🔌 **Model-agnostic** — plug in your favorite segmentation or image-generation models (OpenAI, Gemini supported out-of-the-box).
* ♻️ **Open-source** — clear data model & contribution guidelines.
- 🔍 **Equipment extraction** — detect & crop equipment regions, normalize and store metadata.
- 🧩 **Equipment library** — structured DB with visual preview, masks, tags, anchors, and compatibility hints.
- 🖼️ **Apply equipments** — generate a new image of a character wearing selected equipments.
- 🎭 **Pose & expression controls** — specify expression and pose priors to steer generation.
- 🔌 **Model-agnostic** — plug in your favorite segmentation or image-generation models (OpenAI, Gemini supported out-of-the-box).
- ♻️ **Open-source** — clear data model & contribution guidelines.
---
@@ -43,11 +43,11 @@ Forge characters, not chaos. `forgery` helps you extract equipments (props, clot
## Core Concepts
* **Equipment** — any wearable or attachable asset extracted from images (hat, glasses, sword, jacket). Stored with mask, anchor points, metadata.
* **Character** — a user-created profile representing a base person/portrait used as the generation canvas.
* **Look** — a generated image (or variant) that applies a set of equipments to a character, optionally altered by pose/expression.
* **Extraction** — computer vision process that detects equipment regions and outputs masks, crops, and suggested anchors.
* **Application/Generation** — image generation pipeline that composes character + selected equipments + conditioning (pose/expression) into a final render.
- **Equipment** — any wearable or attachable asset extracted from images (hat, glasses, sword, jacket). Stored with mask, anchor points, metadata.
- **Character** — a user-created profile representing a base person/portrait used as the generation canvas.
- **Look** — a generated image (or variant) that applies a set of equipments to a character, optionally altered by pose/expression.
- **Extraction** — computer vision process that detects equipment regions and outputs masks, crops, and suggested anchors.
- **Application/Generation** — image generation pipeline that composes character + selected equipments + conditioning (pose/expression) into a final render.
---
@@ -55,9 +55,9 @@ Forge characters, not chaos. `forgery` helps you extract equipments (props, clot
### Requirements
* [Bun](https://bun.sh) (v1.0.0 or later)
* **OR** [Docker](https://www.docker.com/)
* API Keys for OpenAI or Google Gemini (for AI generation)
- [Bun](https://bun.sh) (v1.0.0 or later)
- **OR** [Docker](https://www.docker.com/)
- API Keys for OpenAI or Google Gemini (for AI generation)
### Local Quickstart
@@ -95,17 +95,17 @@ Forgery requires API keys for AI generation services (Google Gemini and/or OpenA
A modular pipeline built on a modern stack:
1. **Frontend** (React + Shadcn UI + Tailwind)
* Upload UI, equipment library browser, look composer (Fitting Room).
- Upload UI, equipment library browser, look composer (Fitting Room).
2. **API Server** (Hono on Bun)
* REST API for extraction, equipment CRUD, character CRUD, generation.
- REST API for extraction, equipment CRUD, character CRUD, generation.
3. **Extraction Service**
* Orchestrates AI models (OpenAI/Gemini) to analyze images and generate masks.
- Orchestrates AI models (OpenAI/Gemini) to analyze images and generate masks.
4. **Asset Store**
* Local file system (`data/files`) for storing optimized WebP images.
- Local file system (`data/files`) for storing optimized WebP images.
5. **Generation Service**
* Composes prompts and conditioning for image generation models.
- Composes prompts and conditioning for image generation models.
6. **DB** (SQLite + Prisma)
* Stores metadata, relationships, settings, and generation history.
- Stores metadata, relationships, settings, and generation history.
---
@@ -113,9 +113,9 @@ A modular pipeline built on a modern stack:
Love forks, fixes, and wild experiments.
* Run tests: `bun test`
* Lint code: `bun run lint`
* Create clear PRs with reproducible steps and small commits.
- Run tests: `bun test`
- Lint code: `bun run lint`
- Create clear PRs with reproducible steps and small commits.
---
-24
View File
@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
-122
View File
@@ -1,122 +0,0 @@
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[]>([]);
// 1. Handle Upload
const handleImageUpload = useCallback((imageDataUrl: string) => {
setUploadedImage(imageDataUrl);
setStage('analyze');
}, []);
// 2. Handle Analysis Completion
const handleAnalysisComplete = useCallback(async () => {
if (!uploadedImage) return;
try {
// Slice image into 3x3 grid
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'
}));
setSegments(newSegments);
setStage('split');
} catch (error) {
console.error("Failed to slice image", error);
// Fallback or error handling could go here
}
}, [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]);
// 4. Reset
const handleReset = useCallback(() => {
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]" />
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-sky-900/20 rounded-full blur-[120px]" />
</div>
<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>
)}
</header>
{/* Content Area */}
<div className="flex-1 relative overflow-hidden">
{stage === 'upload' && (
<UploadStage onImageUpload={handleImageUpload} />
)}
{stage === 'analyze' && uploadedImage && (
<AnalyzeStage
imageSrc={uploadedImage}
onAnalysisComplete={handleAnalysisComplete}
/>
)}
{stage === 'split' && (
<SplitStage
segments={segments}
onConfirm={handleSplitConfirm}
onBack={handleReset}
/>
)}
{stage === 'refine' && (
<RefineStage
initialSegments={segments}
onDone={handleReset}
/>
)}
</div>
</main>
</div>
);
};
export default App;
-20
View File
@@ -1,20 +0,0 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1P0JLZhnyDHgV03_9rLIzEixF3AY0-NQU
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`
@@ -1,70 +0,0 @@
import React, { useEffect } from 'react';
import { Scan, BrainCircuit } from 'lucide-react';
interface AnalyzeStageProps {
imageSrc: string;
onAnalysisComplete: () => void;
}
export const AnalyzeStage: React.FC<AnalyzeStageProps> = ({ imageSrc, onAnalysisComplete }) => {
useEffect(() => {
const timer = setTimeout(() => {
onAnalysisComplete();
}, 5000);
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
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})`,
backgroundPosition: 'center',
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"
/>
{/* 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>
{/* Corner Brackets UI */}
<div className="absolute top-4 left-4 w-8 h-8 border-t-2 border-l-2 border-sky-500/50 rounded-tl-lg"></div>
<div className="absolute top-4 right-4 w-8 h-8 border-t-2 border-r-2 border-sky-500/50 rounded-tr-lg"></div>
<div className="absolute bottom-4 left-4 w-8 h-8 border-b-2 border-l-2 border-sky-500/50 rounded-bl-lg"></div>
<div className="absolute bottom-4 right-4 w-8 h-8 border-b-2 border-r-2 border-sky-500/50 rounded-br-lg"></div>
</div>
{/* 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>
<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,131 +0,0 @@
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;
}
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);
useEffect(() => {
// We only process segments that were selected
const selectedSegments = segments.filter(s => s.selected);
if (processingIndex >= selectedSegments.length) {
setIsAllDone(true);
return;
}
if (processingRef.current) return;
processingRef.current = true;
const currentSegmentId = selectedSegments[processingIndex].id;
// Set current to processing
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);
return () => clearTimeout(processTimer);
}, [processingIndex, segments]);
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">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<Sparkles className="text-purple-400" />
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}...`}
</p>
</div>
{isAllDone && (
<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"
>
<RefreshCw className="w-4 h-4" />
Start Over
</button>
)}
</div>
{/* Grid */}
<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
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'}
`}
>
<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' && (
<div className="px-3 py-1 rounded-full bg-slate-900/80 text-xs text-slate-400 border border-white/10">
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>
<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>
</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>
))}
</div>
</div>
</div>
);
};
-110
View File
@@ -1,110 +0,0 @@
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;
}
export const SplitStage: React.FC<SplitStageProps> = ({ segments, onConfirm, onBack }) => {
const [localSegments, setLocalSegments] = useState<ImageSegment[]>(segments);
const toggleSelection = (id: string) => {
setLocalSegments(prev =>
prev.map(seg => seg.id === id ? { ...seg, selected: !seg.selected } : seg)
);
};
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);
};
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">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<Grid2X2 className="text-sky-400" />
Select Segments
</h2>
<p className="text-slate-400 text-sm mt-1">
Unselect any areas you wish to exclude from processing.
</p>
</div>
<div className="px-4 py-2 bg-slate-800 rounded-lg border border-slate-700">
<span className="text-sky-400 font-bold text-lg">{selectedCount}</span>
<span className="text-slate-400 ml-2 text-sm">Selected</span>
</div>
</div>
{/* Grid */}
<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
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'}
`}
>
<img src={seg.src} alt="segment" className="w-full h-full object-cover" />
{/* Selection Overlay */}
<div className={`
absolute inset-0 bg-sky-900/20 transition-opacity duration-200
${seg.selected ? 'opacity-100' : 'opacity-0'}
`} />
{/* Checkbox Indicator */}
<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'}
`}>
<Check className="w-5 h-5" />
</div>
</div>
))}
</div>
</div>
{/* 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={`
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'}
`}
>
Start Refining
<ArrowRight className="w-4 h-4" />
</button>
</div>
</div>
</div>
);
};
@@ -1,95 +0,0 @@
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;
}
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(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]);
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]);
return (
<div className="flex flex-col items-center justify-center h-full w-full p-8 animate-fade-in">
<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'}
`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="flex flex-col items-center justify-center space-y-4 text-center p-6">
<div className="p-4 rounded-full bg-slate-700/50 ring-1 ring-white/10">
<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>
<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}
/>
<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
</span>
</label>
</div>
</div>
<p className="mt-8 text-slate-500 text-sm">
Supports JPG, PNG, WEBP up to 10MB
</p>
</div>
);
};
-66
View File
@@ -1,66 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Image Refine Tool</title>
<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 */
color: #f8fafc;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #475569;
}
/* Scan Animation */
@keyframes scan {
0% { top: 0%; opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { top: 100%; opacity: 0; }
}
.scan-line {
position: absolute;
width: 100%;
height: 4px;
background: linear-gradient(90deg, transparent, #38bdf8, transparent);
box-shadow: 0 0 15px #38bdf8;
animation: scan 2.5s linear infinite;
left: 0;
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>
<body>
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>
-15
View File
@@ -1,15 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
-5
View File
@@ -1,5 +0,0 @@
{
"name": "Image Split & Refine Tool",
"description": "A sophisticated image processing tool that analyzes, splits, and refines images with a modern frosted glass UI.",
"requestFramePermissions": []
}
-22
View File
@@ -1,22 +0,0 @@
{
"name": "image-split-&-refine-tool",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.4",
"lucide-react": "^0.564.0",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}
-29
View File
@@ -1,29 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}
-13
View File
@@ -1,13 +0,0 @@
export type AppStage = 'upload' | 'analyze' | 'split' | 'refine' | 'completed';
export interface ImageSegment {
id: string;
src: string;
selected: boolean;
status: 'pending' | 'processing' | 'completed';
}
export interface ProcessingState {
isProcessing: boolean;
currentId: string | null;
}
-72
View File
@@ -1,72 +0,0 @@
/**
* Slices an image into a grid of smaller images.
* @param imageSrc The source image data URL.
* @param rows Number of rows.
* @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[]> => {
return new Promise((resolve, reject) => {
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 canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error("Could not get canvas context"));
return;
}
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);
// Draw the specific slice
ctx.drawImage(
img,
x * segmentWidth,
y * segmentHeight,
segmentWidth,
segmentHeight,
0,
0,
segmentWidth,
segmentHeight
);
segments.push(canvas.toDataURL('image/png'));
}
}
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();
reader.onload = (e) => {
if (e.target?.result) {
resolve(e.target.result as string);
} else {
reject(new Error("Failed to read file"));
}
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
-23
View File
@@ -1,23 +0,0 @@
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',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});
+3 -1
View File
@@ -92,7 +92,9 @@ describe('EquipmentService', () => {
await equipmentService.deleteEquipment('1')
expect(mockPrisma.equipment.findUnique).toHaveBeenCalledWith({ where: { id: '1' } })
expect(mockPrisma.generationEquipment.deleteMany).toHaveBeenCalledWith({ where: { equipmentId: '1' } })
expect(mockPrisma.generationEquipment.deleteMany).toHaveBeenCalledWith({
where: { equipmentId: '1' },
})
expect(mockPrisma.equipment.delete).toHaveBeenCalledWith({ where: { id: '1' } })
expect(mockAssetService.deleteAsset).toHaveBeenCalledWith('asset-1')
})
+94 -459
View File
@@ -1,9 +1,12 @@
import { AlertCircle, Check, Loader2 } from 'lucide-react'
import React, { useEffect, useState } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { client } from '../client'
import { CandidateAsset, ExtractedAsset, ExtractionStatus } from '../types'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog'
import { cn } from '../lib/utils'
import { CandidateAsset, ExtractedAsset } from '../types'
import { AnalyzeStage } from './extractor/AnalyzeStage'
import { RefineStage } from './extractor/RefineStage'
import { SelectionStage } from './extractor/SelectionStage'
import { UploadStage } from './extractor/UploadStage'
import { Dialog, DialogContent, DialogTitle } from './ui/dialog'
interface ExtractorDialogProps {
open: boolean
@@ -11,81 +14,68 @@ 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 () => {
const handleImageUpload = (f: File) => {
setFile(f)
setPreview(URL.createObjectURL(f))
setStage('analyze')
}
const startAnalysis = async () => {
if (!file) return
setCandidates([])
setResults([])
setError(null)
setStatus('analyzing')
setStatusMessage('Starting analysis...')
setTextureSheet(null)
setGridDimensions(null)
setStatusMessage('Initializing analysis...')
analyzeController.current = new AbortController()
try {
const response = await client.extract.analyze.$post({
form: {
image: file,
},
form: { image: file },
})
if (!response.ok) throw new Error('Server error')
@@ -113,18 +103,10 @@ export const ExtractorDialog: React.FC<ExtractorDialogProps> = ({
const data = JSON.parse(dataMatch[1])
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))
setCandidates(data.assets as CandidateAsset[])
setStage('selection')
} else if (event === 'error') {
throw new Error(data.message)
}
@@ -135,29 +117,27 @@ 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 || 'Analysis failed')
setStatus('error')
toast.error(err.message || 'Analysis failed')
setStage('upload')
}
}
const handleRefine = async () => {
if (selectedIndices.length === 0) {
setError('Please select at least one item to refine.')
return
}
const handleRefineConfirm = (indices: number[]) => {
setSelectedIndices(indices)
setStage('refine')
handleRefine(indices)
}
const selectedCandidates = candidates.filter((_, i) => selectedIndices.includes(i))
const handleRefine = async (indices: number[]) => {
const selectedCandidates = candidates.filter((_, i) => indices.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 +165,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,395 +181,53 @@ 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-[98vw] w-full h-[95vh] p-0 overflow-hidden bg-slate-50 border-none shadow-2xl rounded-2xl flex flex-col">
<DialogTitle className="sr-only">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>
</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>
{/* 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">
<h1 className="font-bold text-lg tracking-tight text-slate-800">Extractor</h1>
</div>
</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>
{/* Content Area */}
<div className="flex-1 overflow-hidden relative">
{stage === 'upload' && <UploadStage onImageUpload={handleImageUpload} />}
<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>
{stage === 'analyze' && preview && (
<AnalyzeStage
imageSrc={preview}
statusMessage={statusMessage}
isAnalyzing={!!statusMessage}
onStart={startAnalysis}
/>
)}
{/* 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" />
{stage === 'selection' && (
<SelectionStage
candidates={candidates}
onConfirm={handleRefineConfirm}
onCancel={() => setStage('upload')}
/>
)}
{/* 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>
</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>
{stage === 'refine' && (
<RefineStage
selectedCandidates={candidates.filter((_, i) => selectedIndices.includes(i))}
results={results}
isComplete={isRefineComplete}
onDone={resetState}
/>
)}
</div>
</DialogContent>
@@ -0,0 +1,89 @@
import { BrainCircuit, Play } from 'lucide-react'
import React from 'react'
import { Button } from '../ui/button'
interface AnalyzeStageProps {
imageSrc: string
statusMessage: string
isAnalyzing: boolean
onStart: () => void
}
export const AnalyzeStage: React.FC<AnalyzeStageProps> = ({
imageSrc,
statusMessage,
isAnalyzing,
onStart,
}) => {
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 (only when analyzing) */}
{isAnalyzing && (
<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 or Action Button */}
<div className="mt-8 flex items-center justify-center">
{isAnalyzing ? (
<div className="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>
) : (
<Button
size="lg"
className="px-8 py-6 rounded-full text-lg font-bold shadow-xl shadow-cyan-500/20 hover:shadow-cyan-500/40 hover:-translate-y-1 transition-all"
onClick={onStart}
>
<Play className="w-5 h-5 mr-2 fill-current" />
Start Extraction
</Button>
)}
</div>
</div>
<style>{`
@keyframes scanline {
0% {
transform: translateY(-100%);
}
100% {
transform: translateY(100vh);
}
}
`}</style>
</div>
)
}
+161
View File
@@ -0,0 +1,161 @@
import { CheckCircle2, Loader2, RefreshCw, Sparkles } from 'lucide-react'
import React from 'react'
import { cn } from '../../lib/utils'
import { CandidateAsset, ExtractedAsset } from '../../types'
interface RefineStageProps {
selectedCandidates: CandidateAsset[]
results: ExtractedAsset[]
isComplete: boolean
onDone: () => void
}
export const RefineStage: React.FC<RefineStageProps> = ({
selectedCandidates,
results,
isComplete,
onDone,
}) => {
return (
<div className="flex flex-col h-full w-full max-w-6xl mx-auto px-4 py-6">
{/* Header */}
<div className="flex items-center justify-between mb-8 animate-fade-in-up">
<div>
<h2 className="text-2xl font-bold text-slate-800 flex items-center gap-2">
<Sparkles className="text-amber-500" />
Refining Assets
</h2>
<p className="text-slate-500 text-sm mt-1">
{isComplete
? 'All selected assets have been refined and saved.'
: `Processing item ${Math.min(results.length + 1, selectedCandidates.length)} of ${selectedCandidates.length}...`}
</p>
</div>
{isComplete && (
<button
onClick={onDone}
className="flex items-center gap-2 px-6 py-2.5 rounded-lg bg-green-500 hover:bg-green-600 text-white font-medium shadow-lg shadow-green-200 transition-all animate-bounce"
>
<RefreshCw className="w-4 h-4" />
Start Over
</button>
)}
</div>
{/* Grid */}
<div className="flex-1 overflow-y-auto pr-2 pb-10 custom-scrollbar">
<div className="flex flex-wrap gap-6 justify-center">
{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 w-[160px] h-[160px] rounded-2xl overflow-hidden border-2 transition-all duration-500 bg-white flex-shrink-0',
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="flex flex-wrap gap-4 pb-24 justify-center">
{candidates.map((item, index) => {
const isSelected = selectedIndices.includes(index)
return (
<div
key={index}
onClick={() => toggleSelection(index)}
className={cn(
'group relative w-[140px] h-[140px] rounded-xl overflow-hidden cursor-pointer transition-all duration-300 border-2 bg-white animate-fade-in-up flex-shrink-0',
isSelected
? 'border-cyan-500 shadow-lg shadow-cyan-100 scale-[1.02]'
: 'border-slate-200 opacity-80 hover:opacity-100 hover:border-slate-300',
)}
style={{ animationDelay: `${index * 50}ms` }}
>
{/* Checkerboard bg */}
<div
className="absolute inset-0 opacity-10 pointer-events-none"
style={{
backgroundImage: 'radial-gradient(#000 1px, transparent 1px)',
backgroundSize: '8px 8px',
}}
></div>
<img
src={item.base64}
alt={item.name}
className="w-full h-full object-contain p-4"
/>
{/* Selection Overlay */}
<div
className={cn(
'absolute inset-0 bg-cyan-900/10 transition-opacity duration-200',
isSelected ? 'opacity-100' : 'opacity-0',
)}
/>
{/* Checkbox Indicator */}
<div
className={cn(
'absolute top-2 right-2 w-6 h-6 rounded-full flex items-center justify-center transition-all duration-200',
isSelected
? 'bg-cyan-500 text-white shadow-md scale-100'
: 'bg-white/80 border border-slate-300 text-transparent scale-90 group-hover:border-slate-400',
)}
>
<Check className="w-4 h-4" strokeWidth={3} />
</div>
{/* Label */}
<div className="absolute bottom-0 left-0 right-0 p-2 bg-white/90 backdrop-blur-sm border-t border-slate-100">
<p className="text-xs font-bold text-slate-700 truncate">{item.name}</p>
<p className="text-[10px] text-slate-400 truncate uppercase">{item.category}</p>
</div>
</div>
)
})}
</div>
</div>
{/* Footer Actions */}
<div className="absolute bottom-0 left-0 right-0 p-6 bg-white/80 backdrop-blur-md border-t border-slate-200 z-50">
<div className="max-w-6xl mx-auto flex justify-end items-center gap-4">
<button
onClick={onCancel}
className="px-6 py-3 rounded-xl text-slate-500 font-medium hover:text-slate-800 hover:bg-slate-100 transition-colors"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={selectedIndices.length === 0}
className={cn(
'flex items-center gap-2 px-8 py-3 rounded-xl font-bold uppercase tracking-wide transition-all shadow-lg',
selectedIndices.length > 0
? 'bg-gradient-to-r from-cyan-500 to-cyan-600 text-white shadow-cyan-200 hover:shadow-cyan-300 hover:-translate-y-0.5'
: 'bg-slate-200 text-slate-400 cursor-not-allowed',
)}
>
Start Refining
<ArrowRight className="w-5 h-5" />
</button>
</div>
</div>
</div>
)
}
+123
View File
@@ -0,0 +1,123 @@
import { Image as ImageIcon, Upload } from 'lucide-react'
import React, { useCallback, useState } from 'react'
import { cn } from '../../lib/utils'
interface UploadStageProps {
onImageUpload: (file: File) => void
}
export const UploadStage: React.FC<UploadStageProps> = ({ onImageUpload }) => {
const [isDragging, setIsDragging] = useState(false)
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
const file = e.dataTransfer.files[0]
if (file.type.startsWith('image/')) {
onImageUpload(file)
}
}
},
[onImageUpload],
)
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
onImageUpload(e.target.files[0])
}
},
[onImageUpload],
)
React.useEffect(() => {
const handlePaste = (e: ClipboardEvent) => {
if (e.clipboardData && e.clipboardData.files.length > 0) {
const file = e.clipboardData.files[0]
if (file.type.startsWith('image/')) {
onImageUpload(file)
}
}
}
window.addEventListener('paste', handlePaste)
return () => window.removeEventListener('paste', handlePaste)
}, [onImageUpload])
return (
<div className="flex flex-col items-center justify-center h-full w-full p-8 animate-fade-in-up">
<div
className={cn(
'relative flex flex-col items-center justify-center w-full max-w-2xl h-96 rounded-3xl border-2 border-dashed transition-all duration-300 ease-in-out cursor-pointer group',
isDragging
? 'border-cyan-500 bg-cyan-50 scale-[1.02]'
: 'border-slate-300 bg-slate-50 hover:border-cyan-400 hover:bg-slate-100',
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => document.getElementById('file-upload')?.click()}
>
<div className="flex flex-col items-center justify-center space-y-4 text-center p-6 pointer-events-none">
<div
className={cn(
'p-4 rounded-full ring-1 transition-all duration-300',
isDragging
? 'bg-cyan-100 ring-cyan-200'
: 'bg-white ring-slate-200 group-hover:ring-cyan-200 group-hover:bg-cyan-50',
)}
>
<Upload
className={cn(
'w-8 h-8 transition-colors duration-300',
isDragging ? 'text-cyan-600' : 'text-slate-400 group-hover:text-cyan-500',
)}
/>
</div>
<div className="space-y-2">
<h3 className="text-xl font-bold text-slate-700 group-hover:text-slate-900 transition-colors">
Upload an Image
</h3>
<p className="text-sm text-slate-500 max-w-xs mx-auto group-hover:text-slate-600 transition-colors">
Drag and drop your image here, or click to browse files
</p>
</div>
<div className="pt-4">
<span
className={cn(
'inline-flex items-center px-6 py-2.5 rounded-xl font-medium transition-all shadow-sm',
isDragging
? 'bg-cyan-600 text-white shadow-cyan-200'
: 'bg-white text-slate-700 border border-slate-200 group-hover:border-cyan-200 group-hover:text-cyan-600 group-hover:shadow-md',
)}
>
<ImageIcon className="w-4 h-4 mr-2" />
Select File
</span>
</div>
</div>
<input
id="file-upload"
type="file"
className="hidden"
accept="image/*"
onChange={handleFileChange}
/>
</div>
<p className="mt-8 text-slate-400 text-sm font-medium">Supports JPG, PNG, WEBP up to 10MB</p>
</div>
)
}