feat: timeline and folder import

This commit is contained in:
2026-04-07 00:42:22 +02:00
parent 6d1b227fb9
commit 78e12e8309
20 changed files with 1036 additions and 75 deletions

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

@@ -24,6 +24,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
@@ -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

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

@@ -84,7 +84,7 @@ export function AddSourceFolderDialog({ isOpen, onClose, onAdd }: AddSourceFolde
type="text"
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
placeholder="/path/to/photos"
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',
@@ -93,9 +93,17 @@ export function AddSourceFolderDialog({ isOpen, onClose, onAdd }: AddSourceFolde
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 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 */}

View File

@@ -3,7 +3,6 @@ import {
ChevronRight,
ChevronDown,
Folder,
Image,
Calendar,
Star,
@@ -11,9 +10,14 @@ import {
Trash2,
Plus,
MoreHorizontal,
HardDrive
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
@@ -27,6 +31,65 @@ interface TreeItem {
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)
@@ -55,7 +118,13 @@ export function LeftSidebar() {
id: 'folders',
label: 'Folders',
icon: <Folder className="h-4 w-4" />,
children: [], // Will be populated from API
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',
@@ -162,12 +231,32 @@ export function LeftSidebar() {
</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">
<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

@@ -7,31 +7,71 @@ import {
FolderOpen,
Upload,
Settings,
Menu
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 selectedCount = usePhotoStore((state) => state.selectedPhotos.length)
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="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
title="Toggle sidebar"
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>
<h1 className="text-lg font-semibold text-text">Mulita</h1>
<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>
<>
<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>
@@ -81,10 +121,13 @@ export function TopBar() {
{/* Filter Button */}
<button
className="rounded p-1.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Filter photos"
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" />

View File

@@ -4,14 +4,16 @@ import clsx from 'clsx'
interface Photo {
id: string
path: string
filepath: string
filename: string
width: number
height: number
date_taken: string | null
width: number | null
height: number | null
taken_at: string | null
rating: number
flag: string | null
hash: string
is_picked: boolean
is_rejected: boolean
file_hash: string
media_type: string
}
interface PhotoThumbnailProps {
@@ -28,8 +30,8 @@ export function PhotoThumbnail({ photo, size, isSelected, onClick }: PhotoThumbn
// 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
// 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 = () => {
@@ -49,7 +51,7 @@ export function PhotoThumbnail({ photo, size, isSelected, onClick }: PhotoThumbn
return (
<div
className={clsx(
'relative cursor-pointer overflow-hidden rounded-sm transition-all duration-200',
'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'
@@ -59,6 +61,7 @@ export function PhotoThumbnail({ photo, size, isSelected, onClick }: PhotoThumbn
height: displayHeight,
}}
onClick={onClick}
title="Click to select • Shift+Click for range • Ctrl+Click to add"
>
{/* Thumbnail Image */}
{!imageError ? (
@@ -102,22 +105,20 @@ export function PhotoThumbnail({ photo, size, isSelected, onClick }: PhotoThumbn
)}
{/* 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>
)}
<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.path.toLowerCase().match(/\.(raw|arw|cr2|cr3|nef|orf|rw2|dng)$/i) ||
photo.path.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i)) && (
{(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.path.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i) ? 'VIDEO' : 'RAW'}
{photo.filepath.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i) ? 'VIDEO' : 'RAW'}
</div>
)}
</div>

View File

@@ -8,14 +8,16 @@ import axios from 'axios'
interface Photo {
id: string
path: string
filepath: string
filename: string
width: number
height: number
date_taken: string | null
width: number | null
height: number | null
taken_at: string | null
rating: number
flag: string | null
hash: string
is_picked: boolean
is_rejected: boolean
file_hash: string
media_type: string
}
export function Timeline() {
@@ -61,13 +63,13 @@ export function Timeline() {
const { data: photos = [], isLoading } = useQuery({
queryKey: ['photos'],
queryFn: async () => {
const response = await axios.get<Photo[]>('http://localhost:8001/api/v1/photos', {
const response = await axios.get<{photos: Photo[], total: number}>('http://localhost:8001/api/v1/photos', {
params: {
limit: 1000,
offset: 0,
},
})
return response.data
return response.data.photos || []
},
staleTime: 30000,
})

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

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