feat: timeline and folder import
This commit is contained in:
@@ -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}
|
||||
@@ -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 []
|
||||
}
|
||||
@@ -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'):
|
||||
|
||||
@@ -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
|
||||
|
||||
15
backend/app/tasks/__init__.py
Normal file
15
backend/app/tasks/__init__.py
Normal 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'
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
BIN
frontend/src/assets/muli-logo.png
Normal file
BIN
frontend/src/assets/muli-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 821 KiB |
45
frontend/src/components/KeyboardHints.tsx
Normal file
45
frontend/src/components/KeyboardHints.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
143
frontend/src/components/KeyboardShortcuts.tsx
Normal file
143
frontend/src/components/KeyboardShortcuts.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
166
frontend/src/components/ScanProgress.tsx
Normal file
166
frontend/src/components/ScanProgress.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
95
frontend/src/components/ToastContainer.tsx
Normal file
95
frontend/src/components/ToastContainer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
186
frontend/src/services/api.ts
Normal file
186
frontend/src/services/api.ts
Normal 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
29
frontend/src/types/images.d.ts
vendored
Normal 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
Reference in New Issue
Block a user