2 Commits

Author SHA1 Message Date
78e12e8309 feat: timeline and folder import 2026-04-07 00:42:22 +02:00
6d1b227fb9 feat: structure 2 2026-04-07 00:15:00 +02:00
28 changed files with 8419 additions and 119 deletions

View File

@@ -3,7 +3,7 @@ Database configuration and session management
"""
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import declarative_base
from sqlalchemy import event
from sqlalchemy import event, text
import logging
import os
from pathlib import Path
@@ -17,16 +17,23 @@ db_path = Path(settings.database_url.replace("sqlite+aiosqlite:///", ""))
db_path.parent.mkdir(parents=True, exist_ok=True)
# Create async engine
engine = create_async_engine(
settings.database_url,
echo=False, # Set to True for SQL debugging
pool_size=settings.performance.db_pool_size,
pool_recycle=settings.performance.db_pool_recycle,
connect_args={
"check_same_thread": False, # SQLite specific
"timeout": 30
} if "sqlite" in settings.database_url else {}
)
# SQLite doesn't support pool configuration
if "sqlite" in settings.database_url:
engine = create_async_engine(
settings.database_url,
echo=False, # Set to True for SQL debugging
connect_args={
"check_same_thread": False, # SQLite specific
"timeout": 30
}
)
else:
engine = create_async_engine(
settings.database_url,
echo=False, # Set to True for SQL debugging
pool_size=settings.performance.db_pool_size,
pool_recycle=settings.performance.db_pool_recycle
)
# Create async session factory
AsyncSessionLocal = async_sessionmaker(
@@ -57,10 +64,10 @@ async def init_db():
# Enable WAL mode for SQLite (better concurrency)
if "sqlite" in settings.database_url:
await conn.execute("PRAGMA journal_mode=WAL")
await conn.execute("PRAGMA synchronous=NORMAL")
await conn.execute("PRAGMA cache_size=10000")
await conn.execute("PRAGMA temp_store=MEMORY")
await conn.execute(text("PRAGMA journal_mode=WAL"))
await conn.execute(text("PRAGMA synchronous=NORMAL"))
await conn.execute(text("PRAGMA cache_size=10000"))
await conn.execute(text("PRAGMA temp_store=MEMORY"))
logger.info("Database initialized successfully")
@@ -69,7 +76,7 @@ async def create_fts_table():
if "sqlite" in settings.database_url:
async with engine.begin() as conn:
# Create FTS5 virtual table for full-text search
await conn.execute("""
await conn.execute(text("""
CREATE VIRTUAL TABLE IF NOT EXISTS photos_fts USING fts5(
photo_id UNINDEXED,
filename,
@@ -78,5 +85,5 @@ async def create_fts_table():
exif_text,
tokenize='unicode61'
)
""")
"""))
logger.info("FTS5 table created successfully")

View File

@@ -4,30 +4,96 @@ Folders API router
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List
from typing import List, Optional
from pydantic import BaseModel
import os
import uuid
from app.database import get_db
from app.models import Folder, SourceRoot
router = APIRouter()
class FolderCreate(BaseModel):
path: str
recursive: bool = True
watch: bool = False
class FolderResponse(BaseModel):
id: str
name: str
path: str
photo_count: int
@router.get("")
async def get_folders(db: AsyncSession = Depends(get_db)):
"""Get folder tree"""
result = await db.execute(select(Folder))
folders = result.scalars().all()
return folders
"""Get all source folders"""
# Get source roots instead of regular folders
result = await db.execute(select(SourceRoot).where(SourceRoot.is_active == True))
source_roots = result.scalars().all()
folders_list = []
for root in source_roots:
# Get photo count for this source root
folder_result = await db.execute(
select(Folder).where(Folder.source_root_id == root.id)
)
folders = folder_result.scalars().all()
photo_count = sum(f.photo_count for f in folders)
folders_list.append({
"id": root.id,
"name": root.name or os.path.basename(root.path),
"path": root.path,
"photo_count": photo_count
})
return {"folders": folders_list}
@router.post("")
async def create_folder(folder: FolderCreate, db: AsyncSession = Depends(get_db)):
"""Add a new source folder"""
# Check if path exists
if not os.path.exists(folder.path):
raise HTTPException(status_code=400, detail=f"Path does not exist: {folder.path}")
# Check if path is already added
result = await db.execute(select(SourceRoot).where(SourceRoot.path == folder.path))
existing = result.scalar_one_or_none()
if existing:
raise HTTPException(status_code=400, detail="Path already added as source folder")
# Create source root
source_root = SourceRoot(
id=str(uuid.uuid4()),
name=os.path.basename(folder.path),
path=folder.path
)
db.add(source_root)
await db.commit()
# Automatically trigger a scan for the new folder
from app.tasks.celery import celery_app
celery_app.send_task('scan_folder', args=[source_root.path, source_root.id])
return {
"id": source_root.id,
"name": source_root.name,
"path": source_root.path,
"photo_count": 0
}
@router.post("/{folder_id}/scan")
async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db)):
"""Trigger manual re-scan of folder"""
from app.tasks.scan import scan_folder as scan_task
"""Trigger manual re-scan of source root folder"""
from app.tasks.celery import celery_app
result = await db.execute(select(Folder).where(Folder.id == folder_id))
folder = result.scalar_one_or_none()
result = await db.execute(select(SourceRoot).where(SourceRoot.id == folder_id))
source_root = result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
if not source_root:
raise HTTPException(status_code=404, detail="Source folder not found")
scan_task.delay(folder.path)
return {"status": "success", "message": f"Scan queued for {folder.path}"}
# Queue scan task using the task name defined in the decorator
task = celery_app.send_task('scan_folder', args=[source_root.path, source_root.id])
return {"status": "success", "message": f"Scan queued for {source_root.path}", "task_id": task.id}

View File

@@ -48,14 +48,25 @@ async def trigger_scan():
return {"status": "success", "message": "Library scan started"}
@router.get("/scan/status")
async def get_scan_status():
async def get_scan_status(db: AsyncSession = Depends(get_db)):
"""Get current scan status"""
# This would connect to Celery to get task status
# For now, return a simple response
import redis
from app.config import settings
# Connect to Redis to get scan status
r = redis.Redis.from_url(settings.redis_url)
# Get scan status from Redis (set by worker tasks)
is_scanning = r.get('scan:active') == b'true'
current_folder = r.get('scan:current_folder')
processed_files = int(r.get('scan:processed_files') or 0)
total_files = int(r.get('scan:total_files') or 0)
errors = r.lrange('scan:errors', 0, -1)
return {
"status": "idle",
"progress": 0,
"current_folder": None,
"queued": 0,
"done": 0
"is_scanning": is_scanning,
"current_folder": current_folder.decode() if current_folder else None,
"processed_files": processed_files,
"total_files": total_files,
"errors": [e.decode() for e in errors] if errors else []
}

View File

@@ -10,6 +10,9 @@ from sqlalchemy import select, and_, or_, func
from sqlalchemy.ext.asyncio import AsyncSession
import json
import os
import logging
logger = logging.getLogger(__name__)
from app.database import get_db
from app.models import Photo, Folder, Tag, PhotoTag
@@ -164,10 +167,51 @@ async def get_thumbnail(
if not photo:
raise HTTPException(status_code=404, detail="Photo not found")
thumb_path = getattr(photo, f'thumb_{size}')
# Check if thumbnail exists, generate if not
thumb_dir = f"/data/thumbs/{photo_id}"
thumb_path = f"{thumb_dir}/{size}.webp"
if not thumb_path or not os.path.exists(thumb_path):
raise HTTPException(status_code=404, detail="Thumbnail not found")
if not os.path.exists(thumb_path):
# Generate thumbnail on demand
from app.tasks.thumbs import generate_thumbnails
generate_thumbnails.delay(photo_id)
# For now, return a placeholder or the original with reduced quality
if os.path.exists(photo.filepath):
from PIL import Image
try:
os.makedirs(thumb_dir, exist_ok=True)
img = Image.open(photo.filepath)
# Auto-rotate based on EXIF
from PIL import ExifTags
try:
for orientation in ExifTags.TAGS.keys():
if ExifTags.TAGS[orientation] == 'Orientation':
break
exif = img._getexif()
if exif is not None:
orient = exif.get(orientation)
if orient == 3:
img = img.rotate(180, expand=True)
elif orient == 6:
img = img.rotate(270, expand=True)
elif orient == 8:
img = img.rotate(90, expand=True)
except:
pass
# Generate thumbnail size
sizes = {'small': 150, 'medium': 400, 'large': 800}
target_size = sizes.get(size, 400)
img.thumbnail((target_size, target_size), Image.Resampling.LANCZOS)
# Save as WebP
img.save(thumb_path, 'WEBP', quality=85, optimize=True)
except Exception as e:
logger.error(f"Error generating thumbnail: {e}")
raise HTTPException(status_code=404, detail="Could not generate thumbnail")
# Check if we're behind Nginx
if os.environ.get('USE_X_ACCEL_REDIRECT'):

View File

@@ -40,7 +40,7 @@ class PhotoResponse(PhotoBase):
exif_json: Optional[str] = None
is_duplicate: bool = False
live_photo_video_id: Optional[str] = None
tags: List[Dict[str, Any]] = []
# tags: List[Dict[str, Any]] = [] # TODO: Enable when using eager loading
class Config:
orm_mode = True

View File

@@ -0,0 +1,15 @@
"""
Celery tasks module
"""
from app.tasks.celery import celery_app
from app.tasks.scan import scan_folder, scan_all_source_roots, watch_folders
from app.tasks.thumbs import generate_thumbnails, regenerate_all_thumbnails
__all__ = [
'celery_app',
'scan_folder',
'scan_all_source_roots',
'watch_folders',
'generate_thumbnails',
'regenerate_all_thumbnails'
]

View File

@@ -11,13 +11,26 @@ import json
from celery import shared_task
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
import pyvips
import rawpy
import imageio
from PIL import Image
import imageio
from pillow_heif import register_heif_opener
import ffmpeg
# Try to import optional libraries
try:
import pyvips
PYVIPS_AVAILABLE = True
except ImportError:
PYVIPS_AVAILABLE = False
print("pyvips not available, using Pillow for image processing")
try:
import rawpy
RAWPY_AVAILABLE = True
except ImportError:
RAWPY_AVAILABLE = False
print("rawpy not available, using exiftool for RAW preview extraction")
from app.database import AsyncSessionLocal
from app.models import Photo
from app.config import settings
@@ -40,24 +53,28 @@ def get_thumb_path(photo_id: str, size: str) -> str:
os.makedirs(thumb_dir, exist_ok=True)
return f"{thumb_dir}/{size}.{settings.thumbnails.format}"
def process_standard_image(filepath: str) -> pyvips.Image:
def process_standard_image(filepath: str) -> Image.Image:
"""Process standard image formats (JPEG, PNG, etc.)"""
return pyvips.Image.new_from_file(filepath, access='sequential')
return Image.open(filepath)
def process_raw_image(filepath: str) -> pyvips.Image:
def process_raw_image(filepath: str) -> Image.Image:
"""Process RAW image formats"""
try:
with rawpy.imread(filepath) as raw:
# Use half_size for faster processing
rgb = raw.postprocess(use_camera_wb=True, half_size=True)
# Convert numpy array to pyvips image
return pyvips.Image.new_from_array(rgb)
except Exception as e:
logger.error(f"Error processing RAW file {filepath}: {e}")
# Try to extract embedded JPEG preview
if RAWPY_AVAILABLE:
try:
with rawpy.imread(filepath) as raw:
# Use half_size for faster processing
rgb = raw.postprocess(use_camera_wb=True, half_size=True)
# Convert numpy array to PIL Image
return Image.fromarray(rgb, 'RGB')
except Exception as e:
logger.error(f"Error processing RAW file {filepath}: {e}")
# Try to extract embedded JPEG preview
return extract_raw_preview(filepath)
else:
# Use exiftool to extract embedded preview
return extract_raw_preview(filepath)
def extract_raw_preview(filepath: str) -> Optional[pyvips.Image]:
def extract_raw_preview(filepath: str) -> Optional[Image.Image]:
"""Extract embedded JPEG preview from RAW file"""
try:
# Use exiftool to extract preview
@@ -71,13 +88,13 @@ def extract_raw_preview(filepath: str) -> Optional[pyvips.Image]:
if result.returncode == 0 and result.stdout:
tmp.write(result.stdout)
tmp.flush()
return pyvips.Image.new_from_file(tmp.name, access='sequential')
return Image.open(tmp.name)
except Exception as e:
logger.error(f"Error extracting RAW preview from {filepath}: {e}")
return None
def process_heic_image(filepath: str) -> pyvips.Image:
def process_heic_image(filepath: str) -> Image.Image:
"""Process HEIC/HEIF image formats"""
try:
# Use pillow-heif to open the image
@@ -85,16 +102,12 @@ def process_heic_image(filepath: str) -> pyvips.Image:
# Convert to RGB if needed
if img.mode != 'RGB':
img = img.convert('RGB')
# Save to temp file and load with pyvips
import tempfile
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp:
img.save(tmp.name, 'JPEG')
return pyvips.Image.new_from_file(tmp.name, access='sequential')
return img
except Exception as e:
logger.error(f"Error processing HEIC file {filepath}: {e}")
raise
def process_video_thumbnail(filepath: str) -> pyvips.Image:
def process_video_thumbnail(filepath: str) -> Image.Image:
"""Extract thumbnail from video file"""
try:
# Get video duration
@@ -111,57 +124,50 @@ def process_video_thumbnail(filepath: str) -> pyvips.Image:
stream = ffmpeg.output(stream, tmp.name, vframes=1, format='image2', vcodec='mjpeg')
ffmpeg.run(stream, capture_stdout=True, capture_stderr=True)
return pyvips.Image.new_from_file(tmp.name, access='sequential')
return Image.open(tmp.name)
except Exception as e:
logger.error(f"Error extracting video thumbnail from {filepath}: {e}")
# Create a placeholder thumbnail
return create_placeholder_thumbnail('video')
def create_placeholder_thumbnail(media_type: str) -> pyvips.Image:
def create_placeholder_thumbnail(media_type: str) -> Image.Image:
"""Create a placeholder thumbnail for failed processing"""
# Create a simple gray placeholder
placeholder = pyvips.Image.black(640, 480)
placeholder = placeholder + [128, 128, 128] # Make it gray
return placeholder
img = Image.new('RGB', (640, 480), color=(128, 128, 128))
return img
def auto_rotate_image(image: pyvips.Image) -> pyvips.Image:
def auto_rotate_image(image: Image.Image) -> Image.Image:
"""Auto-rotate image based on EXIF orientation"""
try:
orientation = image.get('orientation')
rotation_map = {
3: 180,
6: 90,
8: 270
}
if orientation in rotation_map:
image = image.rot(rotation_map[orientation])
# Get EXIF data
exif = image._getexif()
if exif:
orientation = exif.get(274) # Orientation tag
rotation_map = {
3: 180,
6: 270, # Note: PIL uses different rotation values than vips
8: 90
}
if orientation in rotation_map:
image = image.rotate(rotation_map[orientation], expand=True)
except:
pass # No orientation data available
return image
def generate_thumbnail(image: pyvips.Image, size: int, output_path: str):
def generate_thumbnail(image: Image.Image, size: int, output_path: str):
"""Generate a thumbnail of the specified size"""
# Calculate scale to fit within size (longest edge)
width = image.width
height = image.height
if width > height:
scale = size / width
else:
scale = size / height
# Only downscale, never upscale
if scale < 1:
image = image.resize(scale)
# Maintain aspect ratio
image.thumbnail((size, size), Image.Resampling.LANCZOS)
# Save as WebP with specified quality
image.webpsave(
image.save(
output_path,
Q=settings.thumbnails.quality,
effort=4 # Balance between speed and compression
'WEBP',
quality=settings.thumbnails.quality,
method=4 # Balance between speed and compression
)
@shared_task(bind=True, name='generate_thumbnails')

View File

@@ -14,8 +14,8 @@ celery==5.3.6
flower==2.0.1
# Image processing
pyvips==2.2.2
rawpy==0.19.0
# pyvips==2.2.1 # Optional - having compatibility issues, using Pillow as fallback
# rawpy==0.19.0 # Optional - numpy compatibility issues, using Pillow as fallback
pillow==10.2.0
pillow-heif==0.15.0
imageio==2.33.1
@@ -38,8 +38,7 @@ python-dotenv==1.0.0
httpx==0.26.0
aiofiles==23.2.1
# Hashing and security
hashlib
# Security and authentication
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4

View File

@@ -20,10 +20,11 @@ services:
dockerfile: Dockerfile
container_name: mulita-backend
ports:
- "8000:8000"
- "8001:8000"
volumes:
- ./mulita.yml:/app/config/mulita.yml:ro
- ${PHOTO_DIRS:-./photos}:/photos:rw
- ~/Pictures:/host/Pictures:ro
- thumbs_data:/data/thumbs
- db_data:/data/db
- trash_data:/data/trash
@@ -48,6 +49,7 @@ services:
volumes:
- ./mulita.yml:/app/config/mulita.yml:ro
- ${PHOTO_DIRS:-./photos}:/photos:rw
- ~/Pictures:/host/Pictures:ro
- thumbs_data:/data/thumbs
- db_data:/data/db
- trash_data:/data/trash

6069
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -10,19 +10,6 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@tanstack/react-query": "^5.17.0",
"@tanstack/react-virtual": "^3.0.1",
"zustand": "^4.4.7",
"framer-motion": "^10.18.0",
"axios": "^1.6.5",
"date-fns": "^3.2.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.0",
"react-hotkeys-hook": "^4.4.3",
"leaflet": "^1.9.4",
"react-leaflet": "^4.2.1",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-checkbox": "^1.0.4",
@@ -39,13 +26,27 @@
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-query": "^5.17.0",
"@tanstack/react-virtual": "^3.0.1",
"axios": "^1.6.5",
"clsx": "^2.1.0",
"date-fns": "^3.2.0",
"framer-motion": "^10.18.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.303.0",
"react-intersection-observer": "^9.5.3"
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.4.3",
"react-intersection-observer": "^9.5.3",
"react-leaflet": "^4.2.1",
"tailwind-merge": "^2.2.0",
"zustand": "^4.4.7"
},
"devDependencies": {
"@tanstack/react-query-devtools": "^5.96.2",
"@types/leaflet": "^1.9.8",
"@types/react": "^18.2.46",
"@types/react-dom": "^18.2.18",
"@types/leaflet": "^1.9.8",
"@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0",
"@vitejs/plugin-react": "^4.2.1",
@@ -58,4 +59,4 @@
"typescript": "^5.3.3",
"vite": "^5.0.10"
}
}
}

View File

@@ -3,6 +3,10 @@ import { Timeline } from './components/timeline/Timeline'
import { LeftSidebar } from './components/layout/LeftSidebar'
import { RightSidebar } from './components/layout/RightSidebar'
import { TopBar } from './components/layout/TopBar'
import { ScanProgress } from './components/ScanProgress'
import { ToastContainer } from './components/ToastContainer'
import { KeyboardShortcuts } from './components/KeyboardShortcuts'
import { KeyboardHints } from './components/KeyboardHints'
import { usePhotoStore } from './store/photoStore'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
@@ -52,6 +56,18 @@ function App() {
<RightSidebar />
</div>
</div>
{/* Contextual Keyboard Hints */}
<KeyboardHints />
{/* Keyboard Shortcuts Legend */}
<KeyboardShortcuts />
{/* Scan Progress Indicator */}
<ScanProgress />
{/* Toast Notifications */}
<ToastContainer />
</div>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

View File

@@ -0,0 +1,45 @@
import { usePhotoStore } from '../store/photoStore'
export function KeyboardHints() {
const selectedCount = usePhotoStore((state) => state.selectedPhotos.length)
const hints = selectedCount > 0 ? [
{ key: '1-5', action: 'Rate' },
{ key: 'P', action: 'Pick' },
{ key: 'X', action: 'Reject' },
{ key: 'Delete', action: 'Trash' },
{ key: 'Esc', action: 'Deselect' },
] : [
{ key: '↑↓←→', action: 'Navigate' },
{ key: 'Click', action: 'Select' },
{ key: 'Shift+Click', action: 'Range' },
{ key: 'Ctrl+A', action: 'Select All' },
{ key: 'Space', action: 'Preview' },
]
return (
<div className="fixed top-14 left-1/2 z-20 -translate-x-1/2">
<div className="flex items-center gap-3 rounded-full border border-border bg-surface/90 px-4 py-2 shadow-lg backdrop-blur-sm">
{hints.map((hint, i) => (
<div key={i} className="flex items-center gap-1.5">
<kbd className="rounded bg-surface-offset px-2 py-0.5 text-xs font-medium text-text">
{hint.key}
</kbd>
<span className="text-xs text-text-muted">{hint.action}</span>
{i < hints.length - 1 && (
<span className="ml-2 text-text-faint"></span>
)}
</div>
))}
{selectedCount > 0 && (
<>
<span className="ml-2 text-text-faint"></span>
<span className="text-xs font-medium text-primary">
{selectedCount} selected
</span>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,143 @@
import { useState } from 'react'
import { Keyboard, ChevronRight, ChevronDown, X } from 'lucide-react'
import clsx from 'clsx'
interface Shortcut {
keys: string[]
description: string
category: 'navigation' | 'selection' | 'actions' | 'view'
}
const shortcuts: Shortcut[] = [
// Navigation
{ keys: ['↑', '↓', '←', '→'], description: 'Navigate photos', category: 'navigation' },
{ keys: ['Space'], description: 'Quick preview', category: 'navigation' },
{ keys: ['Enter'], description: 'Open in loupe view', category: 'navigation' },
// Selection
{ keys: ['Click'], description: 'Select photo', category: 'selection' },
{ keys: ['Shift', 'Click'], description: 'Select range', category: 'selection' },
{ keys: ['Ctrl/Cmd', 'Click'], description: 'Add to selection', category: 'selection' },
{ keys: ['Ctrl/Cmd', 'A'], description: 'Select all', category: 'selection' },
{ keys: ['Escape'], description: 'Clear selection', category: 'selection' },
// Actions
{ keys: ['1-5'], description: 'Set rating', category: 'actions' },
{ keys: ['0'], description: 'Remove rating', category: 'actions' },
{ keys: ['P'], description: 'Pick photo', category: 'actions' },
{ keys: ['X'], description: 'Reject photo', category: 'actions' },
{ keys: ['U'], description: 'Unflag photo', category: 'actions' },
{ keys: ['Delete'], description: 'Move to trash', category: 'actions' },
// View
{ keys: ['Tab'], description: 'Toggle left sidebar', category: 'view' },
{ keys: ['I'], description: 'Toggle info panel', category: 'view' },
{ keys: ['G'], description: 'Grid view', category: 'view' },
{ keys: ['E'], description: 'Loupe view', category: 'view' },
{ keys: ['F'], description: 'Fullscreen', category: 'view' },
]
export function KeyboardShortcuts() {
const [isExpanded, setIsExpanded] = useState(true)
const [isMinimized, setIsMinimized] = useState(false)
const categories = {
navigation: { label: 'Navigation', color: 'text-primary' },
selection: { label: 'Selection', color: 'text-pick' },
actions: { label: 'Actions', color: 'text-star' },
view: { label: 'View', color: 'text-text' },
}
if (isMinimized) {
return (
<div className="fixed bottom-4 left-4 z-30">
<button
onClick={() => setIsMinimized(false)}
className="flex items-center gap-2 rounded-lg border border-border bg-surface/90 px-3 py-2 text-sm backdrop-blur-sm hover:bg-surface"
title="Show keyboard shortcuts"
>
<Keyboard className="h-4 w-4 text-primary" />
<span className="text-text-muted">Shortcuts</span>
</button>
</div>
)
}
return (
<div className="fixed bottom-4 left-4 z-30 w-80 overflow-hidden rounded-lg border border-border bg-surface/95 shadow-xl backdrop-blur-sm">
{/* Header */}
<div className="flex items-center justify-between bg-surface-2 px-3 py-2">
<div className="flex items-center gap-2">
<Keyboard className="h-4 w-4 text-primary" />
<span className="text-sm font-medium text-text">Keyboard Shortcuts</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="rounded p-1 text-text-muted hover:bg-surface-offset hover:text-text"
title={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</button>
<button
onClick={() => setIsMinimized(true)}
className="rounded p-1 text-text-muted hover:bg-surface-offset hover:text-text"
title="Minimize"
>
<X className="h-3 w-3" />
</button>
</div>
</div>
{/* Content */}
{isExpanded && (
<div className="max-h-96 overflow-y-auto p-2">
{Object.entries(categories).map(([category, { label, color }]) => (
<div key={category} className="mb-3">
<h3 className={clsx('mb-1.5 text-xs font-semibold uppercase', color)}>
{label}
</h3>
<div className="space-y-1">
{shortcuts
.filter(s => s.category === category)
.map((shortcut, i) => (
<div
key={i}
className="flex items-center justify-between rounded px-2 py-1 hover:bg-surface-2"
>
<span className="text-xs text-text-muted">
{shortcut.description}
</span>
<div className="flex items-center gap-1">
{shortcut.keys.map((key, j) => (
<span key={j} className="flex items-center">
<kbd className="rounded bg-surface-offset px-1.5 py-0.5 text-[10px] font-medium text-text">
{key}
</kbd>
{j < shortcut.keys.length - 1 && (
<span className="mx-0.5 text-[10px] text-text-muted">+</span>
)}
</span>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
{/* Footer Hint */}
{!isExpanded && (
<div className="px-3 pb-2 pt-1">
<p className="text-xs text-text-muted">Click to expand shortcuts list</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,166 @@
import { useEffect, useState } from 'react'
import { FolderOpen, Loader2, Check, AlertCircle, X } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { library } from '../services/api'
import clsx from 'clsx'
interface ScanStatus {
is_scanning: boolean
current_folder?: string
processed_files: number
total_files: number
errors: string[]
}
export function ScanProgress() {
const [isVisible, setIsVisible] = useState(false)
const [isMinimized, setIsMinimized] = useState(false)
// Poll scan status every 2 seconds when scanning
const { data: scanStatus } = useQuery<ScanStatus>({
queryKey: ['scan-status'],
queryFn: async () => {
const response = await library.scanStatus()
return response
},
refetchInterval: (query) => {
// Poll every 2 seconds if scanning, otherwise every 10 seconds
return query.state.data?.is_scanning ? 2000 : 10000
},
enabled: true,
})
useEffect(() => {
if (scanStatus?.is_scanning) {
setIsVisible(true)
setIsMinimized(false)
} else if (isVisible && !scanStatus?.is_scanning && (scanStatus?.processed_files ?? 0) > 0) {
// Keep showing for 3 seconds after scan completes
setTimeout(() => {
if (!scanStatus?.is_scanning) {
setIsVisible(false)
}
}, 3000)
}
}, [scanStatus?.is_scanning, scanStatus?.processed_files, isVisible])
if (!isVisible || !scanStatus) return null
const progress = scanStatus.total_files > 0
? (scanStatus.processed_files / scanStatus.total_files) * 100
: 0
const isComplete = !scanStatus.is_scanning && scanStatus.processed_files > 0
const hasErrors = scanStatus.errors && scanStatus.errors.length > 0
return (
<div
className={clsx(
'fixed bottom-4 right-4 z-40 overflow-hidden rounded-lg border border-border bg-surface shadow-xl transition-all duration-300',
isMinimized ? 'w-12' : 'w-80'
)}
>
{/* Header */}
<div
className="flex cursor-pointer items-center justify-between bg-surface-2 px-3 py-2"
onClick={() => setIsMinimized(!isMinimized)}
>
<div className="flex items-center gap-2">
{scanStatus.is_scanning ? (
<Loader2 className="h-4 w-4 animate-spin text-primary" />
) : isComplete && !hasErrors ? (
<Check className="h-4 w-4 text-pick" />
) : hasErrors ? (
<AlertCircle className="h-4 w-4 text-reject" />
) : (
<FolderOpen className="h-4 w-4 text-text-muted" />
)}
{!isMinimized && (
<span className="text-sm font-medium text-text">
{scanStatus.is_scanning
? 'Scanning Folders'
: isComplete
? 'Scan Complete'
: 'Scan Status'}
</span>
)}
</div>
{!isMinimized && (
<button
onClick={(e) => {
e.stopPropagation()
setIsVisible(false)
}}
className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
>
<X className="h-3 w-3" />
</button>
)}
</div>
{/* Content */}
{!isMinimized && (
<div className="p-3">
{/* Current folder */}
{scanStatus.current_folder && (
<div className="mb-2 text-xs text-text-muted">
<span className="font-mono">{scanStatus.current_folder}</span>
</div>
)}
{/* Progress bar */}
<div className="mb-2">
<div className="h-1.5 overflow-hidden rounded-full bg-surface-offset">
<div
className={clsx(
'h-full transition-all duration-300',
scanStatus.is_scanning
? 'bg-primary'
: hasErrors
? 'bg-reject'
: 'bg-pick'
)}
style={{ width: `${progress}%` }}
/>
</div>
</div>
{/* Stats */}
<div className="flex items-center justify-between text-xs">
<span className="text-text-muted">
{scanStatus.processed_files} / {scanStatus.total_files || '?'} files
</span>
<span className={clsx(
'font-medium',
scanStatus.is_scanning ? 'text-primary' : hasErrors ? 'text-reject' : 'text-pick'
)}>
{scanStatus.is_scanning
? `${Math.round(progress)}%`
: isComplete
? 'Done'
: 'Idle'}
</span>
</div>
{/* Errors */}
{hasErrors && (
<div className="mt-2 max-h-20 overflow-y-auto rounded bg-reject/10 p-2">
<div className="text-xs text-reject">
{scanStatus.errors.slice(0, 3).map((error, i) => (
<div key={i} className="truncate">
{error}
</div>
))}
{scanStatus.errors.length > 3 && (
<div className="mt-1 text-text-muted">
+{scanStatus.errors.length - 3} more errors
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,95 @@
import { useEffect, useState } from 'react'
import { CheckCircle, XCircle, Info, AlertCircle, X } from 'lucide-react'
import clsx from 'clsx'
export interface Toast {
id: string
type: 'success' | 'error' | 'info' | 'warning'
title: string
message?: string
duration?: number
}
// Global toast state (in production, use Zustand or Context)
let toastListeners: ((toasts: Toast[]) => void)[] = []
let toastList: Toast[] = []
export const toast = {
success: (title: string, message?: string) => addToast('success', title, message),
error: (title: string, message?: string) => addToast('error', title, message),
info: (title: string, message?: string) => addToast('info', title, message),
warning: (title: string, message?: string) => addToast('warning', title, message),
}
function addToast(type: Toast['type'], title: string, message?: string, duration = 5000) {
const id = Date.now().toString()
const newToast: Toast = { id, type, title, message, duration }
toastList = [...toastList, newToast]
toastListeners.forEach(listener => listener(toastList))
// Auto-remove after duration
setTimeout(() => {
removeToast(id)
}, duration)
}
function removeToast(id: string) {
toastList = toastList.filter(t => t.id !== id)
toastListeners.forEach(listener => listener(toastList))
}
export function ToastContainer() {
const [toasts, setToasts] = useState<Toast[]>([])
useEffect(() => {
const listener = (newToasts: Toast[]) => setToasts(newToasts)
toastListeners.push(listener)
return () => {
toastListeners = toastListeners.filter(l => l !== listener)
}
}, [])
const icons = {
success: <CheckCircle className="h-5 w-5 text-pick" />,
error: <XCircle className="h-5 w-5 text-reject" />,
info: <Info className="h-5 w-5 text-primary" />,
warning: <AlertCircle className="h-5 w-5 text-star" />,
}
const colors = {
success: 'border-pick bg-pick/10',
error: 'border-reject bg-reject/10',
info: 'border-primary bg-primary/10',
warning: 'border-star bg-star/10',
}
return (
<div className="pointer-events-none fixed bottom-4 left-4 z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
className={clsx(
'pointer-events-auto flex items-start gap-3 rounded-lg border p-3 shadow-lg backdrop-blur-sm transition-all duration-300',
'animate-slide-up',
colors[toast.type]
)}
style={{ minWidth: '300px', maxWidth: '400px' }}
>
{icons[toast.type]}
<div className="flex-1">
<div className="font-medium text-text">{toast.title}</div>
{toast.message && (
<div className="mt-0.5 text-sm text-text-muted">{toast.message}</div>
)}
</div>
<button
onClick={() => removeToast(toast.id)}
className="pointer-events-auto rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,154 @@
import { useState } from 'react'
import { X, FolderPlus, AlertCircle } from 'lucide-react'
import clsx from 'clsx'
interface AddSourceFolderDialogProps {
isOpen: boolean
onClose: () => void
onAdd: (path: string, recursive: boolean) => Promise<void>
}
export function AddSourceFolderDialog({ isOpen, onClose, onAdd }: AddSourceFolderDialogProps) {
const [folderPath, setFolderPath] = useState('')
const [recursive, setRecursive] = useState(true)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!folderPath.trim()) {
setError('Please enter a folder path')
return
}
setIsLoading(true)
setError(null)
try {
await onAdd(folderPath.trim(), recursive)
setFolderPath('')
setRecursive(true)
onClose()
} catch (err: any) {
setError(err.message || 'Failed to add source folder')
} finally {
setIsLoading(false)
}
}
const handleClose = () => {
if (!isLoading) {
setFolderPath('')
setError(null)
onClose()
}
}
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={handleClose}
/>
{/* Dialog */}
<div className="relative z-10 w-full max-w-md rounded-lg bg-surface border border-border p-6 shadow-xl">
{/* Header */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<FolderPlus className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold text-text">Add Source Folder</h2>
</div>
<button
onClick={handleClose}
disabled={isLoading}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text disabled:opacity-50"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Path Input */}
<div>
<label htmlFor="folderPath" className="mb-1 block text-sm text-text-muted">
Folder Path
</label>
<input
id="folderPath"
type="text"
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
placeholder="/host/Pictures/your-folder"
disabled={isLoading}
className={clsx(
'w-full rounded border bg-bg px-3 py-2 text-sm text-text placeholder-text-faint',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
'disabled:opacity-50',
error ? 'border-reject' : 'border-border'
)}
/>
<div className="mt-1 space-y-1">
<p className="text-xs text-text-muted">
Use container paths. Your Pictures folder is available at:
</p>
<code className="block text-xs bg-surface-2 px-2 py-1 rounded text-primary">
/host/Pictures/
</code>
<p className="text-xs text-text-faint">
Example: /host/Pictures/MulitaTest
</p>
</div>
</div>
{/* Recursive Checkbox */}
<div className="flex items-center gap-2">
<input
id="recursive"
type="checkbox"
checked={recursive}
onChange={(e) => setRecursive(e.target.checked)}
disabled={isLoading}
className="h-4 w-4 rounded border-border bg-bg text-primary focus:ring-2 focus:ring-primary focus:ring-offset-0"
/>
<label htmlFor="recursive" className="text-sm text-text">
Include subfolders
</label>
</div>
{/* Error Message */}
{error && (
<div className="flex items-center gap-2 rounded bg-reject/10 p-3 text-sm text-reject">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>{error}</span>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={handleClose}
disabled={isLoading}
className="rounded bg-surface-2 px-4 py-2 text-sm text-text hover:bg-surface-offset disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading || !folderPath.trim()}
className="rounded bg-primary px-4 py-2 text-sm text-white hover:bg-primary/90 disabled:opacity-50"
>
{isLoading ? 'Adding...' : 'Add Folder'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,262 @@
import { useState } from 'react'
import {
ChevronRight,
ChevronDown,
Folder,
Image,
Calendar,
Star,
Flag,
Trash2,
Plus,
MoreHorizontal,
HardDrive,
RefreshCw
} from 'lucide-react'
import clsx from 'clsx'
import { AddSourceFolderDialog } from '../dialogs/AddSourceFolderDialog'
import { sourceFolders, library } from '../../services/api'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from '../ToastContainer'
interface TreeItem {
id: string
label: string
icon?: React.ReactNode
count?: number
children?: TreeItem[]
type?: 'folder' | 'heap' | 'special'
}
export function LeftSidebar() {
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
const [selectedItem, setSelectedItem] = useState<string | null>('all-photos')
const [showAddFolderDialog, setShowAddFolderDialog] = useState(false)
const [isScanning, setIsScanning] = useState(false)
const queryClient = useQueryClient()
// Fetch folders from API
const { data: foldersData, refetch: refetchFolders } = useQuery({
queryKey: ['folders'],
queryFn: sourceFolders.list,
})
// Mutation for adding folders
const addFolderMutation = useMutation({
mutationFn: async ({ path, recursive }: { path: string; recursive: boolean }) => {
// Add the folder
const folder = await sourceFolders.add(path, recursive)
// Trigger scan for the new folder
await sourceFolders.scan(folder.id)
return folder
},
onSuccess: (folder) => {
toast.success('Folder Added', `Scanning ${folder.name || folder.path}...`)
// Refetch folders list
refetchFolders()
// Refetch photos to show new ones
queryClient.invalidateQueries({ queryKey: ['photos'] })
},
onError: (error: any) => {
toast.error('Failed to Add Folder', error.message || 'An error occurred')
},
})
// Mutation for scanning all folders
const scanLibraryMutation = useMutation({
mutationFn: library.scan,
onMutate: () => {
setIsScanning(true)
toast.info('Scan Started', 'Scanning all folders for new photos...')
},
onSuccess: () => {
toast.success('Scan Complete', 'All folders have been scanned')
},
onError: (error: any) => {
toast.error('Scan Failed', error.message || 'Failed to scan folders')
},
onSettled: () => {
setIsScanning(false)
// Refetch photos after scan
queryClient.invalidateQueries({ queryKey: ['photos'] })
},
})
const handleAddFolder = async (path: string, recursive: boolean) => {
await addFolderMutation.mutateAsync({ path, recursive })
}
const handleScanAll = () => {
scanLibraryMutation.mutate()
}
const toggleExpanded = (id: string) => {
const newExpanded = new Set(expandedItems)
if (newExpanded.has(id)) {
newExpanded.delete(id)
} else {
newExpanded.add(id)
}
setExpandedItems(newExpanded)
}
const libraryTree: TreeItem[] = [
{
id: 'library',
label: 'Library',
icon: <HardDrive className="h-4 w-4" />,
children: [
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: 0 },
{ id: 'by-date', label: 'By Date', icon: <Calendar className="h-4 w-4" /> },
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 },
{ id: 'flagged', label: 'Flagged', icon: <Flag className="h-4 w-4" />, count: 0 },
{ id: 'trash', label: 'Trash', icon: <Trash2 className="h-4 w-4" />, count: 0 },
],
},
{
id: 'folders',
label: 'Folders',
icon: <Folder className="h-4 w-4" />,
children: foldersData?.folders?.map((folder: any) => ({
id: `folder-${folder.id}`,
label: folder.name || folder.path.split('/').pop() || folder.path,
icon: <Folder className="h-4 w-4" />,
count: folder.photo_count,
type: 'folder',
})) || [],
},
{
id: 'heaps',
label: 'Heaps',
icon: <Folder className="h-4 w-4" />,
children: [], // Will be populated from API
},
]
const renderTreeItem = (item: TreeItem, depth: number = 0) => {
const hasChildren = item.children && item.children.length > 0
const isExpanded = expandedItems.has(item.id)
const isSelected = selectedItem === item.id
return (
<div key={item.id}>
<div
className={clsx(
'group flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-sm',
isSelected ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
depth > 0 && 'text-[13px]'
)}
style={{ paddingLeft: `${8 + depth * 16}px` }}
onClick={() => {
setSelectedItem(item.id)
if (hasChildren) {
toggleExpanded(item.id)
}
}}
>
{/* Expand/Collapse Icon */}
{hasChildren ? (
<button
onClick={(e) => {
e.stopPropagation()
toggleExpanded(item.id)
}}
className="rounded p-0.5 hover:bg-surface-offset"
>
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</button>
) : (
<div className="w-4" />
)}
{/* Item Icon */}
{item.icon && (
<span className={clsx('flex-shrink-0', isSelected ? 'text-primary' : 'text-text-muted')}>
{item.icon}
</span>
)}
{/* Label */}
<span className="flex-1 truncate">{item.label}</span>
{/* Count Badge */}
{item.count !== undefined && item.count > 0 && (
<span className="rounded bg-surface-offset px-1.5 py-0.5 text-xs text-text-muted">
{item.count}
</span>
)}
{/* Actions (shown on hover) */}
{(item.id === 'folders' || item.id === 'heaps') && (
<button
onClick={(e) => {
e.stopPropagation()
// Handle add folder/heap
}}
className="invisible rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:visible"
>
<Plus className="h-3 w-3" />
</button>
)}
</div>
{/* Render Children */}
{hasChildren && isExpanded && (
<div>
{item.children!.map((child) => renderTreeItem(child, depth + 1))}
</div>
)}
</div>
)
}
return (
<div className="flex h-full flex-col bg-surface">
{/* Sidebar Header */}
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<h2 className="text-sm font-semibold text-text">Library</h2>
<button className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text">
<MoreHorizontal className="h-4 w-4" />
</button>
</div>
{/* Tree View */}
<div className="flex-1 overflow-y-auto py-2">
{libraryTree.map((item) => renderTreeItem(item))}
</div>
{/* Bottom Actions */}
<div className="border-t border-border p-3 space-y-2">
<button
onClick={() => setShowAddFolderDialog(true)}
className="flex w-full items-center gap-2 rounded bg-surface-2 px-3 py-2 text-sm text-text hover:bg-surface-offset"
>
<Plus className="h-4 w-4" />
Add Source Folder
</button>
{foldersData?.folders?.length > 0 && (
<button
onClick={handleScanAll}
disabled={isScanning}
className="flex w-full items-center gap-2 rounded bg-surface-2 px-3 py-2 text-sm text-text hover:bg-surface-offset disabled:opacity-50"
>
<RefreshCw className={clsx("h-4 w-4", isScanning && "animate-spin")} />
{isScanning ? 'Scanning...' : 'Scan All Folders'}
</button>
)}
</div>
{/* Add Source Folder Dialog */}
<AddSourceFolderDialog
isOpen={showAddFolderDialog}
onClose={() => setShowAddFolderDialog(false)}
onAdd={handleAddFolder}
/>
</div>
)
}

View File

@@ -0,0 +1,302 @@
import { useState } from 'react'
import {
X,
Star,
MapPin,
Camera,
Aperture,
Info,
ChevronDown,
ChevronRight,
Check,
Plus
} from 'lucide-react'
import clsx from 'clsx'
import { usePhotoStore } from '../../store/photoStore'
import { format } from 'date-fns'
export function RightSidebar() {
const { selectedPhotos, clearSelection } = usePhotoStore()
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(['basic', 'camera', 'location', 'tags'])
)
const [rating, setRating] = useState(0)
const [flagStatus, setFlagStatus] = useState<'none' | 'pick' | 'reject'>('none')
const toggleSection = (section: string) => {
const newExpanded = new Set(expandedSections)
if (newExpanded.has(section)) {
newExpanded.delete(section)
} else {
newExpanded.add(section)
}
setExpandedSections(newExpanded)
}
// Mock photo data - in real app, fetch based on selectedPhotos
const mockPhoto = selectedPhotos.length > 0 ? {
filename: 'IMG_1234.jpg',
size: '3.2 MB',
dimensions: '4032 × 3024',
dateTaken: new Date('2024-01-15T14:30:00'),
camera: 'Canon EOS R5',
lens: 'RF 24-70mm F2.8L IS USM',
iso: 400,
aperture: 'f/2.8',
shutterSpeed: '1/250',
focalLength: '50mm',
location: 'San Francisco, CA',
tags: ['landscape', 'sunset', 'golden hour'],
} : null
if (selectedPhotos.length === 0) {
return (
<div className="flex h-full items-center justify-center p-4 text-center">
<div className="text-text-muted">
<Info className="mx-auto mb-2 h-8 w-8" />
<p className="text-sm">Select photos to view details</p>
</div>
</div>
)
}
const multipleSelected = selectedPhotos.length > 1
return (
<div className="flex h-full flex-col bg-surface">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold text-text">
{multipleSelected
? `${selectedPhotos.length} Photos Selected`
: 'Photo Details'}
</h2>
<button
onClick={clearSelection}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Quick Actions */}
<div className="border-b border-border p-4">
{/* Rating Stars */}
<div className="mb-3">
<label className="mb-1 block text-xs text-text-muted">Rating</label>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((value) => (
<button
key={value}
onClick={() => setRating(rating === value ? 0 : value)}
className="p-0.5"
>
<Star
className={clsx(
'h-5 w-5 transition-colors',
value <= rating
? 'fill-star text-star'
: 'text-text-muted hover:text-star'
)}
/>
</button>
))}
</div>
</div>
{/* Flag Status */}
<div>
<label className="mb-1 block text-xs text-text-muted">Flag</label>
<div className="flex gap-2">
<button
onClick={() => setFlagStatus(flagStatus === 'pick' ? 'none' : 'pick')}
className={clsx(
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
flagStatus === 'pick'
? 'bg-pick/20 text-pick'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
)}
>
<Check className="h-3 w-3" />
Pick
</button>
<button
onClick={() => setFlagStatus(flagStatus === 'reject' ? 'none' : 'reject')}
className={clsx(
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
flagStatus === 'reject'
? 'bg-reject/20 text-reject'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
)}
>
<X className="h-3 w-3" />
Reject
</button>
</div>
</div>
</div>
{/* Metadata Sections */}
<div className="flex-1 overflow-y-auto">
{mockPhoto && (
<>
{/* Basic Info */}
<div className="border-b border-border">
<button
onClick={() => toggleSection('basic')}
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
>
<span className="font-medium text-text">Basic Info</span>
{expandedSections.has('basic') ? (
<ChevronDown className="h-4 w-4 text-text-muted" />
) : (
<ChevronRight className="h-4 w-4 text-text-muted" />
)}
</button>
{expandedSections.has('basic') && (
<div className="px-4 pb-3 text-xs">
<div className="grid grid-cols-2 gap-2">
<div>
<span className="text-text-muted">Filename:</span>
<p className="text-text">{mockPhoto.filename}</p>
</div>
<div>
<span className="text-text-muted">Size:</span>
<p className="text-text">{mockPhoto.size}</p>
</div>
<div>
<span className="text-text-muted">Dimensions:</span>
<p className="text-text">{mockPhoto.dimensions}</p>
</div>
<div>
<span className="text-text-muted">Date Taken:</span>
<p className="text-text">
{format(mockPhoto.dateTaken, 'MMM d, yyyy')}
</p>
</div>
</div>
</div>
)}
</div>
{/* Camera Info */}
<div className="border-b border-border">
<button
onClick={() => toggleSection('camera')}
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
>
<span className="font-medium text-text">Camera</span>
{expandedSections.has('camera') ? (
<ChevronDown className="h-4 w-4 text-text-muted" />
) : (
<ChevronRight className="h-4 w-4 text-text-muted" />
)}
</button>
{expandedSections.has('camera') && (
<div className="px-4 pb-3 text-xs">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Camera className="h-3 w-3 text-text-muted" />
<span className="text-text">{mockPhoto.camera}</span>
</div>
<div className="flex items-center gap-2">
<Aperture className="h-3 w-3 text-text-muted" />
<span className="text-text">{mockPhoto.lens}</span>
</div>
<div className="grid grid-cols-2 gap-2 mt-2">
<div>
<span className="text-text-muted">ISO:</span>
<span className="ml-1 text-text">{mockPhoto.iso}</span>
</div>
<div>
<span className="text-text-muted">Aperture:</span>
<span className="ml-1 text-text">{mockPhoto.aperture}</span>
</div>
<div>
<span className="text-text-muted">Shutter:</span>
<span className="ml-1 text-text">{mockPhoto.shutterSpeed}</span>
</div>
<div>
<span className="text-text-muted">Focal:</span>
<span className="ml-1 text-text">{mockPhoto.focalLength}</span>
</div>
</div>
</div>
</div>
)}
</div>
{/* Location */}
<div className="border-b border-border">
<button
onClick={() => toggleSection('location')}
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
>
<span className="font-medium text-text">Location</span>
{expandedSections.has('location') ? (
<ChevronDown className="h-4 w-4 text-text-muted" />
) : (
<ChevronRight className="h-4 w-4 text-text-muted" />
)}
</button>
{expandedSections.has('location') && (
<div className="px-4 pb-3">
<div className="flex items-center gap-2 text-xs">
<MapPin className="h-3 w-3 text-text-muted" />
<span className="text-text">{mockPhoto.location}</span>
</div>
</div>
)}
</div>
{/* Tags */}
<div className="border-b border-border">
<button
onClick={() => toggleSection('tags')}
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
>
<span className="font-medium text-text">Tags</span>
{expandedSections.has('tags') ? (
<ChevronDown className="h-4 w-4 text-text-muted" />
) : (
<ChevronRight className="h-4 w-4 text-text-muted" />
)}
</button>
{expandedSections.has('tags') && (
<div className="px-4 pb-3">
<div className="flex flex-wrap gap-1">
{mockPhoto.tags.map((tag) => (
<span
key={tag}
className="rounded bg-surface-2 px-2 py-0.5 text-xs text-text"
>
{tag}
</span>
))}
<button className="rounded bg-surface-2 px-2 py-0.5 text-xs text-text-muted hover:bg-surface-offset hover:text-text">
<Plus className="h-3 w-3" />
</button>
</div>
</div>
)}
</div>
</>
)}
</div>
{/* Footer Actions */}
{multipleSelected && (
<div className="border-t border-border p-3">
<div className="space-y-2">
<button className="w-full rounded bg-surface-2 px-3 py-1.5 text-sm text-text hover:bg-surface-offset">
Add to Heap
</button>
<button className="w-full rounded bg-surface-2 px-3 py-1.5 text-sm text-text hover:bg-surface-offset">
Export Selected
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,159 @@
import { useState } from 'react'
import {
Search,
Grid,
List,
SlidersHorizontal,
FolderOpen,
Upload,
Settings,
Menu,
Trash2
} from 'lucide-react'
import clsx from 'clsx'
import { usePhotoStore } from '../../store/photoStore'
import { photos } from '../../services/api'
import { toast } from '../ToastContainer'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import muliLogo from '../../assets/muli-logo.png'
export function TopBar() {
const [searchQuery, setSearchQuery] = useState('')
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const selectedPhotos = usePhotoStore((state) => state.selectedPhotos)
const clearSelection = usePhotoStore((state) => state.clearSelection)
const selectedCount = selectedPhotos.length
const queryClient = useQueryClient()
// Mutation for moving photos to trash
const trashPhotosMutation = useMutation({
mutationFn: async () => {
await photos.bulkUpdate(selectedPhotos, { trash: true })
},
onSuccess: () => {
toast.success('Moved to Trash', `${selectedCount} photo${selectedCount > 1 ? 's' : ''} moved to trash`)
clearSelection()
queryClient.invalidateQueries({ queryKey: ['photos'] })
},
onError: (error: any) => {
toast.error('Failed to Move to Trash', error.message || 'An error occurred')
},
})
return (
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
{/* Left Section - Menu and App Name */}
<div className="flex items-center gap-3">
<button
className="group relative rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
title="Toggle sidebar (Tab)"
>
<Menu className="h-5 w-5" />
<kbd className="absolute -bottom-5 left-1/2 -translate-x-1/2 rounded bg-surface-offset px-1 py-0.5 text-[9px] font-medium text-text opacity-0 group-hover:opacity-100">
Tab
</kbd>
</button>
<div className="flex items-center gap-2">
<img src={muliLogo} alt="Mulita" className="h-7 w-7 object-contain" />
<h1 className="text-lg font-semibold text-text">Mulita</h1>
</div>
{selectedCount > 0 && (
<>
<span className="rounded bg-primary/20 px-2 py-0.5 text-sm text-primary">
{selectedCount} selected
</span>
<button
onClick={() => trashPhotosMutation.mutate()}
disabled={trashPhotosMutation.isPending}
className="flex items-center gap-1 rounded bg-reject/20 px-2 py-0.5 text-sm text-reject hover:bg-reject/30 disabled:opacity-50"
title="Move to trash"
>
<Trash2 className="h-3.5 w-3.5" />
Trash
</button>
</>
)}
</div>
{/* Center Section - Search */}
<div className="flex max-w-xl flex-1 items-center px-8">
<div className="relative w-full">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-muted" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search photos..."
className="w-full rounded-md border border-border bg-bg py-1.5 pl-9 pr-3 text-sm text-text placeholder-text-muted focus:border-primary focus:outline-none"
/>
</div>
</div>
{/* Right Section - View Controls and Actions */}
<div className="flex items-center gap-2">
{/* View Mode Toggle */}
<div className="flex rounded-md border border-border">
<button
className={clsx(
'rounded-l-md px-2 py-1',
viewMode === 'grid'
? 'bg-primary text-white'
: 'bg-surface text-text-muted hover:bg-surface-2'
)}
onClick={() => setViewMode('grid')}
title="Grid view"
>
<Grid className="h-4 w-4" />
</button>
<button
className={clsx(
'rounded-r-md px-2 py-1',
viewMode === 'list'
? 'bg-primary text-white'
: 'bg-surface text-text-muted hover:bg-surface-2'
)}
onClick={() => setViewMode('list')}
title="List view"
>
<List className="h-4 w-4" />
</button>
</div>
{/* Filter Button */}
<button
className="group relative rounded p-1.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Filter photos (Ctrl+F)"
>
<SlidersHorizontal className="h-4 w-4" />
<kbd className="absolute -bottom-5 left-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-surface-offset px-1 py-0.5 text-[9px] font-medium text-text opacity-0 group-hover:opacity-100">
Ctrl+F
</kbd>
</button>
<div className="mx-1 h-6 w-px bg-border" />
{/* Action Buttons */}
<button
className="rounded p-1.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Add folder"
>
<FolderOpen className="h-4 w-4" />
</button>
<button
className="rounded p-1.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Import photos"
>
<Upload className="h-4 w-4" />
</button>
<button
className="rounded p-1.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Settings"
>
<Settings className="h-4 w-4" />
</button>
</div>
</header>
)
}

View File

@@ -0,0 +1,126 @@
import { useState, useEffect } from 'react'
import { Star, Check, X } from 'lucide-react'
import clsx from 'clsx'
interface Photo {
id: string
filepath: string
filename: string
width: number | null
height: number | null
taken_at: string | null
rating: number
is_picked: boolean
is_rejected: boolean
file_hash: string
media_type: string
}
interface PhotoThumbnailProps {
photo: Photo
size: number
isSelected: boolean
onClick: (e: React.MouseEvent) => void
}
export function PhotoThumbnail({ photo, size, isSelected, onClick }: PhotoThumbnailProps) {
const [imageError, setImageError] = useState(false)
const [imageLoaded, setImageLoaded] = useState(false)
// Generate thumbnail URL - assuming backend serves thumbnails at /api/photos/{id}/thumbnail
const thumbnailUrl = `http://localhost:8001/api/v1/photos/${photo.id}/thumb/medium`
// Calculate aspect ratio for proper sizing (default to 1:1 if dimensions unknown)
const aspectRatio = (photo.height && photo.width) ? photo.height / photo.width : 1
const displayHeight = size * Math.min(aspectRatio, 1.5) // Cap height at 1.5x width
const handleImageLoad = () => {
setImageLoaded(true)
}
const handleImageError = () => {
setImageError(true)
}
// Reset state when photo changes
useEffect(() => {
setImageError(false)
setImageLoaded(false)
}, [photo.id])
return (
<div
className={clsx(
'group relative cursor-pointer overflow-hidden rounded-sm transition-all duration-200',
'hover:ring-2 hover:ring-primary/50',
isSelected && 'ring-2 ring-primary shadow-lg',
!imageLoaded && 'bg-surface animate-pulse'
)}
style={{
width: size,
height: displayHeight,
}}
onClick={onClick}
title="Click to select • Shift+Click for range • Ctrl+Click to add"
>
{/* Thumbnail Image */}
{!imageError ? (
<img
src={thumbnailUrl}
alt={photo.filename}
className={clsx(
'h-full w-full object-cover transition-opacity duration-200',
imageLoaded ? 'opacity-100' : 'opacity-0'
)}
onLoad={handleImageLoad}
onError={handleImageError}
loading="lazy"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-surface text-text-muted">
<div className="text-center text-xs">
<div>Unable to load</div>
<div className="mt-1 font-mono text-[10px]">{photo.filename}</div>
</div>
</div>
)}
{/* Selection Indicator */}
{isSelected && (
<div className="absolute left-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-white">
<Check className="h-4 w-4" />
</div>
)}
{/* Rating Stars */}
{photo.rating > 0 && (
<div className="absolute bottom-1 left-1 flex gap-0.5">
{Array.from({ length: photo.rating }).map((_, i) => (
<Star
key={i}
className="h-3 w-3 fill-star text-star"
/>
))}
</div>
)}
{/* Flag Indicators */}
<div className="absolute bottom-1 right-1">
{photo.is_picked && (
<Check className="h-4 w-4 text-pick" />
)}
{photo.is_rejected && (
<X className="h-4 w-4 text-reject" />
)}
</div>
{/* File Type Badge for RAW/Video */}
{(photo.filepath.toLowerCase().match(/\.(raw|arw|cr2|cr3|nef|orf|rw2|dng)$/i) ||
photo.filepath.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i)) && (
<div className="absolute right-1 top-1 rounded bg-black/50 px-1 py-0.5 text-[10px] font-medium text-white">
{photo.filepath.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i) ? 'VIDEO' : 'RAW'}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,260 @@
import { useRef, useEffect, useMemo, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { usePhotoStore } from '../../store/photoStore'
import { PhotoThumbnail } from './PhotoThumbnail'
import { useQuery } from '@tanstack/react-query'
import axios from 'axios'
interface Photo {
id: string
filepath: string
filename: string
width: number | null
height: number | null
taken_at: string | null
rating: number
is_picked: boolean
is_rejected: boolean
file_hash: string
media_type: string
}
export function Timeline() {
const parentRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0)
const {
selectedPhotos,
lastSelectedIndex,
rangeStartIndex,
selectPhoto,
togglePhotoSelection,
clearSelection,
} = usePhotoStore()
// Helper function for range selection
const selectRange = (endIndex: number) => {
const startIndex = rangeStartIndex ?? lastSelectedIndex ?? 0
const minIndex = Math.min(startIndex, endIndex)
const maxIndex = Math.max(startIndex, endIndex)
// Select all photos in the range
for (let i = minIndex; i <= maxIndex; i++) {
if (i < photos.length && !selectedPhotos.includes(photos[i].id)) {
togglePhotoSelection(photos[i].id, i)
}
}
}
// Thumbnail size configuration
const thumbnailSize = 200 // Base size for thumbnails
const gap = 4
const padding = 16
// Calculate number of columns based on container width
const columns = useMemo(() => {
if (containerWidth === 0) return 4
return Math.floor((containerWidth - padding * 2) / (thumbnailSize + gap))
}, [containerWidth, thumbnailSize, gap, padding])
// Fetch photos from backend
const { data: photos = [], isLoading } = useQuery({
queryKey: ['photos'],
queryFn: async () => {
const response = await axios.get<{photos: Photo[], total: number}>('http://localhost:8001/api/v1/photos', {
params: {
limit: 1000,
offset: 0,
},
})
return response.data.photos || []
},
staleTime: 30000,
})
// Group photos into rows for grid layout
const rows = useMemo(() => {
const result: Photo[][] = []
for (let i = 0; i < photos.length; i += columns) {
result.push(photos.slice(i, i + columns))
}
return result
}, [photos, columns])
// Virtual scrolling setup
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => thumbnailSize + gap,
overscan: 5,
})
// Measure container width on mount and resize
useEffect(() => {
const measureWidth = () => {
if (parentRef.current) {
setContainerWidth(parentRef.current.clientWidth)
}
}
measureWidth()
window.addEventListener('resize', measureWidth)
return () => window.removeEventListener('resize', measureWidth)
}, [])
// Handle keyboard shortcuts for photo navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (photos.length === 0) return
const currentIndex = lastSelectedIndex ?? -1
switch (e.key) {
case 'ArrowUp':
e.preventDefault()
if (currentIndex > columns - 1) {
const newIndex = currentIndex - columns
if (e.shiftKey) {
selectRange(newIndex)
} else {
selectPhoto(photos[newIndex].id, newIndex)
}
}
break
case 'ArrowDown':
e.preventDefault()
if (currentIndex < photos.length - columns) {
const newIndex = Math.min(currentIndex + columns, photos.length - 1)
if (e.shiftKey) {
selectRange(newIndex)
} else {
selectPhoto(photos[newIndex].id, newIndex)
}
}
break
case 'ArrowLeft':
e.preventDefault()
if (currentIndex > 0) {
const newIndex = currentIndex - 1
if (e.shiftKey) {
selectRange(newIndex)
} else {
selectPhoto(photos[newIndex].id, newIndex)
}
}
break
case 'ArrowRight':
e.preventDefault()
if (currentIndex < photos.length - 1) {
const newIndex = currentIndex + 1
if (e.shiftKey) {
selectRange(newIndex)
} else {
selectPhoto(photos[newIndex].id, newIndex)
}
}
break
case 'a':
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
// Select all
photos.forEach((photo, index) => {
if (!selectedPhotos.includes(photo.id)) {
togglePhotoSelection(photo.id, index)
}
})
}
break
case 'Escape':
e.preventDefault()
clearSelection()
break
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [photos, selectedPhotos, lastSelectedIndex, columns, selectPhoto, togglePhotoSelection, selectRange, clearSelection])
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-text-muted">Loading photos...</div>
</div>
)
}
if (photos.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-text-muted">No photos found. Add a source folder to get started.</div>
</div>
)
}
return (
<div
ref={parentRef}
className="h-full overflow-auto bg-bg"
style={{ padding: `${padding}px` }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index]
return (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div
className="flex"
style={{ gap: `${gap}px` }}
>
{row.map((photo, colIndex) => {
const globalIndex = virtualRow.index * columns + colIndex
return (
<PhotoThumbnail
key={photo.id}
photo={photo}
size={thumbnailSize}
isSelected={selectedPhotos.includes(photo.id)}
onClick={(e) => {
if (e.shiftKey && lastSelectedIndex !== null) {
selectRange(globalIndex)
} else if (e.ctrlKey || e.metaKey) {
togglePhotoSelection(photo.id, globalIndex)
} else {
selectPhoto(photo.id, globalIndex)
}
}}
/>
)
})}
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,55 @@
import { useHotkeys } from 'react-hotkeys-hook'
interface KeyboardShortcutsProps {
onToggleLeftSidebar: () => void
onToggleRightSidebar: () => void
}
export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
const { onToggleLeftSidebar, onToggleRightSidebar } = props
// Toggle sidebars
useHotkeys('tab', (e) => {
e.preventDefault()
onToggleLeftSidebar()
})
useHotkeys('i', (e) => {
e.preventDefault()
onToggleRightSidebar()
})
// Navigation shortcuts
useHotkeys('g', () => {
// Go to grid view
console.log('Grid view')
})
useHotkeys('e', () => {
// Go to loupe view
console.log('Loupe view')
})
// Rating shortcuts
useHotkeys('1,2,3,4,5', (_e, handler) => {
const rating = parseInt(handler.keys![0])
console.log('Set rating:', rating)
})
useHotkeys('0', () => {
console.log('Remove rating')
})
// Flag shortcuts
useHotkeys('p', () => {
console.log('Pick photo')
})
useHotkeys('x', () => {
console.log('Reject photo')
})
useHotkeys('u', () => {
console.log('Unflag photo')
})
}

View File

@@ -0,0 +1,186 @@
import axios from 'axios'
const API_BASE_URL = 'http://localhost:8001/api/v1'
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
})
// Source Folders API
export const sourceFolders = {
list: async () => {
const response = await api.get('/folders')
return response.data
},
add: async (path: string, recursive: boolean = true) => {
const response = await api.post('/folders', {
path,
recursive,
watch: false, // Can be made configurable later
})
return response.data
},
scan: async (folderId: string) => {
const response = await api.post(`/folders/${folderId}/scan`)
return response.data
},
delete: async (folderId: string) => {
const response = await api.delete(`/folders/${folderId}`)
return response.data
},
}
// Photos API
export const photos = {
list: async (params?: {
limit?: number
offset?: number
folder_id?: string
heap_id?: string
rating?: number
flag?: string
}) => {
const response = await api.get('/photos', { params })
return response.data
},
get: async (photoId: string) => {
const response = await api.get(`/photos/${photoId}`)
return response.data
},
update: async (photoId: string, data: {
rating?: number
flag?: string
user_title?: string
user_notes?: string
}) => {
const response = await api.patch(`/photos/${photoId}`, data)
return response.data
},
bulkUpdate: async (photoIds: string[], data: {
rating?: number
flag?: string
heap_id?: string
trash?: boolean
}) => {
const response = await api.post('/photos/bulk', {
photo_ids: photoIds,
...data,
})
return response.data
},
getThumbnailUrl: (photoId: string, size: 'small' | 'medium' | 'large' = 'medium') => {
return `${API_BASE_URL}/photos/${photoId}/thumb/${size}`
},
getOriginalUrl: (photoId: string) => {
return `${API_BASE_URL}/photos/${photoId}/original`
},
}
// Library API
export const library = {
scan: async () => {
const response = await api.post('/library/scan')
return response.data
},
scanStatus: async () => {
const response = await api.get('/library/scan/status')
return response.data
},
stats: async () => {
const response = await api.get('/library/stats')
return response.data
},
}
// Heaps API
export const heaps = {
list: async () => {
const response = await api.get('/heaps')
return response.data
},
create: async (name: string, description?: string) => {
const response = await api.post('/heaps', {
name,
description,
})
return response.data
},
update: async (heapId: string, data: {
name?: string
description?: string
}) => {
const response = await api.patch(`/heaps/${heapId}`, data)
return response.data
},
delete: async (heapId: string) => {
const response = await api.delete(`/heaps/${heapId}`)
return response.data
},
}
// Tags API
export const tags = {
list: async () => {
const response = await api.get('/tags')
return response.data
},
create: async (name: string, color?: string) => {
const response = await api.post('/tags', {
name,
color,
})
return response.data
},
update: async (tagId: string, data: {
name?: string
color?: string
}) => {
const response = await api.patch(`/tags/${tagId}`, data)
return response.data
},
delete: async (tagId: string) => {
const response = await api.delete(`/tags/${tagId}`)
return response.data
},
}
// Trash API
export const trash = {
list: async () => {
const response = await api.get('/trash')
return response.data
},
restore: async (photoIds: string[]) => {
const response = await api.post('/trash/restore', {
photo_ids: photoIds,
})
return response.data
},
empty: async () => {
const response = await api.delete('/trash/empty')
return response.data
},
}
export default api

View File

@@ -0,0 +1,81 @@
import { create } from 'zustand'
interface Photo {
id: string
filename: string
filepath: string
media_type: string
width?: number
height?: number
taken_at?: string
thumb_small?: string
thumb_medium?: string
thumb_large?: string
rating: number
is_picked: boolean
is_rejected: boolean
}
interface PhotoStore {
photos: Photo[]
selectedPhotos: string[]
activePhotoId: string | null
lastSelectedIndex: number | null
rangeStartIndex: number | null
setPhotos: (photos: Photo[]) => void
selectPhoto: (id: string, index: number) => void
togglePhotoSelection: (id: string, index: number) => void
selectRange: (endIndex: number) => void
deselectPhoto: (id: string) => void
clearSelection: () => void
setActivePhoto: (id: string | null) => void
}
export const usePhotoStore = create<PhotoStore>((set) => ({
photos: [],
selectedPhotos: [],
activePhotoId: null,
lastSelectedIndex: null,
rangeStartIndex: null,
setPhotos: (photos) => set({ photos }),
selectPhoto: (id, index) => set({
selectedPhotos: [id],
activePhotoId: id,
lastSelectedIndex: index,
rangeStartIndex: index,
}),
togglePhotoSelection: (id, index) => set((state) => {
const isSelected = state.selectedPhotos.includes(id)
return {
selectedPhotos: isSelected
? state.selectedPhotos.filter(photoId => photoId !== id)
: [...state.selectedPhotos, id],
lastSelectedIndex: index,
rangeStartIndex: isSelected ? state.rangeStartIndex : index,
}
}),
selectRange: (endIndex) => {
// Note: The actual range selection logic should be handled in the Timeline component
// which has access to the photos array
set({
lastSelectedIndex: endIndex,
})
},
deselectPhoto: (id) => set((state) => ({
selectedPhotos: state.selectedPhotos.filter(photoId => photoId !== id)
})),
clearSelection: () => set({
selectedPhotos: [],
lastSelectedIndex: null,
rangeStartIndex: null,
}),
setActivePhoto: (id) => set({ activePhotoId: id }),
}))

29
frontend/src/types/images.d.ts vendored Normal file
View File

@@ -0,0 +1,29 @@
declare module '*.png' {
const value: string;
export default value;
}
declare module '*.jpg' {
const value: string;
export default value;
}
declare module '*.jpeg' {
const value: string;
export default value;
}
declare module '*.gif' {
const value: string;
export default value;
}
declare module '*.svg' {
const value: string;
export default value;
}
declare module '*.webp' {
const value: string;
export default value;
}

File diff suppressed because one or more lines are too long