Merge branch 'main' into claude/automate-sam-download-mzpRs

This commit is contained in:
outis1one
2026-01-26 11:44:20 -05:00
committed by GitHub
7 changed files with 1243 additions and 398 deletions
+63
View File
@@ -0,0 +1,63 @@
# ==============================================================================
# AI Photo Edit - Unified Container
# Builds React frontend and serves it alongside FastAPI backend
# ==============================================================================
# Stage 1: Build React frontend
FROM node:20-alpine AS frontend-build
WORKDIR /frontend
# Copy package files and install dependencies
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm install
# Copy frontend source and build
COPY frontend/ ./
RUN npm run build
# Stage 2: Python backend with frontend static files
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies for OpenCV, rembg, SAM, and image processing
RUN apt-get update && apt-get install -y \
libgl1 \
libglib2.0-0 \
libsm6 \
libxext6 \
libxrender-dev \
libgomp1 \
wget \
git \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Pre-download rembg model (u2net) to avoid first-run delay
RUN python -c "from rembg import remove; print('rembg model downloaded')" || true
# Copy backend application
COPY backend/ .
# Copy entrypoint script
COPY backend/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Copy scripts
COPY scripts/ /scripts/
# Copy built frontend from stage 1
COPY --from=frontend-build /frontend/dist /app/static
# Create data directories
RUN mkdir -p /app/data/projects /app/data/patches /app/data/models
# Expose port
EXPOSE 8000
# Use entrypoint script
ENTRYPOINT ["/entrypoint.sh"]
+48 -4
View File
@@ -1,7 +1,10 @@
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, HTMLResponse
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
import os
from app.config import settings from app.config import settings
from app.database import init_db from app.database import init_db
@@ -40,9 +43,9 @@ app.include_router(generate.router)
app.include_router(tools.router) app.include_router(tools.router)
@app.get("/") @app.get("/api")
def root(): def api_root():
"""API root endpoint""" """API info endpoint"""
return { return {
"name": "AI Photo Edit API", "name": "AI Photo Edit API",
"version": "1.0.0", "version": "1.0.0",
@@ -54,3 +57,44 @@ def root():
def health(): def health():
"""Health check endpoint""" """Health check endpoint"""
return {"status": "healthy"} return {"status": "healthy"}
# Static files directory
STATIC_DIR = Path("/app/static")
# Serve static assets (JS, CSS, images)
if STATIC_DIR.exists():
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
@app.get("/", response_class=HTMLResponse)
async def serve_spa():
"""Serve React SPA index.html"""
index_path = STATIC_DIR / "index.html"
if index_path.exists():
return FileResponse(index_path)
return HTMLResponse("<h1>Frontend not built. Run npm build in frontend/</h1>")
@app.get("/{full_path:path}")
async def serve_spa_routes(request: Request, full_path: str):
"""
Catch-all route for React Router (SPA).
Serves static files if they exist, otherwise returns index.html.
"""
# Don't catch API routes
if full_path.startswith(("projects", "edits", "patches", "tools", "generate", "health", "docs", "openapi.json", "api")):
return {"detail": "Not Found"}
# Check if it's a static file
static_file = STATIC_DIR / full_path
if static_file.exists() and static_file.is_file():
return FileResponse(static_file)
# Otherwise serve index.html for React Router
index_path = STATIC_DIR / "index.html"
if index_path.exists():
return FileResponse(index_path)
return HTMLResponse("<h1>Frontend not built</h1>", status_code=404)
+5 -28
View File
@@ -1,16 +1,13 @@
version: '3.8'
services: services:
backend: app:
build: build:
context: ./backend context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: ai-photo-edit-backend container_name: ai-photo-edit
ports: ports:
- "8101:8000" # External 8101, internal 8000 - "3080:8000"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./backend:/app
- ./scripts:/scripts - ./scripts:/scripts
environment: environment:
- DATABASE_URL=sqlite:///./data/ai_photo_edit.db - DATABASE_URL=sqlite:///./data/ai_photo_edit.db
@@ -19,28 +16,8 @@ services:
- OPENAI_API_KEY=${OPENAI_API_KEY:-} - OPENAI_API_KEY=${OPENAI_API_KEY:-}
- STABILITY_API_KEY=${STABILITY_API_KEY:-} - STABILITY_API_KEY=${STABILITY_API_KEY:-}
- REPLICATE_API_KEY=${REPLICATE_API_KEY:-} - REPLICATE_API_KEY=${REPLICATE_API_KEY:-}
- CORS_ORIGINS=http://localhost:5173,http://localhost:3000,http://localhost - CORS_ORIGINS=*
- AUTO_DOWNLOAD_SAM=${AUTO_DOWNLOAD_SAM:-true}
restart: unless-stopped restart: unless-stopped
networks:
- ai-photo-edit-network
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: ai-photo-edit-frontend
ports:
- "3080:80" # Frontend on port 3080
depends_on:
- backend
restart: unless-stopped
networks:
- ai-photo-edit-network
networks:
ai-photo-edit-network:
driver: bridge
volumes: volumes:
data: data:
+10
View File
@@ -47,6 +47,16 @@ server {
proxy_cache_bypass $http_upgrade; proxy_cache_bypass $http_upgrade;
} }
location /tools {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
}
location /generate { location /generate {
proxy_pass http://backend:8000; proxy_pass http://backend:8000;
proxy_http_version 1.1; proxy_http_version 1.1;
+590 -68
View File
@@ -1,14 +1,366 @@
.app { /* GIMP-like layout */
min-height: 100vh; * {
background-color: #1a1a1a; box-sizing: border-box;
} }
.error-banner { html, body, #root {
background-color: #ff4444; margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
.app {
height: 100vh;
display: flex;
flex-direction: column;
background-color: #1a1a1a;
color: #e0e0e0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* Header / Menu bar */
.menu-bar {
display: flex;
align-items: center;
justify-content: space-between;
background-color: #2d2d2d;
border-bottom: 1px solid #404040;
padding: 4px 12px;
height: 36px;
flex-shrink: 0;
}
.menu-bar h1 {
font-size: 14px;
font-weight: 600;
margin: 0;
color: #fff;
}
.menu-actions {
display: flex;
gap: 8px;
}
.menu-btn {
background: #404040;
border: none;
color: #e0e0e0;
padding: 4px 12px;
font-size: 12px;
border-radius: 3px;
cursor: pointer;
}
.menu-btn:hover {
background: #505050;
}
.menu-btn.primary {
background: #0066cc;
}
.menu-btn.primary:hover {
background: #0077ee;
}
/* Main workspace */
.workspace {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* Left toolbar - GIMP style vertical tools */
.toolbar {
width: 48px;
background-color: #2d2d2d;
border-right: 1px solid #404040;
display: flex;
flex-direction: column;
padding: 4px;
gap: 2px;
flex-shrink: 0;
}
.tool-btn {
width: 40px;
height: 40px;
background: transparent;
border: 2px solid transparent;
border-radius: 4px;
color: #b0b0b0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
transition: all 0.15s;
}
.tool-btn:hover {
background: #404040;
color: #fff;
}
.tool-btn.active {
background: #0066cc;
border-color: #0088ff;
color: #fff;
}
.tool-btn[title]:hover::after {
content: attr(title);
position: absolute;
left: 52px;
background: #000;
color: #fff;
padding: 4px 8px;
border-radius: 3px;
font-size: 11px;
white-space: nowrap;
z-index: 100;
}
.tool-divider {
height: 1px;
background: #404040;
margin: 4px 0;
}
/* Canvas area - main editing space */
.canvas-area {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: #1a1a1a;
position: relative;
}
.canvas-wrapper {
flex: 1;
position: relative;
overflow: hidden;
background:
linear-gradient(45deg, #252525 25%, transparent 25%),
linear-gradient(-45deg, #252525 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #252525 75%),
linear-gradient(-45deg, transparent 75%, #252525 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
background-color: #1e1e1e;
}
/* Canvas status bar */
.canvas-status {
height: 24px;
background: #2d2d2d;
border-top: 1px solid #404040;
display: flex;
align-items: center;
padding: 0 12px;
font-size: 11px;
color: #888;
gap: 20px;
}
.zoom-controls {
display: flex;
align-items: center;
gap: 8px;
}
.zoom-btn {
background: #404040;
border: none;
color: #e0e0e0;
width: 24px;
height: 20px;
border-radius: 3px;
cursor: pointer;
font-size: 14px;
}
.zoom-btn:hover {
background: #505050;
}
.zoom-level {
min-width: 50px;
text-align: center;
}
/* Right sidebar */
.sidebar {
width: 280px;
background-color: #2d2d2d;
border-left: 1px solid #404040;
display: flex;
flex-direction: column;
overflow-y: auto;
flex-shrink: 0;
}
.sidebar-panel {
border-bottom: 1px solid #404040;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #353535;
cursor: pointer;
user-select: none;
}
.panel-header h3 {
font-size: 12px;
font-weight: 600;
margin: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.panel-toggle {
font-size: 10px;
color: #888;
}
.panel-content {
padding: 12px;
}
.panel-content.collapsed {
display: none;
}
/* Form controls */
.control-group {
margin-bottom: 12px;
}
.control-group:last-child {
margin-bottom: 0;
}
.control-label {
display: block;
font-size: 11px;
color: #888;
margin-bottom: 4px;
text-transform: uppercase;
}
.control-row {
display: flex;
gap: 4px;
}
.mode-btn {
flex: 1;
padding: 6px 8px;
background: #404040;
border: 1px solid #505050;
color: #e0e0e0;
font-size: 11px;
border-radius: 3px;
cursor: pointer;
}
.mode-btn:hover {
background: #505050;
}
.mode-btn.active {
background: #0066cc;
border-color: #0088ff;
}
input[type="range"] {
width: 100%;
height: 4px;
background: #404040;
border-radius: 2px;
outline: none;
-webkit-appearance: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
background: #0088ff;
border-radius: 50%;
cursor: pointer;
}
textarea {
width: 100%;
background: #1a1a1a;
border: 1px solid #404040;
border-radius: 3px;
color: #e0e0e0;
padding: 8px;
font-size: 12px;
resize: vertical;
min-height: 60px;
}
textarea:focus {
border-color: #0088ff;
outline: none;
}
.action-btn {
width: 100%;
padding: 10px;
background: #0066cc;
border: none;
color: white; color: white;
padding: 12px 16px; font-size: 12px;
border-radius: 6px; font-weight: 600;
margin-bottom: 20px; border-radius: 3px;
cursor: pointer;
text-transform: uppercase;
}
.action-btn:hover:not(:disabled) {
background: #0077ee;
}
.action-btn:disabled {
background: #404040;
color: #666;
cursor: not-allowed;
}
.action-btn.secondary {
background: #404040;
}
.action-btn.secondary:hover:not(:disabled) {
background: #505050;
}
.action-btn.danger {
background: #cc3333;
}
.action-btn.danger:hover:not(:disabled) {
background: #dd4444;
}
/* Error banner */
.error-banner {
background-color: #cc3333;
color: white;
padding: 8px 12px;
font-size: 12px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -16,115 +368,285 @@
.error-banner button { .error-banner button {
background: none; background: none;
border: none;
color: white; color: white;
font-size: 20px; font-size: 16px;
cursor: pointer;
padding: 0; padding: 0;
width: 24px; }
height: 24px;
/* Project setup modal */
.project-setup-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1000;
} }
.project-setup { .project-setup {
max-width: 600px; background: #2d2d2d;
margin: 0 auto; border: 1px solid #404040;
padding: 40px;
background-color: #2a2a2a;
border-radius: 8px; border-radius: 8px;
border: 1px solid #444; padding: 24px;
width: 400px;
max-width: 90vw;
} }
.project-setup h2 { .project-setup h2 {
font-size: 24px; font-size: 18px;
margin-bottom: 24px; margin: 0 0 20px 0;
text-align: center; text-align: center;
} }
.setup-form { .setup-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 16px;
} }
.form-group { .form-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 6px;
} }
.form-group label { .form-group label {
font-size: 14px; font-size: 12px;
font-weight: 600; color: #888;
color: #cccccc; text-transform: uppercase;
} }
.form-group input { .form-group input[type="text"],
width: 100%; .form-group input[type="file"] {
background: #1a1a1a;
border: 1px solid #404040;
border-radius: 3px;
color: #e0e0e0;
padding: 10px;
font-size: 14px;
}
.form-group input[type="text"]:focus {
border-color: #0088ff;
outline: none;
} }
.file-name { .file-name {
font-size: 12px; font-size: 11px;
color: #00ff00; color: #0088ff;
margin-top: 4px;
}
.optional-field label::after {
content: '';
}
.optional-field {
opacity: 0.7;
}
.optional-field:focus-within {
opacity: 1;
} }
.create-project-btn { .create-project-btn {
background-color: #0066ff; background: #0066cc;
border: none;
color: white; color: white;
padding: 14px 20px; padding: 12px;
font-size: 16px; font-size: 14px;
font-weight: 600; font-weight: 600;
margin-top: 10px; border-radius: 3px;
cursor: pointer;
margin-top: 8px;
} }
.create-project-btn:hover:not(:disabled) { .create-project-btn:hover:not(:disabled) {
background-color: #0055dd; background: #0077ee;
} }
.workspace { .create-project-btn:disabled {
display: grid; background: #404040;
grid-template-columns: 1fr 400px; color: #666;
gap: 20px;
min-height: calc(100vh - 180px);
height: calc(100vh - 180px);
} }
@media (max-width: 1200px) { /* Layer list */
.workspace { .layer-list {
grid-template-columns: 1fr;
}
}
.left-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; gap: 4px;
min-height: 0;
} }
.right-panel { .layer-item {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 12px; gap: 8px;
overflow-y: auto; padding: 6px 8px;
max-height: calc(100vh - 180px); background: #353535;
padding-right: 8px; border-radius: 3px;
cursor: pointer;
} }
.history-wrapper { .layer-item:hover {
background: #404040;
}
.layer-item.active {
background: #0066cc;
}
.layer-visibility {
width: 16px;
height: 16px;
background: none;
border: none;
color: #888;
cursor: pointer;
padding: 0;
font-size: 12px;
}
.layer-visibility.visible {
color: #0088ff;
}
.layer-name {
flex: 1; flex: 1;
min-height: 0; font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* History list */
.history-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 200px;
overflow-y: auto;
}
.history-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
background: #353535;
border-radius: 3px;
font-size: 11px;
}
.history-item:hover {
background: #404040;
}
.history-item.current {
background: #0066cc;
}
.history-item-actions {
display: flex;
gap: 4px;
}
.history-btn {
background: #505050;
border: none;
color: #e0e0e0;
padding: 2px 8px;
font-size: 10px;
border-radius: 2px;
cursor: pointer;
}
.history-btn:hover {
background: #606060;
}
/* Eye catalog */
.eye-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.eye-item {
aspect-ratio: 1;
background: #353535;
border: 2px solid transparent;
border-radius: 4px;
cursor: pointer;
overflow: hidden;
}
.eye-item:hover {
border-color: #0088ff;
}
.eye-item.selected {
border-color: #0088ff;
}
.eye-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Processing overlay */
.processing-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.processing-spinner {
width: 40px;
height: 40px;
border: 3px solid #404040;
border-top-color: #0088ff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Slider with value */
.slider-row {
display: flex;
align-items: center;
gap: 8px;
}
.slider-row input[type="range"] {
flex: 1;
}
.slider-value {
font-size: 11px;
color: #0088ff;
min-width: 35px;
text-align: right;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1a1a1a;
}
::-webkit-scrollbar-thumb {
background: #404040;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #505050;
} }
+497 -284
View File
@@ -1,37 +1,84 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import ImageCanvas from './components/ImageCanvas'; import ImageCanvas from './components/ImageCanvas';
import Controls from './components/Controls';
import History from './components/History';
import EyeCatalog from './components/EyeCatalog';
import AdvancedTools from './components/AdvancedTools';
import Layers from './components/Layers';
import { projectsApi, editsApi, toolsApi } from './utils/api'; import { projectsApi, editsApi, toolsApi } from './utils/api';
import './App.css'; import './App.css';
// Tool definitions
const TOOLS = {
move: { icon: '✥', name: 'Move', shortcut: 'V' },
select: { icon: '▢', name: 'Rectangle Select', shortcut: 'R' },
ellipse: { icon: '○', name: 'Ellipse Select', shortcut: 'E' },
lasso: { icon: '✎', name: 'Free Select (Lasso)', shortcut: 'F' },
magic: { icon: '✨', name: 'Smart Select (SAM)', shortcut: 'W' },
colorPick: { icon: '◉', name: 'Color Select', shortcut: 'U' },
brush: { icon: '🖌', name: 'Brush', shortcut: 'B' },
bucket: { icon: '◧', name: 'Bucket Fill', shortcut: 'G' },
eraser: { icon: '◫', name: 'Eraser', shortcut: 'Shift+E' },
eyedropper: { icon: '💧', name: 'Color Picker', shortcut: 'O' },
zoom: { icon: '🔍', name: 'Zoom', shortcut: 'Z' },
pan: { icon: '✋', name: 'Pan', shortcut: 'H' },
};
function App() { function App() {
// Project state
const [project, setProject] = useState(null); const [project, setProject] = useState(null);
const [imageFile, setImageFile] = useState(null); const [imageFile, setImageFile] = useState(null);
const [currentImageUrl, setCurrentImageUrl] = useState(null); const [currentImageUrl, setCurrentImageUrl] = useState(null);
const [selection, setSelection] = useState(null); const [showProjectSetup, setShowProjectSetup] = useState(true);
const [selectionMode, setSelectionMode] = useState('rectangle');
const [mode, setMode] = useState('A');
const [feather, setFeather] = useState(5);
const [prompt, setPrompt] = useState('');
const [edits, setEdits] = useState([]);
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState(null);
const [projectName, setProjectName] = useState(''); const [projectName, setProjectName] = useState('');
const [showProjectInput, setShowProjectInput] = useState(true);
// Tool state
const [activeTool, setActiveTool] = useState('select');
const [selection, setSelection] = useState(null);
// Edit state
const [prompt, setPrompt] = useState('');
const [feather, setFeather] = useState(5);
const [mode, setMode] = useState('A');
const [edits, setEdits] = useState([]);
const [currentEditIndex, setCurrentEditIndex] = useState(-1); const [currentEditIndex, setCurrentEditIndex] = useState(-1);
const [layers, setLayers] = useState([]);
const [activeLayer, setActiveLayer] = useState('background');
const [generatedMask, setGeneratedMask] = useState(null);
const [advancedToolMode, setAdvancedToolMode] = useState(null); // 'smart-select', 'color-select', 'object-remove'
const [canvasZoom, setCanvasZoom] = useState(1);
const [externalSelection, setExternalSelection] = useState(null); // For smart-select/color-select polygon results
const editsRef = useRef([]); const editsRef = useRef([]);
// Create project and upload image // Layer state
const [layers, setLayers] = useState([]);
// UI state
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState(null);
const [zoom, setZoom] = useState(100);
const [collapsedPanels, setCollapsedPanels] = useState({});
const [eyes, setEyes] = useState([]);
const [selectedEye, setSelectedEye] = useState(null);
// Canvas ref for tool interactions
const canvasRef = useRef(null);
// Map tool to selection mode
const getSelectionMode = () => {
switch (activeTool) {
case 'select': return 'rectangle';
case 'ellipse': return 'ellipse';
case 'lasso': return 'lasso';
case 'magic': return 'smart';
case 'colorPick': return 'color';
default: return null;
}
};
// Load eyes catalog
useEffect(() => {
const loadEyes = async () => {
try {
const patches = await fetch('/patches/?category=eyes').then(r => r.json()).catch(() => []);
setEyes(patches);
} catch (err) {
console.error('Failed to load eyes:', err);
}
};
if (project) loadEyes();
}, [project]);
// Create project
const handleCreateProject = async () => { const handleCreateProject = async () => {
if (!imageFile) { if (!imageFile) {
setError('Please select an image'); setError('Please select an image');
@@ -42,25 +89,17 @@ function App() {
setError(null); setError(null);
setIsProcessing(true); setIsProcessing(true);
// Generate default project name from file name or timestamp
const defaultName = projectName.trim() || const defaultName = projectName.trim() ||
imageFile.name.replace(/\.[^/.]+$/, '') || imageFile.name.replace(/\.[^/.]+$/, '') ||
`Project ${Date.now()}`; `Project ${Date.now()}`;
// Create project
const newProject = await projectsApi.create(defaultName); const newProject = await projectsApi.create(defaultName);
setProject(newProject); setProject(newProject);
// Upload image
await projectsApi.uploadImage(newProject.id, imageFile); await projectsApi.uploadImage(newProject.id, imageFile);
// Set current image URL
setCurrentImageUrl(projectsApi.getCurrentImageUrl(newProject.id)); setCurrentImageUrl(projectsApi.getCurrentImageUrl(newProject.id));
setShowProjectSetup(false);
// Hide project input
setShowProjectInput(false);
// Load edits
await loadEdits(newProject.id); await loadEdits(newProject.id);
} catch (err) { } catch (err) {
setError(`Failed to create project: ${err.message}`); setError(`Failed to create project: ${err.message}`);
@@ -69,13 +108,12 @@ function App() {
} }
}; };
// Load edits for the project // Load edits
const loadEdits = async (projectId) => { const loadEdits = async (projectId) => {
try { try {
const projectEdits = await projectsApi.getEdits(projectId); const projectEdits = await projectsApi.getEdits(projectId);
setEdits(projectEdits); setEdits(projectEdits);
editsRef.current = projectEdits; editsRef.current = projectEdits;
// Set index to latest completed edit
const completedEdits = projectEdits.filter(e => e.status === 'completed'); const completedEdits = projectEdits.filter(e => e.status === 'completed');
setCurrentEditIndex(completedEdits.length - 1); setCurrentEditIndex(completedEdits.length - 1);
} catch (err) { } catch (err) {
@@ -83,9 +121,9 @@ function App() {
} }
}; };
// Poll for edit status // Poll edit status
const pollEditStatus = async (editId) => { const pollEditStatus = async (editId) => {
const maxAttempts = 60; // 60 attempts = 1 minute with 1 second interval const maxAttempts = 120;
let attempts = 0; let attempts = 0;
const poll = async () => { const poll = async () => {
@@ -93,28 +131,26 @@ function App() {
const edit = await editsApi.get(editId); const edit = await editsApi.get(editId);
if (edit.status === 'completed') { if (edit.status === 'completed') {
// Reload edits and update image
await loadEdits(project.id); await loadEdits(project.id);
setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id)); setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id) + `?t=${Date.now()}`);
setIsProcessing(false); setIsProcessing(false);
setSelection(null); setSelection(null);
return; return;
} else if (edit.status === 'failed') { } else if (edit.status === 'failed') {
setError(`Edit failed: ${edit.error_message}`); setError(`Edit failed: ${edit.error_message}`);
setIsProcessing(false); setIsProcessing(false);
await loadEdits(project.id);
return; return;
} }
attempts++; attempts++;
if (attempts < maxAttempts) { if (attempts < maxAttempts) {
setTimeout(poll, 1000); // Poll every 1 second setTimeout(poll, 1000);
} else { } else {
setError('Edit timeout - please check edit history'); setError('Edit timeout');
setIsProcessing(false); setIsProcessing(false);
} }
} catch (err) { } catch (err) {
setError(`Failed to check edit status: ${err.message}`); setError(`Status check failed: ${err.message}`);
setIsProcessing(false); setIsProcessing(false);
} }
}; };
@@ -122,10 +158,10 @@ function App() {
poll(); poll();
}; };
// Handle fix button // Handle AI fix
const handleFix = async () => { const handleFix = async () => {
if (!selection || !prompt.trim() || !project) { if (!selection || !prompt.trim() || !project) {
setError('Please make a selection and enter a prompt'); setError('Make a selection and enter a prompt');
return; return;
} }
@@ -143,248 +179,214 @@ function App() {
}; };
const edit = await editsApi.create(project.id, editRequest); const edit = await editsApi.create(project.id, editRequest);
// Start polling for status
pollEditStatus(edit.id); pollEditStatus(edit.id);
} catch (err) { } catch (err) {
setError(`Failed to process edit: ${err.message}`); setError(`Edit failed: ${err.message}`);
setIsProcessing(false); setIsProcessing(false);
} }
}; };
// Handle revert // Undo/Redo
const handleRevert = useCallback(async (editId) => { const handleRevert = useCallback(async (editId) => {
if (!project) return; if (!project) return;
try { try {
setError(null);
setIsProcessing(true); setIsProcessing(true);
await editsApi.revert(project.id, editId); await editsApi.revert(project.id, editId);
setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id) + `?t=${Date.now()}`);
// Update image
setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id));
await loadEdits(project.id); await loadEdits(project.id);
} catch (err) { } catch (err) {
setError(`Failed to revert: ${err.message}`); setError(`Revert failed: ${err.message}`);
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
} }
}, [project]); }, [project]);
// Handle reset
const handleReset = useCallback(async () => { const handleReset = useCallback(async () => {
if (!project) return; if (!project) return;
try { try {
setError(null);
setIsProcessing(true); setIsProcessing(true);
await editsApi.reset(project.id); await editsApi.reset(project.id);
setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id) + `?t=${Date.now()}`);
// Update image
setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id));
await loadEdits(project.id); await loadEdits(project.id);
} catch (err) { } catch (err) {
setError(`Failed to reset: ${err.message}`); setError(`Reset failed: ${err.message}`);
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
} }
}, [project]); }, [project]);
// Handle download // Download
const handleDownload = async () => { const handleDownload = async () => {
if (!currentImageUrl) return; if (!currentImageUrl) return;
try { try {
// Fetch the current image const response = await fetch(`${currentImageUrl}&download=1`);
const response = await fetch(`${currentImageUrl}?t=${Date.now()}`);
const blob = await response.blob(); const blob = await response.blob();
// Create download link
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
link.download = `edited-image-${Date.now()}.png`; link.download = `${project?.name || 'image'}-${Date.now()}.png`;
document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
} catch (err) { } catch (err) {
setError(`Failed to download: ${err.message}`); setError(`Download failed: ${err.message}`);
} }
}; };
// Handle undo (Ctrl+Z) // Smart Select (SAM)
const handleUndo = useCallback(async () => { const handleSmartSelect = async (x, y) => {
if (!project || isProcessing) return; if (!project) return;
try {
const completedEdits = editsRef.current.filter(e => e.status === 'completed'); setIsProcessing(true);
if (completedEdits.length === 0) return; const maskBlob = await toolsApi.smartSelect(project.id, x, y);
// TODO: Display mask on canvas
if (currentEditIndex <= 0) { console.log('Smart select mask:', maskBlob);
// Revert to original } catch (err) {
await handleReset(); setError(`Smart select failed: ${err.message}`);
setCurrentEditIndex(-1); } finally {
} else { setIsProcessing(false);
// Revert to previous edit
const previousEdit = completedEdits[currentEditIndex - 1];
await handleRevert(previousEdit.id);
setCurrentEditIndex(currentEditIndex - 1);
} }
}, [project, isProcessing, currentEditIndex, handleReset, handleRevert]); };
// Handle redo (Ctrl+Y) // Remove background
const handleRedo = useCallback(async () => { const handleRemoveBackground = async () => {
if (!project || isProcessing) return; if (!project) return;
try {
setIsProcessing(true);
await toolsApi.removeBackgroundToLayer(project.id);
setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id) + `?t=${Date.now()}`);
} catch (err) {
setError(`Background removal failed: ${err.message}`);
} finally {
setIsProcessing(false);
}
};
const completedEdits = editsRef.current.filter(e => e.status === 'completed'); // Apply eye
if (currentEditIndex >= completedEdits.length - 1) return; const handleApplyEye = async () => {
if (!project || !selection || !selectedEye) {
setError('Select an area and an eye to apply');
return;
}
try {
setIsProcessing(true);
const formData = new FormData();
formData.append('project_id', project.id);
formData.append('patch_id', selectedEye.id);
formData.append('bbox', JSON.stringify(selection.bbox));
formData.append('feather_px', feather);
const nextEdit = completedEdits[currentEditIndex + 1]; await fetch('/patches/apply', {
await handleRevert(nextEdit.id); method: 'POST',
setCurrentEditIndex(currentEditIndex + 1); body: formData,
}, [project, isProcessing, currentEditIndex, handleRevert]); });
// Keyboard shortcut handler setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id) + `?t=${Date.now()}`);
await loadEdits(project.id);
} catch (err) {
setError(`Apply eye failed: ${err.message}`);
} finally {
setIsProcessing(false);
}
};
// Keyboard shortcuts
useEffect(() => { useEffect(() => {
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
// Don't trigger shortcuts when typing in input fields
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { // Tool shortcuts
e.preventDefault(); if (!e.ctrlKey && !e.metaKey) {
handleUndo(); switch (e.key.toLowerCase()) {
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { case 'v': setActiveTool('move'); break;
e.preventDefault(); case 'r': setActiveTool('select'); break;
handleRedo(); case 'e': setActiveTool('ellipse'); break;
case 'f': setActiveTool('lasso'); break;
case 'w': setActiveTool('magic'); break;
case 'b': setActiveTool('brush'); break;
case 'g': setActiveTool('bucket'); break;
case 'z': setActiveTool('zoom'); break;
case 'h': setActiveTool('pan'); break;
case 'delete':
case 'backspace':
if (selection) {
// TODO: Delete selected area
}
break;
}
}
// Ctrl shortcuts
if (e.ctrlKey || e.metaKey) {
switch (e.key.toLowerCase()) {
case 'z':
e.preventDefault();
if (e.shiftKey) {
// Redo
const completed = editsRef.current.filter(ed => ed.status === 'completed');
if (currentEditIndex < completed.length - 1) {
handleRevert(completed[currentEditIndex + 1].id);
setCurrentEditIndex(i => i + 1);
}
} else {
// Undo
if (currentEditIndex >= 0) {
if (currentEditIndex === 0) {
handleReset();
} else {
const completed = editsRef.current.filter(ed => ed.status === 'completed');
handleRevert(completed[currentEditIndex - 1].id);
}
setCurrentEditIndex(i => i - 1);
}
}
break;
case 's':
e.preventDefault();
handleDownload();
break;
}
} }
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleUndo, handleRedo]); }, [selection, currentEditIndex, handleRevert, handleReset]);
// Warn before leaving page when there are unsaved changes // Zoom controls
useEffect(() => { const handleZoomIn = () => setZoom(z => Math.min(z + 25, 400));
const handleBeforeUnload = (e) => { const handleZoomOut = () => setZoom(z => Math.max(z - 25, 25));
if (project && edits.length > 0) { const handleZoomReset = () => setZoom(100);
e.preventDefault();
e.returnValue = 'You have unsaved changes. Are you sure you want to leave?';
return e.returnValue;
}
};
window.addEventListener('beforeunload', handleBeforeUnload); // Panel toggle
return () => window.removeEventListener('beforeunload', handleBeforeUnload); const togglePanel = (panel) => {
}, [project, edits]); setCollapsedPanels(prev => ({ ...prev, [panel]: !prev[panel] }));
// Handle layer creation from advanced tools
const handleLayerCreated = (layer) => {
setLayers((prev) => [...prev, { ...layer, visible: true }]);
}; };
// Handle mask generation from smart select / color select // Project setup overlay
const handleMaskGenerated = async (maskData, source) => { if (showProjectSetup) {
setGeneratedMask({ data: maskData, source }); return (
// Convert mask to selection if it contains polygon data <div className="app">
if (maskData && maskData.polygon && maskData.polygon.length > 0) { <div className="project-setup-overlay">
// Set external selection for canvas to draw
setExternalSelection({
polygon: maskData.polygon,
bbox: maskData.bbox,
});
// Also set selection state for fix button
setSelection({
type: 'polygon',
bbox: maskData.bbox,
selectionData: { points: maskData.polygon },
});
}
// Reset tool mode after selection
setAdvancedToolMode(null);
};
// Handle canvas click for advanced tools (smart select, color select)
const handleAdvancedToolClick = async (x, y, color) => {
if (!project || !advancedToolMode) return;
try {
setIsProcessing(true);
setError(null);
if (advancedToolMode === 'smart-select') {
const result = await toolsApi.smartSelect(project.id, x, y);
handleMaskGenerated(result, 'smart-select');
} else if (advancedToolMode === 'color-select') {
// Color is passed from canvas click
if (color) {
const result = await toolsApi.colorSelect(project.id, color.r, color.g, color.b, 30);
handleMaskGenerated(result, 'color-select');
}
}
} catch (err) {
setError(`${advancedToolMode} failed: ${err.message}`);
} finally {
setIsProcessing(false);
}
};
// Handle flatten layers
const handleFlattenLayers = async (layerOrder) => {
if (!project) return;
try {
setIsProcessing(true);
await toolsApi.flattenLayers(project.id, layerOrder);
setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id));
setLayers([]);
} catch (err) {
setError(`Failed to flatten layers: ${err.message}`);
} finally {
setIsProcessing(false);
}
};
return (
<div className="app">
<div className="container">
<div className="header">
<h1>AI Photo Edit</h1>
<p>Have AI regenerate only a selected area of your photo</p>
</div>
{error && (
<div className="error-banner">
<strong>Error:</strong> {error}
<button onClick={() => setError(null)}></button>
</div>
)}
{showProjectInput ? (
<div className="project-setup"> <div className="project-setup">
<h2>Start Editing</h2> <h2>Open Image</h2>
<div className="setup-form"> <div className="setup-form">
<div className="form-group"> <div className="form-group">
<label>Upload Image</label> <label>Select Image</label>
<input <input
type="file" type="file"
accept="image/*" accept="image/*"
onChange={(e) => setImageFile(e.target.files[0])} onChange={(e) => setImageFile(e.target.files[0])}
disabled={isProcessing} disabled={isProcessing}
/> />
{imageFile && ( {imageFile && <p className="file-name">{imageFile.name}</p>}
<p className="file-name">Selected: {imageFile.name}</p>
)}
</div> </div>
<div className="form-group optional-field"> <div className="form-group">
<label>Project Name (optional)</label> <label>Project Name (optional)</label>
<input <input
type="text" type="text"
value={projectName} value={projectName}
onChange={(e) => setProjectName(e.target.value)} onChange={(e) => setProjectName(e.target.value)}
placeholder="Uses filename if left empty" placeholder="Auto-generated from filename"
disabled={isProcessing} disabled={isProcessing}
/> />
</div> </div>
@@ -393,90 +395,301 @@ function App() {
onClick={handleCreateProject} onClick={handleCreateProject}
disabled={isProcessing || !imageFile} disabled={isProcessing || !imageFile}
> >
{isProcessing ? 'Starting...' : 'Start Editing'} {isProcessing ? 'Opening...' : 'Open'}
</button> </button>
</div> </div>
</div> </div>
) : ( </div>
<div className="workspace"> </div>
<div className="left-panel"> );
<ImageCanvas }
imageUrl={currentImageUrl}
onSelectionChange={setSelection} return (
selectionMode={selectionMode} <div className="app">
advancedToolMode={advancedToolMode} {/* Menu Bar */}
onAdvancedToolClick={handleAdvancedToolClick} <div className="menu-bar">
zoom={canvasZoom} <h1>AI Photo Edit</h1>
onZoomChange={setCanvasZoom} <div className="menu-actions">
externalSelection={externalSelection} <button className="menu-btn" onClick={() => setShowProjectSetup(true)}>New</button>
/> <button className="menu-btn" onClick={handleDownload}>Save</button>
<button className="menu-btn" onClick={handleReset}>Reset</button>
</div>
</div>
{/* Error Banner */}
{error && (
<div className="error-banner">
{error}
<button onClick={() => setError(null)}></button>
</div>
)}
{/* Main Workspace */}
<div className="workspace">
{/* Left Toolbar */}
<div className="toolbar">
<button
className={`tool-btn ${activeTool === 'move' ? 'active' : ''}`}
onClick={() => setActiveTool('move')}
title="Move (V)"
></button>
<div className="tool-divider" />
<button
className={`tool-btn ${activeTool === 'select' ? 'active' : ''}`}
onClick={() => setActiveTool('select')}
title="Rectangle Select (R)"
></button>
<button
className={`tool-btn ${activeTool === 'ellipse' ? 'active' : ''}`}
onClick={() => setActiveTool('ellipse')}
title="Ellipse Select (E)"
></button>
<button
className={`tool-btn ${activeTool === 'lasso' ? 'active' : ''}`}
onClick={() => setActiveTool('lasso')}
title="Free Select (F)"
></button>
<button
className={`tool-btn ${activeTool === 'magic' ? 'active' : ''}`}
onClick={() => setActiveTool('magic')}
title="Smart Select - SAM (W)"
></button>
<button
className={`tool-btn ${activeTool === 'colorPick' ? 'active' : ''}`}
onClick={() => setActiveTool('colorPick')}
title="Color Select (U)"
></button>
<div className="tool-divider" />
<button
className={`tool-btn ${activeTool === 'brush' ? 'active' : ''}`}
onClick={() => setActiveTool('brush')}
title="Brush (B)"
>🖌</button>
<button
className={`tool-btn ${activeTool === 'bucket' ? 'active' : ''}`}
onClick={() => setActiveTool('bucket')}
title="Bucket Fill (G)"
></button>
<button
className={`tool-btn ${activeTool === 'eraser' ? 'active' : ''}`}
onClick={() => setActiveTool('eraser')}
title="Eraser (Shift+E)"
></button>
<div className="tool-divider" />
<button
className={`tool-btn ${activeTool === 'eyedropper' ? 'active' : ''}`}
onClick={() => setActiveTool('eyedropper')}
title="Color Picker (O)"
>💧</button>
<button
className={`tool-btn ${activeTool === 'zoom' ? 'active' : ''}`}
onClick={() => setActiveTool('zoom')}
title="Zoom (Z)"
>🔍</button>
<button
className={`tool-btn ${activeTool === 'pan' ? 'active' : ''}`}
onClick={() => setActiveTool('pan')}
title="Pan (H)"
></button>
</div>
{/* Canvas Area */}
<div className="canvas-area">
<div className="canvas-wrapper">
<ImageCanvas
ref={canvasRef}
imageUrl={currentImageUrl}
onSelectionChange={setSelection}
selectionMode={getSelectionMode()}
activeTool={activeTool}
zoom={zoom}
onSmartSelect={handleSmartSelect}
isProcessing={isProcessing}
/>
{isProcessing && (
<div className="processing-overlay">
<div className="processing-spinner" />
</div>
)}
</div>
<div className="canvas-status">
<div className="zoom-controls">
<button className="zoom-btn" onClick={handleZoomOut}></button>
<span className="zoom-level">{zoom}%</span>
<button className="zoom-btn" onClick={handleZoomIn}>+</button>
<button className="zoom-btn" onClick={handleZoomReset}></button>
</div> </div>
<span>Selection: {selection ? `${selection.bbox?.width || 0}×${selection.bbox?.height || 0}` : 'None'}</span>
<span>Tool: {TOOLS[activeTool]?.name || activeTool}</span>
</div>
</div>
<div className="right-panel"> {/* Right Sidebar */}
<Controls <div className="sidebar">
selectionMode={selectionMode} {/* Tool Options Panel */}
onSelectionModeChange={setSelectionMode} <div className="sidebar-panel">
mode={mode} <div className="panel-header" onClick={() => togglePanel('toolOptions')}>
onModeChange={setMode} <h3>Tool Options</h3>
feather={feather} <span className="panel-toggle">{collapsedPanels.toolOptions ? '▶' : '▼'}</span>
onFeatherChange={setFeather} </div>
prompt={prompt} <div className={`panel-content ${collapsedPanels.toolOptions ? 'collapsed' : ''}`}>
onPromptChange={setPrompt} <div className="control-group">
onFix={handleFix} <label className="control-label">Feather</label>
onDownload={handleDownload} <div className="slider-row">
isProcessing={isProcessing} <input
hasSelection={!!selection} type="range"
/> min="0"
max="50"
<AdvancedTools value={feather}
projectId={project?.id} onChange={(e) => setFeather(parseInt(e.target.value))}
selection={selection} />
onLayerCreated={handleLayerCreated} <span className="slider-value">{feather}px</span>
onMaskGenerated={handleMaskGenerated} </div>
onImageUpdate={() => {
setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id));
}}
isProcessing={isProcessing}
setIsProcessing={setIsProcessing}
setError={setError}
activeToolMode={advancedToolMode}
setActiveToolMode={setAdvancedToolMode}
/>
<Layers
projectId={project?.id}
layers={layers}
setLayers={setLayers}
activeLayer={activeLayer}
setActiveLayer={setActiveLayer}
onFlatten={handleFlattenLayers}
isProcessing={isProcessing}
onError={setError}
/>
<EyeCatalog
projectId={project?.id}
selection={selection}
feather={feather}
onApply={async () => {
// Reload image after applying eye
setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id));
await loadEdits(project.id);
}}
isProcessing={isProcessing}
/>
<div className="history-wrapper">
<History
edits={edits}
onRevert={handleRevert}
onReset={handleReset}
isProcessing={isProcessing}
/>
</div> </div>
</div> </div>
</div> </div>
)}
{/* AI Edit Panel */}
<div className="sidebar-panel">
<div className="panel-header" onClick={() => togglePanel('aiEdit')}>
<h3>AI Edit</h3>
<span className="panel-toggle">{collapsedPanels.aiEdit ? '▶' : '▼'}</span>
</div>
<div className={`panel-content ${collapsedPanels.aiEdit ? 'collapsed' : ''}`}>
<div className="control-group">
<label className="control-label">Mode</label>
<div className="control-row">
<button
className={`mode-btn ${mode === 'A' ? 'active' : ''}`}
onClick={() => setMode('A')}
>Patch Only</button>
<button
className={`mode-btn ${mode === 'B' ? 'active' : ''}`}
onClick={() => setMode('B')}
>With Context</button>
</div>
</div>
<div className="control-group">
<label className="control-label">Prompt</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Describe what to fix or change..."
/>
</div>
<button
className="action-btn"
onClick={handleFix}
disabled={isProcessing || !selection || !prompt.trim()}
>
{isProcessing ? 'Processing...' : 'Apply AI Edit'}
</button>
</div>
</div>
{/* Quick Actions Panel */}
<div className="sidebar-panel">
<div className="panel-header" onClick={() => togglePanel('actions')}>
<h3>Quick Actions</h3>
<span className="panel-toggle">{collapsedPanels.actions ? '▶' : '▼'}</span>
</div>
<div className={`panel-content ${collapsedPanels.actions ? 'collapsed' : ''}`}>
<button
className="action-btn secondary"
onClick={handleRemoveBackground}
disabled={isProcessing}
>Remove Background</button>
</div>
</div>
{/* Eyes Panel */}
<div className="sidebar-panel">
<div className="panel-header" onClick={() => togglePanel('eyes')}>
<h3>Eye Catalog</h3>
<span className="panel-toggle">{collapsedPanels.eyes ? '▶' : '▼'}</span>
</div>
<div className={`panel-content ${collapsedPanels.eyes ? 'collapsed' : ''}`}>
{eyes.length > 0 ? (
<>
<div className="eye-grid">
{eyes.map(eye => (
<div
key={eye.id}
className={`eye-item ${selectedEye?.id === eye.id ? 'selected' : ''}`}
onClick={() => setSelectedEye(eye)}
>
<img src={`/patches/${eye.id}/image?thumbnail=true`} alt={eye.name} />
</div>
))}
</div>
<button
className="action-btn"
onClick={handleApplyEye}
disabled={isProcessing || !selection || !selectedEye}
style={{ marginTop: '12px' }}
>Apply Eye to Selection</button>
</>
) : (
<p style={{ fontSize: '12px', color: '#888' }}>No eyes in catalog</p>
)}
</div>
</div>
{/* Layers Panel */}
<div className="sidebar-panel">
<div className="panel-header" onClick={() => togglePanel('layers')}>
<h3>Layers</h3>
<span className="panel-toggle">{collapsedPanels.layers ? '▶' : '▼'}</span>
</div>
<div className={`panel-content ${collapsedPanels.layers ? 'collapsed' : ''}`}>
<div className="layer-list">
<div className="layer-item active">
<button className="layer-visibility visible">👁</button>
<span className="layer-name">Background</span>
</div>
{layers.map((layer) => (
<div key={layer.id} className="layer-item">
<button className="layer-visibility visible">👁</button>
<span className="layer-name">{layer.name}</span>
</div>
))}
</div>
</div>
</div>
{/* History Panel */}
<div className="sidebar-panel">
<div className="panel-header" onClick={() => togglePanel('history')}>
<h3>History</h3>
<span className="panel-toggle">{collapsedPanels.history ? '▶' : '▼'}</span>
</div>
<div className={`panel-content ${collapsedPanels.history ? 'collapsed' : ''}`}>
<div className="history-list">
<div
className={`history-item ${currentEditIndex === -1 ? 'current' : ''}`}
onClick={handleReset}
>
Original
</div>
{edits.filter(e => e.status === 'completed').map((edit, i) => (
<div
key={edit.id}
className={`history-item ${currentEditIndex === i ? 'current' : ''}`}
onClick={() => handleRevert(edit.id)}
>
{edit.prompt?.substring(0, 30) || `Edit ${i + 1}`}
</div>
))}
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
); );
+30 -14
View File
@@ -1,15 +1,11 @@
/* ImageCanvas fills the entire canvas wrapper */
.canvas-container { .canvas-container {
position: relative; position: absolute;
width: 100%; top: 0;
height: 100%; left: 0;
min-height: calc(100vh - 180px); right: 0;
background-color: #2a2a2a; bottom: 0;
border: 2px solid #444;
border-radius: 8px;
overflow: hidden; overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
} }
.canvas-container canvas { .canvas-container canvas {
@@ -45,16 +41,36 @@
.selection-hint { .selection-hint {
position: absolute; position: absolute;
bottom: 10px; bottom: 40px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.8); background-color: rgba(0, 0, 0, 0.8);
color: #00ff00; color: #0088ff;
padding: 8px 16px; padding: 6px 12px;
border-radius: 4px; border-radius: 4px;
font-size: 12px; font-size: 11px;
z-index: 10; z-index: 10;
white-space: nowrap; white-space: nowrap;
pointer-events: none;
}
/* Clear selection button */
.clear-selection-btn {
position: absolute;
top: 8px;
right: 8px;
background-color: #cc3333;
color: white;
padding: 6px 12px;
font-size: 11px;
border: none;
border-radius: 3px;
cursor: pointer;
z-index: 10;
}
.clear-selection-btn:hover {
background-color: #dd4444;
} }
/* Zoom controls */ /* Zoom controls */