Compare commits
2 Commits
46a0d7aba8
...
78e12e8309
| Author | SHA1 | Date | |
|---|---|---|---|
| 78e12e8309 | |||
| 6d1b227fb9 |
@@ -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,16 +17,23 @@ 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
|
||||||
engine = create_async_engine(
|
# SQLite doesn't support pool configuration
|
||||||
|
if "sqlite" in settings.database_url:
|
||||||
|
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_recycle=settings.performance.db_pool_recycle,
|
|
||||||
connect_args={
|
connect_args={
|
||||||
"check_same_thread": False, # SQLite specific
|
"check_same_thread": False, # SQLite specific
|
||||||
"timeout": 30
|
"timeout": 30
|
||||||
} if "sqlite" in settings.database_url else {}
|
}
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
engine = create_async_engine(
|
||||||
|
settings.database_url,
|
||||||
|
echo=False, # Set to True for SQL debugging
|
||||||
|
pool_size=settings.performance.db_pool_size,
|
||||||
|
pool_recycle=settings.performance.db_pool_recycle
|
||||||
|
)
|
||||||
|
|
||||||
# Create async session factory
|
# Create async session factory
|
||||||
AsyncSessionLocal = async_sessionmaker(
|
AsyncSessionLocal = async_sessionmaker(
|
||||||
@@ -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")
|
||||||
@@ -4,30 +4,96 @@ Folders API router
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.database import get_db
|
||||||
from app.models import Folder, SourceRoot
|
from app.models import Folder, SourceRoot
|
||||||
|
|
||||||
router = APIRouter()
|
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("")
|
@router.get("")
|
||||||
async def get_folders(db: AsyncSession = Depends(get_db)):
|
async def get_folders(db: AsyncSession = Depends(get_db)):
|
||||||
"""Get folder tree"""
|
"""Get all source folders"""
|
||||||
result = await db.execute(select(Folder))
|
# Get source roots instead of regular folders
|
||||||
folders = result.scalars().all()
|
result = await db.execute(select(SourceRoot).where(SourceRoot.is_active == True))
|
||||||
return folders
|
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")
|
@router.post("/{folder_id}/scan")
|
||||||
async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db)):
|
async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
"""Trigger manual re-scan of folder"""
|
"""Trigger manual re-scan of source root folder"""
|
||||||
from app.tasks.scan import scan_folder as scan_task
|
from app.tasks.celery import celery_app
|
||||||
|
|
||||||
result = await db.execute(select(Folder).where(Folder.id == folder_id))
|
result = await db.execute(select(SourceRoot).where(SourceRoot.id == folder_id))
|
||||||
folder = result.scalar_one_or_none()
|
source_root = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not folder:
|
if not source_root:
|
||||||
raise HTTPException(status_code=404, detail="Folder not found")
|
raise HTTPException(status_code=404, detail="Source folder not found")
|
||||||
|
|
||||||
scan_task.delay(folder.path)
|
# Queue scan task using the task name defined in the decorator
|
||||||
return {"status": "success", "message": f"Scan queued for {folder.path}"}
|
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"}
|
return {"status": "success", "message": "Library scan started"}
|
||||||
|
|
||||||
@router.get("/scan/status")
|
@router.get("/scan/status")
|
||||||
async def get_scan_status():
|
async def get_scan_status(db: AsyncSession = Depends(get_db)):
|
||||||
"""Get current scan status"""
|
"""Get current scan status"""
|
||||||
# This would connect to Celery to get task status
|
import redis
|
||||||
# For now, return a simple response
|
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 {
|
return {
|
||||||
"status": "idle",
|
"is_scanning": is_scanning,
|
||||||
"progress": 0,
|
"current_folder": current_folder.decode() if current_folder else None,
|
||||||
"current_folder": None,
|
"processed_files": processed_files,
|
||||||
"queued": 0,
|
"total_files": total_files,
|
||||||
"done": 0
|
"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
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Photo, Folder, Tag, PhotoTag
|
from app.models import Photo, Folder, Tag, PhotoTag
|
||||||
@@ -164,10 +167,51 @@ async def get_thumbnail(
|
|||||||
if not photo:
|
if not photo:
|
||||||
raise HTTPException(status_code=404, detail="Photo not found")
|
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):
|
if not os.path.exists(thumb_path):
|
||||||
raise HTTPException(status_code=404, detail="Thumbnail not found")
|
# 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
|
# Check if we're behind Nginx
|
||||||
if os.environ.get('USE_X_ACCEL_REDIRECT'):
|
if os.environ.get('USE_X_ACCEL_REDIRECT'):
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class PhotoResponse(PhotoBase):
|
|||||||
exif_json: Optional[str] = None
|
exif_json: Optional[str] = None
|
||||||
is_duplicate: bool = False
|
is_duplicate: bool = False
|
||||||
live_photo_video_id: Optional[str] = None
|
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:
|
class Config:
|
||||||
orm_mode = True
|
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'
|
||||||
|
]
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,11 @@ 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
|
||||||
|
- ~/Pictures:/host/Pictures:ro
|
||||||
- thumbs_data:/data/thumbs
|
- thumbs_data:/data/thumbs
|
||||||
- db_data:/data/db
|
- db_data:/data/db
|
||||||
- trash_data:/data/trash
|
- trash_data:/data/trash
|
||||||
@@ -48,6 +49,7 @@ services:
|
|||||||
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
|
||||||
|
- ~/Pictures:/host/Pictures:ro
|
||||||
- thumbs_data:/data/thumbs
|
- thumbs_data:/data/thumbs
|
||||||
- db_data:/data/db
|
- db_data:/data/db
|
||||||
- trash_data:/data/trash
|
- trash_data:/data/trash
|
||||||
|
|||||||
6069
frontend/package-lock.json
generated
Normal file
6069
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { Timeline } from './components/timeline/Timeline'
|
|||||||
import { LeftSidebar } from './components/layout/LeftSidebar'
|
import { LeftSidebar } from './components/layout/LeftSidebar'
|
||||||
import { RightSidebar } from './components/layout/RightSidebar'
|
import { RightSidebar } from './components/layout/RightSidebar'
|
||||||
import { TopBar } from './components/layout/TopBar'
|
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 { usePhotoStore } from './store/photoStore'
|
||||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||||
|
|
||||||
@@ -52,6 +56,18 @@ function App() {
|
|||||||
<RightSidebar />
|
<RightSidebar />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Contextual Keyboard Hints */}
|
||||||
|
<KeyboardHints />
|
||||||
|
|
||||||
|
{/* Keyboard Shortcuts Legend */}
|
||||||
|
<KeyboardShortcuts />
|
||||||
|
|
||||||
|
{/* Scan Progress Indicator */}
|
||||||
|
<ScanProgress />
|
||||||
|
|
||||||
|
{/* Toast Notifications */}
|
||||||
|
<ToastContainer />
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
154
frontend/src/components/dialogs/AddSourceFolderDialog.tsx
Normal file
154
frontend/src/components/dialogs/AddSourceFolderDialog.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { X, FolderPlus, AlertCircle } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
interface AddSourceFolderDialogProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onAdd: (path: string, recursive: boolean) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddSourceFolderDialog({ isOpen, onClose, onAdd }: AddSourceFolderDialogProps) {
|
||||||
|
const [folderPath, setFolderPath] = useState('')
|
||||||
|
const [recursive, setRecursive] = useState(true)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!folderPath.trim()) {
|
||||||
|
setError('Please enter a folder path')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onAdd(folderPath.trim(), recursive)
|
||||||
|
setFolderPath('')
|
||||||
|
setRecursive(true)
|
||||||
|
onClose()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to add source folder')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (!isLoading) {
|
||||||
|
setFolderPath('')
|
||||||
|
setError(null)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={handleClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dialog */}
|
||||||
|
<div className="relative z-10 w-full max-w-md rounded-lg bg-surface border border-border p-6 shadow-xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FolderPlus className="h-5 w-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold text-text">Add Source Folder</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Path Input */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="folderPath" className="mb-1 block text-sm text-text-muted">
|
||||||
|
Folder Path
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="folderPath"
|
||||||
|
type="text"
|
||||||
|
value={folderPath}
|
||||||
|
onChange={(e) => setFolderPath(e.target.value)}
|
||||||
|
placeholder="/host/Pictures/your-folder"
|
||||||
|
disabled={isLoading}
|
||||||
|
className={clsx(
|
||||||
|
'w-full rounded border bg-bg px-3 py-2 text-sm text-text placeholder-text-faint',
|
||||||
|
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
|
||||||
|
'disabled:opacity-50',
|
||||||
|
error ? 'border-reject' : 'border-border'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="mt-1 space-y-1">
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
Use container paths. Your Pictures folder is available at:
|
||||||
|
</p>
|
||||||
|
<code className="block text-xs bg-surface-2 px-2 py-1 rounded text-primary">
|
||||||
|
/host/Pictures/
|
||||||
|
</code>
|
||||||
|
<p className="text-xs text-text-faint">
|
||||||
|
Example: /host/Pictures/MulitaTest
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recursive Checkbox */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="recursive"
|
||||||
|
type="checkbox"
|
||||||
|
checked={recursive}
|
||||||
|
onChange={(e) => setRecursive(e.target.checked)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="h-4 w-4 rounded border-border bg-bg text-primary focus:ring-2 focus:ring-primary focus:ring-offset-0"
|
||||||
|
/>
|
||||||
|
<label htmlFor="recursive" className="text-sm text-text">
|
||||||
|
Include subfolders
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 rounded bg-reject/10 p-3 text-sm text-reject">
|
||||||
|
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="rounded bg-surface-2 px-4 py-2 text-sm text-text hover:bg-surface-offset disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !folderPath.trim()}
|
||||||
|
className="rounded bg-primary px-4 py-2 text-sm text-white hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Adding...' : 'Add Folder'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
262
frontend/src/components/layout/LeftSidebar.tsx
Normal file
262
frontend/src/components/layout/LeftSidebar.tsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
Folder,
|
||||||
|
Image,
|
||||||
|
Calendar,
|
||||||
|
Star,
|
||||||
|
Flag,
|
||||||
|
Trash2,
|
||||||
|
Plus,
|
||||||
|
MoreHorizontal,
|
||||||
|
HardDrive,
|
||||||
|
RefreshCw
|
||||||
|
} from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { AddSourceFolderDialog } from '../dialogs/AddSourceFolderDialog'
|
||||||
|
import { sourceFolders, library } from '../../services/api'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { toast } from '../ToastContainer'
|
||||||
|
|
||||||
|
interface TreeItem {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
icon?: React.ReactNode
|
||||||
|
count?: number
|
||||||
|
children?: TreeItem[]
|
||||||
|
type?: 'folder' | 'heap' | 'special'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeftSidebar() {
|
||||||
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
|
||||||
|
const [selectedItem, setSelectedItem] = useState<string | null>('all-photos')
|
||||||
|
const [showAddFolderDialog, setShowAddFolderDialog] = useState(false)
|
||||||
|
const [isScanning, setIsScanning] = useState(false)
|
||||||
|
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
// Fetch folders from API
|
||||||
|
const { data: foldersData, refetch: refetchFolders } = useQuery({
|
||||||
|
queryKey: ['folders'],
|
||||||
|
queryFn: sourceFolders.list,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mutation for adding folders
|
||||||
|
const addFolderMutation = useMutation({
|
||||||
|
mutationFn: async ({ path, recursive }: { path: string; recursive: boolean }) => {
|
||||||
|
// Add the folder
|
||||||
|
const folder = await sourceFolders.add(path, recursive)
|
||||||
|
// Trigger scan for the new folder
|
||||||
|
await sourceFolders.scan(folder.id)
|
||||||
|
return folder
|
||||||
|
},
|
||||||
|
onSuccess: (folder) => {
|
||||||
|
toast.success('Folder Added', `Scanning ${folder.name || folder.path}...`)
|
||||||
|
// Refetch folders list
|
||||||
|
refetchFolders()
|
||||||
|
// Refetch photos to show new ones
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error('Failed to Add Folder', error.message || 'An error occurred')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mutation for scanning all folders
|
||||||
|
const scanLibraryMutation = useMutation({
|
||||||
|
mutationFn: library.scan,
|
||||||
|
onMutate: () => {
|
||||||
|
setIsScanning(true)
|
||||||
|
toast.info('Scan Started', 'Scanning all folders for new photos...')
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Scan Complete', 'All folders have been scanned')
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error('Scan Failed', error.message || 'Failed to scan folders')
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
setIsScanning(false)
|
||||||
|
// Refetch photos after scan
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleAddFolder = async (path: string, recursive: boolean) => {
|
||||||
|
await addFolderMutation.mutateAsync({ path, recursive })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScanAll = () => {
|
||||||
|
scanLibraryMutation.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleExpanded = (id: string) => {
|
||||||
|
const newExpanded = new Set(expandedItems)
|
||||||
|
if (newExpanded.has(id)) {
|
||||||
|
newExpanded.delete(id)
|
||||||
|
} else {
|
||||||
|
newExpanded.add(id)
|
||||||
|
}
|
||||||
|
setExpandedItems(newExpanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
const libraryTree: TreeItem[] = [
|
||||||
|
{
|
||||||
|
id: 'library',
|
||||||
|
label: 'Library',
|
||||||
|
icon: <HardDrive className="h-4 w-4" />,
|
||||||
|
children: [
|
||||||
|
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: 0 },
|
||||||
|
{ id: 'by-date', label: 'By Date', icon: <Calendar className="h-4 w-4" /> },
|
||||||
|
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 },
|
||||||
|
{ id: 'flagged', label: 'Flagged', icon: <Flag className="h-4 w-4" />, count: 0 },
|
||||||
|
{ id: 'trash', label: 'Trash', icon: <Trash2 className="h-4 w-4" />, count: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'folders',
|
||||||
|
label: 'Folders',
|
||||||
|
icon: <Folder className="h-4 w-4" />,
|
||||||
|
children: foldersData?.folders?.map((folder: any) => ({
|
||||||
|
id: `folder-${folder.id}`,
|
||||||
|
label: folder.name || folder.path.split('/').pop() || folder.path,
|
||||||
|
icon: <Folder className="h-4 w-4" />,
|
||||||
|
count: folder.photo_count,
|
||||||
|
type: 'folder',
|
||||||
|
})) || [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'heaps',
|
||||||
|
label: 'Heaps',
|
||||||
|
icon: <Folder className="h-4 w-4" />,
|
||||||
|
children: [], // Will be populated from API
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const renderTreeItem = (item: TreeItem, depth: number = 0) => {
|
||||||
|
const hasChildren = item.children && item.children.length > 0
|
||||||
|
const isExpanded = expandedItems.has(item.id)
|
||||||
|
const isSelected = selectedItem === item.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.id}>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'group flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-sm',
|
||||||
|
isSelected ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
||||||
|
depth > 0 && 'text-[13px]'
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: `${8 + depth * 16}px` }}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedItem(item.id)
|
||||||
|
if (hasChildren) {
|
||||||
|
toggleExpanded(item.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Expand/Collapse Icon */}
|
||||||
|
{hasChildren ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
toggleExpanded(item.id)
|
||||||
|
}}
|
||||||
|
className="rounded p-0.5 hover:bg-surface-offset"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="w-4" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Item Icon */}
|
||||||
|
{item.icon && (
|
||||||
|
<span className={clsx('flex-shrink-0', isSelected ? 'text-primary' : 'text-text-muted')}>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<span className="flex-1 truncate">{item.label}</span>
|
||||||
|
|
||||||
|
{/* Count Badge */}
|
||||||
|
{item.count !== undefined && item.count > 0 && (
|
||||||
|
<span className="rounded bg-surface-offset px-1.5 py-0.5 text-xs text-text-muted">
|
||||||
|
{item.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions (shown on hover) */}
|
||||||
|
{(item.id === 'folders' || item.id === 'heaps') && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
// Handle add folder/heap
|
||||||
|
}}
|
||||||
|
className="invisible rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:visible"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Render Children */}
|
||||||
|
{hasChildren && isExpanded && (
|
||||||
|
<div>
|
||||||
|
{item.children!.map((child) => renderTreeItem(child, depth + 1))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col bg-surface">
|
||||||
|
{/* Sidebar Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||||
|
<h2 className="text-sm font-semibold text-text">Library</h2>
|
||||||
|
<button className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tree View */}
|
||||||
|
<div className="flex-1 overflow-y-auto py-2">
|
||||||
|
{libraryTree.map((item) => renderTreeItem(item))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Actions */}
|
||||||
|
<div className="border-t border-border p-3 space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddFolderDialog(true)}
|
||||||
|
className="flex w-full items-center gap-2 rounded bg-surface-2 px-3 py-2 text-sm text-text hover:bg-surface-offset"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add Source Folder
|
||||||
|
</button>
|
||||||
|
{foldersData?.folders?.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleScanAll}
|
||||||
|
disabled={isScanning}
|
||||||
|
className="flex w-full items-center gap-2 rounded bg-surface-2 px-3 py-2 text-sm text-text hover:bg-surface-offset disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={clsx("h-4 w-4", isScanning && "animate-spin")} />
|
||||||
|
{isScanning ? 'Scanning...' : 'Scan All Folders'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Source Folder Dialog */}
|
||||||
|
<AddSourceFolderDialog
|
||||||
|
isOpen={showAddFolderDialog}
|
||||||
|
onClose={() => setShowAddFolderDialog(false)}
|
||||||
|
onAdd={handleAddFolder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
302
frontend/src/components/layout/RightSidebar.tsx
Normal file
302
frontend/src/components/layout/RightSidebar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
159
frontend/src/components/layout/TopBar.tsx
Normal file
159
frontend/src/components/layout/TopBar.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Grid,
|
||||||
|
List,
|
||||||
|
SlidersHorizontal,
|
||||||
|
FolderOpen,
|
||||||
|
Upload,
|
||||||
|
Settings,
|
||||||
|
Menu,
|
||||||
|
Trash2
|
||||||
|
} from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
|
import { photos } from '../../services/api'
|
||||||
|
import { toast } from '../ToastContainer'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import muliLogo from '../../assets/muli-logo.png'
|
||||||
|
|
||||||
|
export function TopBar() {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||||
|
const selectedPhotos = usePhotoStore((state) => state.selectedPhotos)
|
||||||
|
const clearSelection = usePhotoStore((state) => state.clearSelection)
|
||||||
|
const selectedCount = selectedPhotos.length
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
// Mutation for moving photos to trash
|
||||||
|
const trashPhotosMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
await photos.bulkUpdate(selectedPhotos, { trash: true })
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Moved to Trash', `${selectedCount} photo${selectedCount > 1 ? 's' : ''} moved to trash`)
|
||||||
|
clearSelection()
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error('Failed to Move to Trash', error.message || 'An error occurred')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
|
||||||
|
{/* Left Section - Menu and App Name */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
className="group relative rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||||
|
title="Toggle sidebar (Tab)"
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
<kbd className="absolute -bottom-5 left-1/2 -translate-x-1/2 rounded bg-surface-offset px-1 py-0.5 text-[9px] font-medium text-text opacity-0 group-hover:opacity-100">
|
||||||
|
Tab
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<img src={muliLogo} alt="Mulita" className="h-7 w-7 object-contain" />
|
||||||
|
<h1 className="text-lg font-semibold text-text">Mulita</h1>
|
||||||
|
</div>
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="rounded bg-primary/20 px-2 py-0.5 text-sm text-primary">
|
||||||
|
{selectedCount} selected
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => trashPhotosMutation.mutate()}
|
||||||
|
disabled={trashPhotosMutation.isPending}
|
||||||
|
className="flex items-center gap-1 rounded bg-reject/20 px-2 py-0.5 text-sm text-reject hover:bg-reject/30 disabled:opacity-50"
|
||||||
|
title="Move to trash"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
Trash
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center Section - Search */}
|
||||||
|
<div className="flex max-w-xl flex-1 items-center px-8">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-muted" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search photos..."
|
||||||
|
className="w-full rounded-md border border-border bg-bg py-1.5 pl-9 pr-3 text-sm text-text placeholder-text-muted focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Section - View Controls and Actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* View Mode Toggle */}
|
||||||
|
<div className="flex rounded-md border border-border">
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
'rounded-l-md px-2 py-1',
|
||||||
|
viewMode === 'grid'
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-surface text-text-muted hover:bg-surface-2'
|
||||||
|
)}
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
title="Grid view"
|
||||||
|
>
|
||||||
|
<Grid className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
'rounded-r-md px-2 py-1',
|
||||||
|
viewMode === 'list'
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-surface text-text-muted hover:bg-surface-2'
|
||||||
|
)}
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
title="List view"
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Button */}
|
||||||
|
<button
|
||||||
|
className="group relative rounded p-1.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||||
|
title="Filter photos (Ctrl+F)"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="h-4 w-4" />
|
||||||
|
<kbd className="absolute -bottom-5 left-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-surface-offset px-1 py-0.5 text-[9px] font-medium text-text opacity-0 group-hover:opacity-100">
|
||||||
|
Ctrl+F
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mx-1 h-6 w-px bg-border" />
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<button
|
||||||
|
className="rounded p-1.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||||
|
title="Add folder"
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="rounded p-1.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||||
|
title="Import photos"
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="rounded p-1.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
126
frontend/src/components/timeline/PhotoThumbnail.tsx
Normal file
126
frontend/src/components/timeline/PhotoThumbnail.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Star, Check, X } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
interface Photo {
|
||||||
|
id: string
|
||||||
|
filepath: string
|
||||||
|
filename: string
|
||||||
|
width: number | null
|
||||||
|
height: number | null
|
||||||
|
taken_at: string | null
|
||||||
|
rating: number
|
||||||
|
is_picked: boolean
|
||||||
|
is_rejected: boolean
|
||||||
|
file_hash: string
|
||||||
|
media_type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PhotoThumbnailProps {
|
||||||
|
photo: Photo
|
||||||
|
size: number
|
||||||
|
isSelected: boolean
|
||||||
|
onClick: (e: React.MouseEvent) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhotoThumbnail({ photo, size, isSelected, onClick }: PhotoThumbnailProps) {
|
||||||
|
const [imageError, setImageError] = useState(false)
|
||||||
|
const [imageLoaded, setImageLoaded] = useState(false)
|
||||||
|
|
||||||
|
// Generate thumbnail URL - assuming backend serves thumbnails at /api/photos/{id}/thumbnail
|
||||||
|
const thumbnailUrl = `http://localhost:8001/api/v1/photos/${photo.id}/thumb/medium`
|
||||||
|
|
||||||
|
// Calculate aspect ratio for proper sizing (default to 1:1 if dimensions unknown)
|
||||||
|
const aspectRatio = (photo.height && photo.width) ? photo.height / photo.width : 1
|
||||||
|
const displayHeight = size * Math.min(aspectRatio, 1.5) // Cap height at 1.5x width
|
||||||
|
|
||||||
|
const handleImageLoad = () => {
|
||||||
|
setImageLoaded(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImageError = () => {
|
||||||
|
setImageError(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset state when photo changes
|
||||||
|
useEffect(() => {
|
||||||
|
setImageError(false)
|
||||||
|
setImageLoaded(false)
|
||||||
|
}, [photo.id])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'group relative cursor-pointer overflow-hidden rounded-sm transition-all duration-200',
|
||||||
|
'hover:ring-2 hover:ring-primary/50',
|
||||||
|
isSelected && 'ring-2 ring-primary shadow-lg',
|
||||||
|
!imageLoaded && 'bg-surface animate-pulse'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: displayHeight,
|
||||||
|
}}
|
||||||
|
onClick={onClick}
|
||||||
|
title="Click to select • Shift+Click for range • Ctrl+Click to add"
|
||||||
|
>
|
||||||
|
{/* Thumbnail Image */}
|
||||||
|
{!imageError ? (
|
||||||
|
<img
|
||||||
|
src={thumbnailUrl}
|
||||||
|
alt={photo.filename}
|
||||||
|
className={clsx(
|
||||||
|
'h-full w-full object-cover transition-opacity duration-200',
|
||||||
|
imageLoaded ? 'opacity-100' : 'opacity-0'
|
||||||
|
)}
|
||||||
|
onLoad={handleImageLoad}
|
||||||
|
onError={handleImageError}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center bg-surface text-text-muted">
|
||||||
|
<div className="text-center text-xs">
|
||||||
|
<div>Unable to load</div>
|
||||||
|
<div className="mt-1 font-mono text-[10px]">{photo.filename}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selection Indicator */}
|
||||||
|
{isSelected && (
|
||||||
|
<div className="absolute left-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-white">
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rating Stars */}
|
||||||
|
{photo.rating > 0 && (
|
||||||
|
<div className="absolute bottom-1 left-1 flex gap-0.5">
|
||||||
|
{Array.from({ length: photo.rating }).map((_, i) => (
|
||||||
|
<Star
|
||||||
|
key={i}
|
||||||
|
className="h-3 w-3 fill-star text-star"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Flag Indicators */}
|
||||||
|
<div className="absolute bottom-1 right-1">
|
||||||
|
{photo.is_picked && (
|
||||||
|
<Check className="h-4 w-4 text-pick" />
|
||||||
|
)}
|
||||||
|
{photo.is_rejected && (
|
||||||
|
<X className="h-4 w-4 text-reject" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File Type Badge for RAW/Video */}
|
||||||
|
{(photo.filepath.toLowerCase().match(/\.(raw|arw|cr2|cr3|nef|orf|rw2|dng)$/i) ||
|
||||||
|
photo.filepath.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i)) && (
|
||||||
|
<div className="absolute right-1 top-1 rounded bg-black/50 px-1 py-0.5 text-[10px] font-medium text-white">
|
||||||
|
{photo.filepath.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i) ? 'VIDEO' : 'RAW'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
260
frontend/src/components/timeline/Timeline.tsx
Normal file
260
frontend/src/components/timeline/Timeline.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import { useRef, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
|
import { PhotoThumbnail } from './PhotoThumbnail'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
|
||||||
|
interface Photo {
|
||||||
|
id: string
|
||||||
|
filepath: string
|
||||||
|
filename: string
|
||||||
|
width: number | null
|
||||||
|
height: number | null
|
||||||
|
taken_at: string | null
|
||||||
|
rating: number
|
||||||
|
is_picked: boolean
|
||||||
|
is_rejected: boolean
|
||||||
|
file_hash: string
|
||||||
|
media_type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Timeline() {
|
||||||
|
const parentRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [containerWidth, setContainerWidth] = useState(0)
|
||||||
|
|
||||||
|
const {
|
||||||
|
selectedPhotos,
|
||||||
|
lastSelectedIndex,
|
||||||
|
rangeStartIndex,
|
||||||
|
selectPhoto,
|
||||||
|
togglePhotoSelection,
|
||||||
|
clearSelection,
|
||||||
|
|
||||||
|
} = usePhotoStore()
|
||||||
|
|
||||||
|
// Helper function for range selection
|
||||||
|
const selectRange = (endIndex: number) => {
|
||||||
|
const startIndex = rangeStartIndex ?? lastSelectedIndex ?? 0
|
||||||
|
const minIndex = Math.min(startIndex, endIndex)
|
||||||
|
const maxIndex = Math.max(startIndex, endIndex)
|
||||||
|
|
||||||
|
// Select all photos in the range
|
||||||
|
for (let i = minIndex; i <= maxIndex; i++) {
|
||||||
|
if (i < photos.length && !selectedPhotos.includes(photos[i].id)) {
|
||||||
|
togglePhotoSelection(photos[i].id, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumbnail size configuration
|
||||||
|
const thumbnailSize = 200 // Base size for thumbnails
|
||||||
|
const gap = 4
|
||||||
|
const padding = 16
|
||||||
|
|
||||||
|
// Calculate number of columns based on container width
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
if (containerWidth === 0) return 4
|
||||||
|
return Math.floor((containerWidth - padding * 2) / (thumbnailSize + gap))
|
||||||
|
}, [containerWidth, thumbnailSize, gap, padding])
|
||||||
|
|
||||||
|
// Fetch photos from backend
|
||||||
|
const { data: photos = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['photos'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await axios.get<{photos: Photo[], total: number}>('http://localhost:8001/api/v1/photos', {
|
||||||
|
params: {
|
||||||
|
limit: 1000,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return response.data.photos || []
|
||||||
|
},
|
||||||
|
staleTime: 30000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Group photos into rows for grid layout
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const result: Photo[][] = []
|
||||||
|
for (let i = 0; i < photos.length; i += columns) {
|
||||||
|
result.push(photos.slice(i, i + columns))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}, [photos, columns])
|
||||||
|
|
||||||
|
// Virtual scrolling setup
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: rows.length,
|
||||||
|
getScrollElement: () => parentRef.current,
|
||||||
|
estimateSize: () => thumbnailSize + gap,
|
||||||
|
overscan: 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Measure container width on mount and resize
|
||||||
|
useEffect(() => {
|
||||||
|
const measureWidth = () => {
|
||||||
|
if (parentRef.current) {
|
||||||
|
setContainerWidth(parentRef.current.clientWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
measureWidth()
|
||||||
|
window.addEventListener('resize', measureWidth)
|
||||||
|
return () => window.removeEventListener('resize', measureWidth)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Handle keyboard shortcuts for photo navigation
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (photos.length === 0) return
|
||||||
|
|
||||||
|
const currentIndex = lastSelectedIndex ?? -1
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault()
|
||||||
|
if (currentIndex > columns - 1) {
|
||||||
|
const newIndex = currentIndex - columns
|
||||||
|
if (e.shiftKey) {
|
||||||
|
selectRange(newIndex)
|
||||||
|
} else {
|
||||||
|
selectPhoto(photos[newIndex].id, newIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault()
|
||||||
|
if (currentIndex < photos.length - columns) {
|
||||||
|
const newIndex = Math.min(currentIndex + columns, photos.length - 1)
|
||||||
|
if (e.shiftKey) {
|
||||||
|
selectRange(newIndex)
|
||||||
|
} else {
|
||||||
|
selectPhoto(photos[newIndex].id, newIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault()
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
const newIndex = currentIndex - 1
|
||||||
|
if (e.shiftKey) {
|
||||||
|
selectRange(newIndex)
|
||||||
|
} else {
|
||||||
|
selectPhoto(photos[newIndex].id, newIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault()
|
||||||
|
if (currentIndex < photos.length - 1) {
|
||||||
|
const newIndex = currentIndex + 1
|
||||||
|
if (e.shiftKey) {
|
||||||
|
selectRange(newIndex)
|
||||||
|
} else {
|
||||||
|
selectPhoto(photos[newIndex].id, newIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'a':
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
// Select all
|
||||||
|
photos.forEach((photo, index) => {
|
||||||
|
if (!selectedPhotos.includes(photo.id)) {
|
||||||
|
togglePhotoSelection(photo.id, index)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault()
|
||||||
|
clearSelection()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [photos, selectedPhotos, lastSelectedIndex, columns, selectPhoto, togglePhotoSelection, selectRange, clearSelection])
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-text-muted">Loading photos...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (photos.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-text-muted">No photos found. Add a source folder to get started.</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={parentRef}
|
||||||
|
className="h-full overflow-auto bg-bg"
|
||||||
|
style={{ padding: `${padding}px` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: `${virtualizer.getTotalSize()}px`,
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
|
const row = rows[virtualRow.index]
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={virtualRow.key}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: `${virtualRow.size}px`,
|
||||||
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex"
|
||||||
|
style={{ gap: `${gap}px` }}
|
||||||
|
>
|
||||||
|
{row.map((photo, colIndex) => {
|
||||||
|
const globalIndex = virtualRow.index * columns + colIndex
|
||||||
|
return (
|
||||||
|
<PhotoThumbnail
|
||||||
|
key={photo.id}
|
||||||
|
photo={photo}
|
||||||
|
size={thumbnailSize}
|
||||||
|
isSelected={selectedPhotos.includes(photo.id)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.shiftKey && lastSelectedIndex !== null) {
|
||||||
|
selectRange(globalIndex)
|
||||||
|
} else if (e.ctrlKey || e.metaKey) {
|
||||||
|
togglePhotoSelection(photo.id, globalIndex)
|
||||||
|
} else {
|
||||||
|
selectPhoto(photo.id, globalIndex)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
55
frontend/src/hooks/useKeyboardShortcuts.ts
Normal file
55
frontend/src/hooks/useKeyboardShortcuts.ts
Normal 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')
|
||||||
|
})
|
||||||
|
}
|
||||||
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
|
||||||
81
frontend/src/store/photoStore.ts
Normal file
81
frontend/src/store/photoStore.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
interface Photo {
|
||||||
|
id: string
|
||||||
|
filename: string
|
||||||
|
filepath: string
|
||||||
|
media_type: string
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
taken_at?: string
|
||||||
|
thumb_small?: string
|
||||||
|
thumb_medium?: string
|
||||||
|
thumb_large?: string
|
||||||
|
rating: number
|
||||||
|
is_picked: boolean
|
||||||
|
is_rejected: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PhotoStore {
|
||||||
|
photos: Photo[]
|
||||||
|
selectedPhotos: string[]
|
||||||
|
activePhotoId: string | null
|
||||||
|
lastSelectedIndex: number | null
|
||||||
|
rangeStartIndex: number | null
|
||||||
|
|
||||||
|
setPhotos: (photos: Photo[]) => void
|
||||||
|
selectPhoto: (id: string, index: number) => void
|
||||||
|
togglePhotoSelection: (id: string, index: number) => void
|
||||||
|
selectRange: (endIndex: number) => void
|
||||||
|
deselectPhoto: (id: string) => void
|
||||||
|
clearSelection: () => void
|
||||||
|
setActivePhoto: (id: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePhotoStore = create<PhotoStore>((set) => ({
|
||||||
|
photos: [],
|
||||||
|
selectedPhotos: [],
|
||||||
|
activePhotoId: null,
|
||||||
|
lastSelectedIndex: null,
|
||||||
|
rangeStartIndex: null,
|
||||||
|
|
||||||
|
setPhotos: (photos) => set({ photos }),
|
||||||
|
|
||||||
|
selectPhoto: (id, index) => set({
|
||||||
|
selectedPhotos: [id],
|
||||||
|
activePhotoId: id,
|
||||||
|
lastSelectedIndex: index,
|
||||||
|
rangeStartIndex: index,
|
||||||
|
}),
|
||||||
|
|
||||||
|
togglePhotoSelection: (id, index) => set((state) => {
|
||||||
|
const isSelected = state.selectedPhotos.includes(id)
|
||||||
|
return {
|
||||||
|
selectedPhotos: isSelected
|
||||||
|
? state.selectedPhotos.filter(photoId => photoId !== id)
|
||||||
|
: [...state.selectedPhotos, id],
|
||||||
|
lastSelectedIndex: index,
|
||||||
|
rangeStartIndex: isSelected ? state.rangeStartIndex : index,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
selectRange: (endIndex) => {
|
||||||
|
// Note: The actual range selection logic should be handled in the Timeline component
|
||||||
|
// which has access to the photos array
|
||||||
|
set({
|
||||||
|
lastSelectedIndex: endIndex,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
deselectPhoto: (id) => set((state) => ({
|
||||||
|
selectedPhotos: state.selectedPhotos.filter(photoId => photoId !== id)
|
||||||
|
})),
|
||||||
|
|
||||||
|
clearSelection: () => set({
|
||||||
|
selectedPhotos: [],
|
||||||
|
lastSelectedIndex: null,
|
||||||
|
rangeStartIndex: null,
|
||||||
|
}),
|
||||||
|
|
||||||
|
setActivePhoto: (id) => set({ activePhotoId: id }),
|
||||||
|
}))
|
||||||
29
frontend/src/types/images.d.ts
vendored
Normal file
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;
|
||||||
|
}
|
||||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user