Merge pull request #10 from outis1one/claude/add-eye-detection-feature-69XOl

Fix UX issues and add Eye Catalog UI
This commit is contained in:
outis1one
2026-01-25 00:20:10 -05:00
committed by GitHub
6 changed files with 529 additions and 36 deletions
+19 -7
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
import ImageCanvas from './components/ImageCanvas';
import Controls from './components/Controls';
import History from './components/History';
import EyeCatalog from './components/EyeCatalog';
import { projectsApi, editsApi } from './utils/api';
import './App.css';
@@ -144,7 +145,7 @@ function App() {
};
// Handle revert
const handleRevert = async (editId) => {
const handleRevert = useCallback(async (editId) => {
if (!project) return;
try {
@@ -162,10 +163,10 @@ function App() {
} finally {
setIsProcessing(false);
}
};
}, [project]);
// Handle reset
const handleReset = async () => {
const handleReset = useCallback(async () => {
if (!project) return;
try {
@@ -183,7 +184,7 @@ function App() {
} finally {
setIsProcessing(false);
}
};
}, [project]);
// Handle download
const handleDownload = async () => {
@@ -225,7 +226,7 @@ function App() {
await handleRevert(previousEdit.id);
setCurrentEditIndex(currentEditIndex - 1);
}
}, [project, isProcessing, currentEditIndex]);
}, [project, isProcessing, currentEditIndex, handleReset, handleRevert]);
// Handle redo (Ctrl+Y)
const handleRedo = useCallback(async () => {
@@ -237,7 +238,7 @@ function App() {
const nextEdit = completedEdits[currentEditIndex + 1];
await handleRevert(nextEdit.id);
setCurrentEditIndex(currentEditIndex + 1);
}, [project, isProcessing, currentEditIndex]);
}, [project, isProcessing, currentEditIndex, handleRevert]);
// Keyboard shortcut handler
useEffect(() => {
@@ -343,12 +344,23 @@ function App() {
prompt={prompt}
onPromptChange={setPrompt}
onFix={handleFix}
onClear={() => setSelection(null)}
onDownload={handleDownload}
isProcessing={isProcessing}
hasSelection={!!selection}
/>
<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}
-8
View File
@@ -11,7 +11,6 @@ const Controls = ({
prompt,
onPromptChange,
onFix,
onClear,
onDownload,
isProcessing,
hasSelection,
@@ -107,13 +106,6 @@ const Controls = ({
>
{isProcessing ? 'Processing...' : 'Fix Selected Area'}
</button>
<button
className="clear-btn"
onClick={onClear}
disabled={isProcessing}
>
Clear Selection
</button>
</div>
<div className="control-section">
+209
View File
@@ -0,0 +1,209 @@
.eye-catalog {
background-color: #2a2a2a;
border-radius: 8px;
border: 1px solid #444;
padding: 16px;
}
.catalog-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.catalog-header h3 {
margin: 0;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.upload-toggle-btn {
background-color: #00aa00;
color: white;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
}
.upload-toggle-btn:hover {
background-color: #00cc00;
}
.catalog-error {
background-color: #ff4444;
color: white;
padding: 8px 12px;
border-radius: 4px;
margin-bottom: 12px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
}
.catalog-error button {
background: none;
color: white;
padding: 0;
font-size: 16px;
}
.upload-form {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
padding: 12px;
background-color: #333;
border-radius: 4px;
}
.upload-form input[type="text"],
.upload-form input[type="file"] {
padding: 8px;
border: 1px solid #555;
border-radius: 4px;
background-color: #2a2a2a;
color: white;
font-size: 13px;
}
.upload-btn {
background-color: #0066ff;
color: white;
padding: 10px;
font-weight: 600;
}
.upload-btn:hover:not(:disabled) {
background-color: #0055dd;
}
.patches-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 8px;
max-height: 200px;
overflow-y: auto;
padding: 4px;
}
.no-patches {
grid-column: 1 / -1;
text-align: center;
color: #888;
font-size: 13px;
padding: 20px;
}
.patch-item {
position: relative;
background-color: #333;
border: 2px solid transparent;
border-radius: 6px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s;
}
.patch-item:hover {
border-color: #666;
}
.patch-item.selected {
border-color: #00ff00;
box-shadow: 0 0 8px rgba(0, 255, 0, 0.3);
}
.patch-item img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
display: block;
}
.patch-info {
padding: 4px 6px;
background-color: rgba(0, 0, 0, 0.7);
position: absolute;
bottom: 0;
left: 0;
right: 0;
}
.patch-name {
font-size: 10px;
font-weight: 600;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.patch-tags {
font-size: 9px;
color: #888;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.delete-patch-btn {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
padding: 0;
background-color: rgba(255, 0, 0, 0.8);
color: white;
font-size: 14px;
line-height: 1;
border-radius: 50%;
opacity: 0;
transition: opacity 0.2s;
}
.patch-item:hover .delete-patch-btn {
opacity: 1;
}
.apply-section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #444;
}
.selected-preview {
font-size: 13px;
margin-bottom: 10px;
color: #00ff00;
}
.apply-btn {
width: 100%;
background-color: #0066ff;
color: white;
padding: 12px;
font-size: 14px;
font-weight: 600;
}
.apply-btn:hover:not(:disabled) {
background-color: #0055dd;
}
.apply-btn:disabled {
background-color: #333;
color: #666;
}
.apply-hint {
font-size: 11px;
color: #888;
margin-top: 8px;
text-align: center;
}
+193
View File
@@ -0,0 +1,193 @@
import React, { useState, useEffect } from 'react';
import { patchesApi } from '../utils/api';
import './EyeCatalog.css';
const EyeCatalog = ({
projectId,
selection,
feather,
onApply,
isProcessing,
}) => {
const [patches, setPatches] = useState([]);
const [selectedPatch, setSelectedPatch] = useState(null);
const [isUploading, setIsUploading] = useState(false);
const [showUpload, setShowUpload] = useState(false);
const [uploadName, setUploadName] = useState('');
const [uploadFile, setUploadFile] = useState(null);
const [uploadTags, setUploadTags] = useState('');
const [error, setError] = useState(null);
// Load patches on mount
useEffect(() => {
loadPatches();
}, []);
const loadPatches = async () => {
try {
const data = await patchesApi.list('eyes');
setPatches(data);
} catch (err) {
console.error('Failed to load patches:', err);
}
};
const handleUpload = async () => {
if (!uploadFile || !uploadName.trim()) {
setError('Please provide a name and select a file');
return;
}
try {
setIsUploading(true);
setError(null);
await patchesApi.create(uploadName.trim(), uploadFile, 'eyes', uploadTags);
await loadPatches();
setShowUpload(false);
setUploadName('');
setUploadFile(null);
setUploadTags('');
} catch (err) {
setError(`Upload failed: ${err.message}`);
} finally {
setIsUploading(false);
}
};
const handleApply = async () => {
if (!selectedPatch || !selection || !projectId) {
setError('Please select an eye and make a selection on the image');
return;
}
try {
setError(null);
await patchesApi.apply(projectId, selectedPatch.id, selection.bbox, feather);
onApply();
setSelectedPatch(null);
} catch (err) {
setError(`Failed to apply: ${err.message}`);
}
};
const handleDelete = async (patchId) => {
if (!window.confirm('Delete this eye from the catalog?')) return;
try {
await patchesApi.delete(patchId);
await loadPatches();
if (selectedPatch?.id === patchId) {
setSelectedPatch(null);
}
} catch (err) {
setError(`Delete failed: ${err.message}`);
}
};
return (
<div className="eye-catalog">
<div className="catalog-header">
<h3>Eye Catalog</h3>
<button
className="upload-toggle-btn"
onClick={() => setShowUpload(!showUpload)}
>
{showUpload ? 'Cancel' : '+ Add Eye'}
</button>
</div>
{error && (
<div className="catalog-error">
{error}
<button onClick={() => setError(null)}>×</button>
</div>
)}
{showUpload && (
<div className="upload-form">
<input
type="text"
placeholder="Eye name (e.g., 'Greek Serene Left')"
value={uploadName}
onChange={(e) => setUploadName(e.target.value)}
/>
<input
type="file"
accept="image/*"
onChange={(e) => setUploadFile(e.target.files[0])}
/>
<input
type="text"
placeholder="Tags (e.g., 'greek,serene,left')"
value={uploadTags}
onChange={(e) => setUploadTags(e.target.value)}
/>
<button
className="upload-btn"
onClick={handleUpload}
disabled={isUploading || !uploadFile || !uploadName.trim()}
>
{isUploading ? 'Uploading...' : 'Upload Eye'}
</button>
</div>
)}
<div className="patches-grid">
{patches.length === 0 ? (
<div className="no-patches">
No eyes in catalog yet. Click "+ Add Eye" to upload.
</div>
) : (
patches.map((patch) => (
<div
key={patch.id}
className={`patch-item ${selectedPatch?.id === patch.id ? 'selected' : ''}`}
onClick={() => setSelectedPatch(patch)}
>
<img
src={patchesApi.getImageUrl(patch.id, true)}
alt={patch.name}
onError={(e) => {
e.target.src = patchesApi.getImageUrl(patch.id, false);
}}
/>
<div className="patch-info">
<span className="patch-name">{patch.name}</span>
{patch.tags && <span className="patch-tags">{patch.tags}</span>}
</div>
<button
className="delete-patch-btn"
onClick={(e) => {
e.stopPropagation();
handleDelete(patch.id);
}}
>
×
</button>
</div>
))
)}
</div>
{selectedPatch && (
<div className="apply-section">
<div className="selected-preview">
<strong>Selected:</strong> {selectedPatch.name}
</div>
<button
className="apply-btn"
onClick={handleApply}
disabled={isProcessing || !selection}
>
{!selection ? 'Draw selection first' : 'Apply to Selection'}
</button>
<p className="apply-hint">
Draw a rectangle/ellipse where you want to place the eye
</p>
</div>
)}
</div>
);
};
export default EyeCatalog;
+48 -21
View File
@@ -32,10 +32,10 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
// Re-center and rescale the image if it exists
const bgImage = canvas.backgroundImage;
if (bgImage) {
// Allow scaling up to fill the canvas
const scale = Math.min(
(width - 40) / bgImage.width,
(height - 40) / bgImage.height,
1
(height - 40) / bgImage.height
);
bgImage.scale(scale);
bgImage.set({
@@ -290,7 +290,7 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
};
const setupLassoMode = (canvas) => {
let line, points = [];
let polygon, points = [], drawingLine;
canvas.on('mouse:down', (e) => {
// If clicking on existing selection, enable transform mode
@@ -314,23 +314,16 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
const pointer = canvas.getPointer(e.e);
points = [{ x: pointer.x, y: pointer.y }];
line = new fabric.Polyline(points, {
fill: 'rgba(255, 255, 255, 0.3)',
// Create a temporary line for visual feedback while drawing
drawingLine = new fabric.Polyline(points, {
fill: 'transparent',
stroke: '#00ff00',
strokeWidth: 2,
selectable: true,
hasControls: true,
hasBorders: true,
lockRotation: false,
cornerColor: '#00ff00',
cornerSize: 10,
transparentCorners: false,
borderColor: '#00ff00',
borderScaleFactor: 2,
selectable: false,
evented: false,
});
canvas.add(line);
setCurrentSelection(line);
canvas.add(drawingLine);
});
canvas.on('mouse:move', (e) => {
@@ -339,16 +332,50 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
const pointer = canvas.getPointer(e.e);
points.push({ x: pointer.x, y: pointer.y });
line.set({ points: points });
// Remove old line and create new one with updated points
canvas.remove(drawingLine);
drawingLine = new fabric.Polyline([...points], {
fill: 'transparent',
stroke: '#00ff00',
strokeWidth: 2,
selectable: false,
evented: false,
});
canvas.add(drawingLine);
canvas.renderAll();
});
canvas.on('mouse:up', () => {
if (isDrawing && !isTransformMode) {
if (isDrawing && !isTransformMode && points.length > 2) {
setIsDrawing(false);
lassoPoints.current = points;
canvas.setActiveObject(line);
updateSelection(line, 'lasso');
lassoPoints.current = [...points];
// Remove drawing line
canvas.remove(drawingLine);
// Create final polygon with fill
polygon = new fabric.Polygon(points, {
fill: 'rgba(255, 255, 255, 0.3)',
stroke: '#00ff00',
strokeWidth: 2,
selectable: true,
hasControls: true,
hasBorders: true,
lockRotation: false,
cornerColor: '#00ff00',
cornerSize: 10,
transparentCorners: false,
borderColor: '#00ff00',
borderScaleFactor: 2,
});
canvas.add(polygon);
canvas.setActiveObject(polygon);
setCurrentSelection(polygon);
updateSelection(polygon, 'lasso');
} else if (isDrawing) {
setIsDrawing(false);
canvas.remove(drawingLine);
}
});
+60
View File
@@ -86,4 +86,64 @@ export const editsApi = {
},
};
export const patchesApi = {
// List all patches (eyes)
list: async (category = null, tags = null) => {
const params = new URLSearchParams();
if (category) params.append('category', category);
if (tags) params.append('tags', tags);
const response = await api.get(`/patches/?${params.toString()}`);
return response.data;
},
// Get a specific patch
get: async (patchId) => {
const response = await api.get(`/patches/${patchId}`);
return response.data;
},
// Upload a new patch (eye)
create: async (name, file, category = 'eyes', tags = '') => {
const formData = new FormData();
formData.append('name', name);
formData.append('source_type', 'imported');
formData.append('category', category);
formData.append('tags', tags);
formData.append('file', file);
const response = await api.post('/patches/', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
},
// Apply a patch to the project
apply: async (projectId, patchId, bbox, featherPx = 5) => {
const formData = new FormData();
formData.append('project_id', projectId);
formData.append('patch_id', patchId);
formData.append('bbox', JSON.stringify(bbox));
formData.append('feather_px', featherPx);
const response = await api.post('/patches/apply', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
},
// Delete a patch
delete: async (patchId) => {
const response = await api.delete(`/patches/${patchId}`);
return response.data;
},
// Get patch image URL
getImageUrl: (patchId, thumbnail = false) =>
`${API_BASE_URL}/patches/${patchId}/image${thumbnail ? '?thumbnail=true' : ''}`,
};
export default api;