feat: structure 2

This commit is contained in:
2026-04-07 00:15:00 +02:00
parent 46a0d7aba8
commit 6d1b227fb9
15 changed files with 7433 additions and 94 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.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import declarative_base from sqlalchemy.orm import declarative_base
from sqlalchemy import event from sqlalchemy import event, text
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
@@ -17,15 +17,22 @@ db_path = Path(settings.database_url.replace("sqlite+aiosqlite:///", ""))
db_path.parent.mkdir(parents=True, exist_ok=True) db_path.parent.mkdir(parents=True, exist_ok=True)
# Create async engine # Create async engine
# 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( engine = create_async_engine(
settings.database_url, settings.database_url,
echo=False, # Set to True for SQL debugging echo=False, # Set to True for SQL debugging
pool_size=settings.performance.db_pool_size, pool_size=settings.performance.db_pool_size,
pool_recycle=settings.performance.db_pool_recycle, pool_recycle=settings.performance.db_pool_recycle
connect_args={
"check_same_thread": False, # SQLite specific
"timeout": 30
} if "sqlite" in settings.database_url else {}
) )
# Create async session factory # Create async session factory
@@ -57,10 +64,10 @@ async def init_db():
# Enable WAL mode for SQLite (better concurrency) # Enable WAL mode for SQLite (better concurrency)
if "sqlite" in settings.database_url: if "sqlite" in settings.database_url:
await conn.execute("PRAGMA journal_mode=WAL") await conn.execute(text("PRAGMA journal_mode=WAL"))
await conn.execute("PRAGMA synchronous=NORMAL") await conn.execute(text("PRAGMA synchronous=NORMAL"))
await conn.execute("PRAGMA cache_size=10000") await conn.execute(text("PRAGMA cache_size=10000"))
await conn.execute("PRAGMA temp_store=MEMORY") await conn.execute(text("PRAGMA temp_store=MEMORY"))
logger.info("Database initialized successfully") logger.info("Database initialized successfully")
@@ -69,7 +76,7 @@ async def create_fts_table():
if "sqlite" in settings.database_url: if "sqlite" in settings.database_url:
async with engine.begin() as conn: async with engine.begin() as conn:
# Create FTS5 virtual table for full-text search # 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( CREATE VIRTUAL TABLE IF NOT EXISTS photos_fts USING fts5(
photo_id UNINDEXED, photo_id UNINDEXED,
filename, filename,
@@ -78,5 +85,5 @@ async def create_fts_table():
exif_text, exif_text,
tokenize='unicode61' tokenize='unicode61'
) )
""") """))
logger.info("FTS5 table created successfully") logger.info("FTS5 table created successfully")

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: mulita-backend container_name: mulita-backend
ports: ports:
- "8000:8000" - "8001:8000"
volumes: volumes:
- ./mulita.yml:/app/config/mulita.yml:ro - ./mulita.yml:/app/config/mulita.yml:ro
- ${PHOTO_DIRS:-./photos}:/photos:rw - ${PHOTO_DIRS:-./photos}:/photos:rw

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" "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
}, },
"dependencies": { "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-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
@@ -39,13 +26,27 @@
"@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7", "@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", "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": { "devDependencies": {
"@tanstack/react-query-devtools": "^5.96.2",
"@types/leaflet": "^1.9.8",
"@types/react": "^18.2.46", "@types/react": "^18.2.46",
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18",
"@types/leaflet": "^1.9.8",
"@typescript-eslint/eslint-plugin": "^6.17.0", "@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0", "@typescript-eslint/parser": "^6.17.0",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",

View File

@@ -0,0 +1,146 @@
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="/path/to/photos"
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'
)}
/>
<p className="mt-1 text-xs text-text-muted">
Enter the full path to the folder containing your photos
</p>
</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,173 @@
import { useState } from 'react'
import {
ChevronRight,
ChevronDown,
Folder,
Image,
Calendar,
Star,
Flag,
Trash2,
Plus,
MoreHorizontal,
HardDrive
} from 'lucide-react'
import clsx from 'clsx'
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 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: [], // Will be populated from API
},
{
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">
<button 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>
</div>
</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,116 @@
import { useState } from 'react'
import {
Search,
Grid,
List,
SlidersHorizontal,
FolderOpen,
Upload,
Settings,
Menu
} from 'lucide-react'
import clsx from 'clsx'
import { usePhotoStore } from '../../store/photoStore'
export function TopBar() {
const [searchQuery, setSearchQuery] = useState('')
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const selectedCount = usePhotoStore((state) => state.selectedPhotos.length)
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="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
title="Toggle sidebar"
>
<Menu className="h-5 w-5" />
</button>
<h1 className="text-lg font-semibold text-text">Mulita</h1>
{selectedCount > 0 && (
<span className="rounded bg-primary/20 px-2 py-0.5 text-sm text-primary">
{selectedCount} selected
</span>
)}
</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="rounded p-1.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Filter photos"
>
<SlidersHorizontal className="h-4 w-4" />
</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,125 @@
import { useState, useEffect } from 'react'
import { Star, Check, X } from 'lucide-react'
import clsx from 'clsx'
interface Photo {
id: string
path: string
filename: string
width: number
height: number
date_taken: string | null
rating: number
flag: string | null
hash: 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
const aspectRatio = photo.height / photo.width
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(
'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}
>
{/* 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 */}
{photo.flag && (
<div className="absolute bottom-1 right-1">
{photo.flag === 'pick' && (
<Check className="h-4 w-4 text-pick" />
)}
{photo.flag === 'reject' && (
<X className="h-4 w-4 text-reject" />
)}
</div>
)}
{/* File Type Badge for RAW/Video */}
{(photo.path.toLowerCase().match(/\.(raw|arw|cr2|cr3|nef|orf|rw2|dng)$/i) ||
photo.path.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.path.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i) ? 'VIDEO' : 'RAW'}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,258 @@
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
path: string
filename: string
width: number
height: number
date_taken: string | null
rating: number
flag: string | null
hash: 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<Photo[]>('http://localhost:8001/api/v1/photos', {
params: {
limit: 1000,
offset: 0,
},
})
return response.data
},
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,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 }),
}))

File diff suppressed because one or more lines are too long