4 Commits

Author SHA1 Message Date
7efac4354e ui: migrate to shadcn/ui primitives across dialogs, filters, and forms
Adopts shadcn/ui components (Dialog, Button, Input, Select, Popover,
Command, Checkbox, Switch, Toggle, Calendar, etc.) across the app,
replacing hand-rolled modals, dropdowns, and form controls. Adds a
reusable cmdk-backed MultiSelect for the Type, Tags, and Flag filters
so all multi-value filter popovers share one component and layout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:07:20 +02:00
8529771122 ui: rework selection/heap visuals, contextual shortcut hints, inline scan activity
- Selection now reads as a blue ring + tint with a springy scale-down,
  hover stays a subtle gray ring so keyboard-driven and mouse-driven
  states are tellable apart.
- Heap membership is signalled with a green tint only (no badge, no
  ring, no scale).
- Discard/restore is optimistic and non-yanking: photos stay in the
  grid greyed out until the next reload, X toggles based on the
  current state, and the same treatment applies in preview.
- Filmstrip mirrors the grid styling (selection blue, heap green,
  discarded grey).
- Preview close restores the LAST viewed photo as the focused/selected
  one in the grid.
- Right sidebar collapses on view change and re-opens when a photo is
  in focus; Esc clears active selection so the panel collapses too.
- Keyboard hints panel is context-aware (grid / preview / discarded
  section), collapsible with H, persisted, and rendered inside the
  preview column above the filmstrip.
- "Pick (P)" renamed to "Select (S)" everywhere.
- Needs review moved into the Flag pill dropdown.
- Fixed vertical videos overflowing the preview column (min-h-0).
- Replaced the bottom-right ScanProgress popover with an inline
  spinner next to the FOLDERS sidebar header (and on the specific
  folder row being scanned). ScanProgress is now a headless
  invalidator; useScanActivity exposes the live status.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:45:36 +02:00
a6eb406052 ui: tidy sidebar tree indent and consolidate sidebar toggles into filter bar
Folder tree now indents 20px per level (chevron width + gap) so a child's
chevron column lines up under its parent's label, and depth-1 rows nest
under the section eyebrow instead of starting flush with it. Spacer for
leaf rows matches the chevron button footprint so rows align regardless
of expandability.

Sidebar open/close buttons (previously split between TopBar and each
panel header) collapse into two toggles at the ends of the FilterBar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 22:31:10 +02:00
574d71371f refactor: strip AI pipeline to binary photo/other classifier
Drops face recognition, OCR, object detection, and semantic embeddings.
The sole remaining vision task is a CLIP-based binary classifier
(photography vs other); photos in "other" get needs_review=true so
screenshots, documents, memes and scans can be triaged from a new
filter pill in the UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 22:27:17 +02:00
106 changed files with 4187 additions and 5104 deletions

View File

@@ -38,7 +38,6 @@ from app.models import ( # noqa: E402, F401
Tag,
Heap,
HeapPhoto,
Embedding,
)
config = context.config

View File

@@ -0,0 +1,65 @@
"""Strip AI pipeline to binary classifier only
Revision ID: 0012_strip_ai
Revises: 0011_sharing
Create Date: 2026-04-14
Removes face recognition, OCR, object detection, and semantic embeddings.
The remaining AI is a single binary 'photography' vs 'other' classifier
whose output feeds Tag(kind='content_type') and a new Photo.needs_review
flag.
Drops: embeddings, face_embeddings, ocr_text tables.
Drops: photo_tags rows produced by 'vision:yolov8n' and 'vision:sface'.
Drops: tags with kind IN ('object','scene','face_cluster').
Drops: tags.representative_photo_id column.
Adds: photos.needs_review (bool, default false) + partial index.
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0012_strip_ai"
down_revision: Union[str, None] = "0011_sharing"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Drop dropped-AI tables. CASCADE clears any lingering FKs/indices.
op.execute("DROP TABLE IF EXISTS embeddings CASCADE")
op.execute("DROP TABLE IF EXISTS face_embeddings CASCADE")
op.execute("DROP TABLE IF EXISTS ocr_text CASCADE")
# Clear ML-produced photo_tags rows and their parent tags.
op.execute(
"DELETE FROM photo_tags WHERE source IN ('vision:yolov8n','vision:sface')"
)
op.execute(
"DELETE FROM tags WHERE kind IN ('object','scene','face_cluster')"
)
# Drop the face-cluster representative column.
op.execute("ALTER TABLE tags DROP COLUMN IF EXISTS representative_photo_id")
# Add the needs_review flag.
op.execute(
"ALTER TABLE photos ADD COLUMN IF NOT EXISTS needs_review "
"BOOLEAN NOT NULL DEFAULT false"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_photos_needs_review "
"ON photos(needs_review) WHERE needs_review"
)
def downgrade() -> None:
# Data is not recoverable on downgrade — only the schema stubs are
# put back so a future reinstall of the old pipeline can re-populate.
op.execute("DROP INDEX IF EXISTS ix_photos_needs_review")
op.execute("ALTER TABLE photos DROP COLUMN IF EXISTS needs_review")
op.execute(
"ALTER TABLE tags ADD COLUMN IF NOT EXISTS representative_photo_id "
"VARCHAR REFERENCES photos(id) ON DELETE SET NULL"
)

View File

@@ -0,0 +1,35 @@
"""Drop legacy content_type tags from the 6-category classifier
Revision ID: 0013_drop_old_ct
Revises: 0012_strip_ai
Create Date: 2026-04-14
The previous classifier wrote Tag(kind='content_type', name IN
('photograph','screenshot','document','receipt','meme','artwork')).
The new binary classifier writes names ('photography','other'). Both
coexisted after the cutover so users saw duplicate groupings like
'photography' alongside 'photograph'. Drop the old names — photo_tags
rows cascade-delete via the FK.
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0013_drop_old_ct"
down_revision: Union[str, None] = "0012_strip_ai"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
LEGACY_NAMES = ('photograph', 'screenshot', 'document', 'receipt', 'meme', 'artwork')
def upgrade() -> None:
op.execute(
"DELETE FROM tags WHERE kind = 'content_type' "
f"AND name IN {LEGACY_NAMES}"
)
def downgrade() -> None:
pass

View File

@@ -30,51 +30,16 @@ class PerformanceSettings(BaseModel):
db_pool_max_overflow: int = 10
db_pool_recycle: int = 3600
class EmbedderSettings(BaseModel):
"""CLIP / SigLIP embedding model settings.
Supported: "openclip_vitb32" (512-d), "siglip2_vitb16" (768-d, default)."""
name: str = "openclip_vitb32"
batch_size: int = 8
class OCRSettings(BaseModel):
"""PaddleOCR / rapidocr settings"""
enabled: bool = True
languages: list[str] = ["en"]
min_confidence: float = 0.5
class DetectorSettings(BaseModel):
"""YOLOv8n object detection settings"""
enabled: bool = True
min_confidence: float = 0.35
max_detections: int = 50
class FacesSettings(BaseModel):
"""YuNet + SFace face detection/recognition settings"""
enabled: bool = True
min_face_size: int = 40
recognition_threshold: float = 0.65
cluster_eps: float = 0.5
class ClassifierSettings(BaseModel):
"""CLIP zero-shot content classification settings"""
enabled: bool = True
"""Binary content classifier (photography vs other)."""
min_confidence: float = 0.3
class VisionSettings(BaseModel):
"""AI vision pipeline settings. Disabled when running on SQLite
(pgvector is required for embedding storage)."""
"""Vision pipeline — one binary classifier (photography vs other)."""
enabled: bool = True
backend: str = "onnx" # "onnx" | "rocm" (future)
backend: str = "onnx"
models_dir: str = "/data/models"
# ONNX Runtime execution providers in priority order.
# Auto-detected at startup; falls back to CPU if GPU is unavailable.
# Options: "CUDAExecutionProvider", "ROCMExecutionProvider",
# "OpenVINOExecutionProvider", "CPUExecutionProvider"
execution_providers: list[str] = ["CPUExecutionProvider"]
embedder: EmbedderSettings = EmbedderSettings()
ocr: OCRSettings = OCRSettings()
detector: DetectorSettings = DetectorSettings()
faces: FacesSettings = FacesSettings()
classifier: ClassifierSettings = ClassifierSettings()
worker_concurrency: int = 2

View File

@@ -111,13 +111,7 @@ async def init_db():
"""Initialize database, create tables if they don't exist"""
async with engine.begin() as conn:
# Import all models to register them with Base
from app.models import User, Photo, Folder, SourceRoot, Tag, PhotoTag, Heap, HeapPhoto, Embedding
# Postgres: ensure pgvector is available before create_all touches
# any Vector columns (added in later PRs but the extension is cheap
# and idempotent to create now).
if _is_postgres:
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
from app.models import User, Photo, Folder, SourceRoot, Tag, PhotoTag, Heap, HeapPhoto # noqa: F401
# Create all tables. Note: create_all only creates *missing* tables —
# it does NOT add new columns to existing tables when the model gains

View File

@@ -6,9 +6,6 @@ from app.models.photos import Photo
from app.models.folders import Folder, SourceRoot
from app.models.tags import Tag, PhotoTag
from app.models.heaps import Heap, HeapPhoto
from app.models.embeddings import Embedding
from app.models.ocr_text import OCRText
from app.models.face_embedding import FaceEmbedding
from app.models.sharing import HeapShare, FolderShare
__all__ = [
@@ -20,9 +17,6 @@ __all__ = [
'PhotoTag',
'Heap',
'HeapPhoto',
'Embedding',
'OCRText',
'FaceEmbedding',
'HeapShare',
'FolderShare',
]
]

View File

@@ -1,24 +0,0 @@
"""
Embedding model — stores CLIP/SigLIP image embeddings via pgvector.
Composite PK (photo_id, model) allows re-embedding with newer models
without clobbering old vectors.
Vector dimension is 768 to support SigLIP2 ViT-B/16 (the default).
OpenCLIP ViT-B/32 (512-d) embeddings are zero-padded on insert so
both models coexist in the same column. The padding is invisible to
cosine similarity (zeros don't affect the angle).
"""
from sqlalchemy import Column, String, ForeignKey, DateTime, func
from pgvector.sqlalchemy import Vector
from app.database import Base
class Embedding(Base):
__tablename__ = 'embeddings'
photo_id = Column(String, ForeignKey('photos.id', ondelete='CASCADE'), primary_key=True)
model = Column(String(64), primary_key=True) # e.g. 'siglip2_vitb16'
vector = Column(Vector(512)) # OpenCLIP ViT-B/32 → 512-d
created_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@@ -1,24 +0,0 @@
"""
Face embedding model — stores per-face detection + recognition vectors.
cluster_id FKs to tags.id where kind='face_cluster'. Null means
unclustered (will be assigned by recluster_faces).
"""
from sqlalchemy import Column, String, Float, ForeignKey, DateTime, func
from sqlalchemy.dialects.postgresql import JSONB
from pgvector.sqlalchemy import Vector
import uuid
from app.database import Base
class FaceEmbedding(Base):
__tablename__ = 'face_embeddings'
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
photo_id = Column(String, ForeignKey('photos.id', ondelete='CASCADE'), nullable=False, index=True)
bbox = Column(JSONB) # [x1, y1, x2, y2] normalized 0-1
vector = Column(Vector(512)) # ArcFace → 512-d
cluster_id = Column(String, ForeignKey('tags.id', ondelete='SET NULL'), nullable=True, index=True)
quality = Column(Float)
created_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@@ -1,20 +0,0 @@
"""
OCR text model — stores text regions extracted from photos via rapidocr.
"""
from sqlalchemy import Column, String, Float, ForeignKey, Text, DateTime, func
from sqlalchemy.dialects.postgresql import JSONB
import uuid
from app.database import Base
class OCRText(Base):
__tablename__ = 'ocr_text'
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
photo_id = Column(String, ForeignKey('photos.id', ondelete='CASCADE'), nullable=False, index=True)
text = Column(Text, nullable=False)
language = Column(String(8), default='')
confidence = Column(Float)
bbox = Column(JSONB) # [x1, y1, x2, y2] normalized 0-1
created_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@@ -50,6 +50,11 @@ class Photo(Base):
# rows, and POST /folders/{id}/hide recomputes it on toggle.
is_hidden = Column(Boolean, nullable=False, default=False, server_default='false', index=True)
# "Needs review" — set by the content classifier when a photo is
# classified as 'other' (screenshot, document, meme, scan, etc.) so
# the user can page through non-photographs in the UI and triage them.
needs_review = Column(Boolean, nullable=False, default=False, server_default='false', index=True)
# "Capture date is probably wrong" — denormalized from the folder/filename
# date-guesser. Set at scan time and recomputed on every taken_at edit so
# the filter bar can query it directly. See services/date_guess.py for
@@ -118,4 +123,5 @@ class Photo(Base):
Index('ix_photos_media_type', 'media_type'),
Index('ix_photos_processing_status', 'processing_status'),
Index('ix_photos_lat_lon', 'latitude', 'longitude'),
Index('ix_photos_needs_review', 'needs_review'),
)

View File

@@ -6,7 +6,7 @@ labels, and face clusters via the `kind` column. The `photo_tags`
association carries per-photo ML metadata (confidence, bounding box,
source model).
"""
from sqlalchemy import Column, String, Float, ForeignKey, Table, Index, UniqueConstraint
from sqlalchemy import Column, String, Float, ForeignKey, Table, Index, UniqueConstraint # noqa: F401
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import JSONB
import uuid
@@ -22,7 +22,7 @@ photo_tags = Table(
# ML metadata — null for user-applied tags
Column('confidence', Float, nullable=True),
Column('bbox', JSONB, nullable=True), # [x1, y1, x2, y2] normalized 0-1
Column('source', String, nullable=True), # e.g. "vision:yolov8n", "vision:sface"
Column('source', String, nullable=True), # e.g. "vision:clip_classifier"
Index('ix_photo_tags_photo_id', 'photo_id'),
Index('ix_photo_tags_tag_id', 'tag_id'),
)
@@ -42,16 +42,11 @@ class Tag(Base):
# Tag classification
kind = Column(String, nullable=False, default='user', index=True)
# kind values: 'user' | 'object' | 'scene' | 'face_cluster'
# kind values: 'user' | 'content_type'
# Which model produced this tag (null for user-created)
source = Column(String, nullable=True)
# e.g. "vision:yolov8n", "vision:sface", null
# For face clusters: the photo used as the cluster representative thumbnail
representative_photo_id = Column(
String, ForeignKey('photos.id', ondelete='SET NULL'), nullable=True
)
# e.g. "vision:clip_classifier", null
# Relationships
photos = relationship("Photo", secondary=photo_tags, backref="tags")

View File

@@ -320,13 +320,8 @@ async def update_feature_flag(
class BackfillVisionBody(BaseModel):
"""POST body for triggering a vision backfill. ``task`` picks a
specific stage (``embed`` / ``ocr`` / ``detect`` / ``faces`` /
``classify``); leaving it null runs every enabled stage. ``limit``
caps how many photos per stage are queued — useful for smoke-
testing a newly-enabled feature before committing a full run.
"""
task: Optional[str] = None
"""POST body for triggering a classifier backfill. ``limit`` caps how
many photos are queued."""
limit: Optional[int] = None
@@ -335,61 +330,29 @@ async def trigger_ai_backfill(
body: BackfillVisionBody,
admin: User = Depends(require_admin),
):
"""Queue a vision backfill pass. Identical code path as the automatic
post-scan backfill — just triggered manually from the UI."""
"""Queue a classifier backfill pass."""
if not is_enabled(FLAG_VISION_ENABLED):
raise HTTPException(
status_code=400,
detail="Vision is currently disabled; enable it before running a backfill.",
)
valid_tasks = {'embed', 'ocr', 'detect', 'faces', 'classify'}
if body.task is not None and body.task not in valid_tasks:
raise HTTPException(
status_code=400,
detail=f"task must be one of {sorted(valid_tasks)} or null",
)
if body.limit is not None and body.limit <= 0:
raise HTTPException(status_code=400, detail="limit must be positive")
# Import lazily so importing admin.py doesn't pull in the whole
# vision stack on startup (Celery task module loads numpy etc.).
from app.tasks.vision import backfill_vision
result = backfill_vision.apply_async(
kwargs={'task': body.task, 'limit': body.limit}
)
result = backfill_vision.apply_async(kwargs={'limit': body.limit})
logger.info(
f"Admin '{admin.username}' queued vision backfill "
f"(task={body.task}, limit={body.limit}, celery_id={result.id})"
f"(limit={body.limit}, celery_id={result.id})"
)
return {
"status": "queued",
"task_id": result.id,
"task": body.task,
"limit": body.limit,
}
@router.post("/ai/recluster-faces")
async def trigger_face_recluster(admin: User = Depends(require_admin)):
"""Kick off face recluster. Normally auto-fires after a scan via a
debounced scheduler; this endpoint is for admins who want to force
a fresh clustering pass (e.g. after tweaking ``cluster_eps`` in
the YAML config)."""
if not is_enabled(FLAG_VISION_ENABLED):
raise HTTPException(
status_code=400,
detail="Vision is currently disabled; enable it before reclustering.",
)
from app.tasks.vision import recluster_faces
result = recluster_faces.apply_async()
logger.info(
f"Admin '{admin.username}' queued face recluster (celery_id={result.id})"
)
return {"status": "queued", "task_id": result.id}
@router.post("/ai/rescan")
async def trigger_full_rescan(admin: User = Depends(require_admin)):
"""Dispatch the same scan_all_source_roots job the backend runs at

View File

@@ -89,6 +89,12 @@ async def get_library_stats(
)
).scalar() or 0
needs_review_count = (
await db.execute(
select(func.count(Photo.id)).where(visible, Photo.needs_review.is_(True))
)
).scalar() or 0
# Legacy split (kept for the existing /stats consumers).
photo_count = (
await db.execute(
@@ -120,6 +126,7 @@ async def get_library_stats(
"with_gps": with_gps_count,
"duplicates": duplicates_count,
"discarded": discarded_count,
"needs_review": needs_review_count,
"total_photos": photo_count,
"total_videos": video_count,
"total_size": size,
@@ -437,10 +444,7 @@ async def get_worker_status(
r = _redis.Redis.from_url(settings.redis_url, socket_timeout=1.0)
r.ping()
broker_ok = True
# `vision` is the big one — embed / classify / detect / ocr /
# extract_faces all land here, so it's where backlogs actually
# pile up. Leaving it off the dashboard made it look like the
# queue was always empty while the worker was clearly busy.
# `vision` runs the content classifier — the only heavy queue.
for q in ('default', 'high', 'low', 'vision'):
try:
queue_depths[q] = int(r.llen(q) or 0)
@@ -525,8 +529,6 @@ async def get_pipeline_stats(
Keep the shape flat + serialisable; the frontend turns it straight
into a list of rows without needing to know about the models.
"""
from app.config import settings as _settings
from app.models import Embedding, FaceEmbedding, OCRText
from app.models.tags import photo_tags # association Table, not a model
owner = _owner_filter(current_user, scope)
@@ -572,45 +574,15 @@ async def get_pipeline_stats(
)
)
# Embeddings: count distinct photos that have a row for the currently
# configured embedder model. A photo can have multiple model rows
# (historical re-embeds) so COUNT(DISTINCT) is the right thing here.
embedder_model = _settings.vision.embedder.name
embeddings_done = await scalar_count(
select(func.count(func.distinct(Embedding.photo_id)))
.select_from(Embedding)
.join(Photo, Photo.id == Embedding.photo_id)
.where(not_discarded, Embedding.model == embedder_model)
)
tagged_photos = await scalar_count(
# Classified: distinct photos with a content_type tag.
classified_done = await scalar_count(
select(func.count(func.distinct(photo_tags.c.photo_id)))
.select_from(photo_tags)
.join(Photo, Photo.id == photo_tags.c.photo_id)
.where(not_discarded)
.where(not_discarded, photo_tags.c.source == 'vision:clip_classifier')
)
ocr_done = await scalar_count(
select(func.count(func.distinct(OCRText.photo_id)))
.select_from(OCRText)
.join(Photo, Photo.id == OCRText.photo_id)
.where(not_discarded)
)
# Faces: photos that have at least one face_embeddings row. A photo
# with no faces legitimately finishes face extraction with zero rows,
# so this undercounts by exactly "images with no visible people". We
# surface the photo-with-faces count rather than "images scanned for
# faces" because the latter isn't tracked anywhere.
faces_photos = await scalar_count(
select(func.count(func.distinct(FaceEmbedding.photo_id)))
.select_from(FaceEmbedding)
.join(Photo, Photo.id == FaceEmbedding.photo_id)
.where(not_discarded)
)
face_rows = await scalar_count(select(func.count(FaceEmbedding.id)))
face_clusters = await scalar_count(
select(func.count(func.distinct(FaceEmbedding.cluster_id)))
.where(FaceEmbedding.cluster_id.is_not(None))
needs_review_count = await scalar_count(
select(func.count(Photo.id)).where(not_discarded, Photo.needs_review.is_(True))
)
duplicate_groups = await scalar_count(
@@ -657,43 +629,11 @@ async def get_pipeline_stats(
"hint": "Feeds duplicate detection.",
},
{
"key": "embeddings",
"label": f"Embeddings ({embedder_model})",
"done": embeddings_done,
"key": "classification",
"label": "Content classification (photo vs other)",
"done": classified_done,
"total": total_images,
"hint": "Semantic search + content classification.",
},
{
"key": "tags",
"label": "Object tags (YOLO)",
"done": tagged_photos,
"total": total_images,
"hint": "Auto-generated object labels. Not every photo has a detectable object.",
"partial": True,
},
{
"key": "ocr",
"label": "OCR text",
"done": ocr_done,
"total": total_images,
"hint": "Extracted text from screenshots / documents. Many photos have none.",
"partial": True,
},
{
"key": "faces",
"label": "Face detection",
"done": faces_photos,
"total": total_images,
"hint": f"{face_rows} face rows detected across {faces_photos} photos.",
"partial": True,
},
{
"key": "face_clusters",
"label": "Face clusters",
"done": face_clusters,
"total": face_clusters, # no meaningful "total" — it's just the current count
"hint": "Built by recluster_faces. Run it after backfill to populate the People view.",
"standalone": True,
"hint": f"{needs_review_count} photos flagged for review.",
},
{
"key": "duplicates",
@@ -708,7 +648,6 @@ async def get_pipeline_stats(
return {
"total_photos": total_photos,
"total_images": total_images,
"embedder_model": embedder_model,
"stages": stages,
}

View File

@@ -48,6 +48,7 @@ async def list_photos(
color_label: Optional[str] = None,
is_discarded: Optional[bool] = False,
is_duplicate: Optional[bool] = None,
needs_review: Optional[bool] = None,
has_date_warning: Optional[bool] = None,
heap_id: Optional[str] = None,
sort: str = "taken_at",
@@ -196,6 +197,8 @@ async def list_photos(
# view shows everything regardless of duplicate status.
if is_duplicate is not None:
filters.append(Photo.is_duplicate == is_duplicate)
if needs_review is not None:
filters.append(Photo.needs_review == needs_review)
if has_date_warning is not None:
filters.append(Photo.has_date_warning == has_date_warning)

View File

@@ -25,13 +25,7 @@ class SearchRequest(BaseModel):
@router.post("")
async def search_photos(body: SearchRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Unified search endpoint. Every query runs hybrid (FTS + semantic)
by default — the user never picks a mode.
Filters:
- tag_ids: list of tag IDs (any kind: user, object, face_cluster)
- date_from / date_to: ISO date strings
"""
"""FTS search over photo metadata with optional tag and date filters."""
filters = body.filters or {}
results = await hybrid_search(

View File

@@ -1,10 +1,8 @@
"""
Tags API router.
Unified across user tags, ML-detected objects, and face clusters via
the `kind` query parameter. Default behaviour (no kind filter) returns
all tags — the frontend's "Hide auto-generated tags" toggle filters
client-side or passes `kind=user`.
Unified across user tags and the binary content-type classifier
('photography' | 'other') via the `kind` column.
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
@@ -34,15 +32,11 @@ class TagUpdate(BaseModel):
color: Optional[str] = None
class TagMerge(BaseModel):
target_id: str # tag to merge INTO
# ── Endpoints ─────────────────────────────────────────────────────────────
@router.get("")
async def list_tags(
kind: Optional[str] = Query(None, description="Filter by kind: user, object, scene, face_cluster"),
kind: Optional[str] = Query(None, description="Filter by kind: user, content_type"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -90,7 +84,7 @@ async def list_tags(
"color": tag.color,
"kind": tag.kind,
"source": tag.source,
"representative_photo_id": tag.representative_photo_id or first_photo_id,
"representative_photo_id": first_photo_id,
"photo_count": int(count or 0),
}
for tag, count, first_photo_id in rows
@@ -130,7 +124,7 @@ async def update_tag(
tag_id: str, body: TagUpdate, db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Rename or recolor a tag (works for any kind — user, object, face_cluster)."""
"""Rename or recolor a tag."""
result = await db.execute(select(Tag).where(Tag.id == tag_id, Tag.user_id == current_user.id))
tag = result.scalar_one_or_none()
if not tag:
@@ -149,52 +143,6 @@ async def update_tag(
return {"id": tag.id, "name": tag.name, "color": tag.color, "kind": tag.kind}
@router.post("/{tag_id}/merge")
async def merge_tag(
tag_id: str, body: TagMerge, db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Merge tag_id INTO target_id. All photo associations from the source
tag are moved to the target, then the source tag is deleted.
Useful for merging auto-detected face clusters (e.g. "Person 3""Alice")
or merging duplicate object labels."""
if tag_id == body.target_id:
raise HTTPException(status_code=400, detail="Cannot merge a tag into itself")
source = (await db.execute(select(Tag).where(Tag.id == tag_id, Tag.user_id == current_user.id))).scalar_one_or_none()
target = (await db.execute(select(Tag).where(Tag.id == body.target_id, Tag.user_id == current_user.id))).scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="Source tag not found")
if not target:
raise HTTPException(status_code=404, detail="Target tag not found")
# Move photo associations: update tag_id from source → target.
# Skip any that would violate the PK (photo already tagged with target).
existing_target_photos = select(photo_tags.c.photo_id).where(
photo_tags.c.tag_id == body.target_id
)
await db.execute(
update(photo_tags)
.where(
photo_tags.c.tag_id == tag_id,
photo_tags.c.photo_id.notin_(existing_target_photos),
)
.values(tag_id=body.target_id)
)
# Delete remaining source associations (duplicates that couldn't move)
from sqlalchemy import delete as sa_delete
await db.execute(
sa_delete(photo_tags).where(photo_tags.c.tag_id == tag_id)
)
# Delete source tag
await db.delete(source)
await db.commit()
return {"merged_into": target.id, "target_name": target.name}
@router.delete("/{tag_id}", status_code=204)
async def delete_tag(tag_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Delete a tag. Photo associations cascade-delete via the FK."""

View File

@@ -39,6 +39,7 @@ class PhotoResponse(PhotoBase):
latitude: Optional[float] = None
longitude: Optional[float] = None
is_duplicate: bool = False
needs_review: bool = False
has_date_warning: bool = False
live_photo_video_id: Optional[str] = None
owner_username: Optional[str] = None

View File

@@ -41,12 +41,10 @@ import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import select, update, text
from sqlalchemy import select, update
from app.database import AsyncSessionLocal
from app.models.photos import Photo
from app.models.embeddings import Embedding
from app.config import settings
logger = logging.getLogger(__name__)
@@ -54,11 +52,6 @@ logger = logging.getLogger(__name__)
# pHash Hamming distance threshold (6 out of 64 bits).
DEFAULT_PHASH_THRESHOLD = 6
# CLIP cosine distance threshold. CLIP embeddings are L2-normalized,
# so cosine distance = 1 - dot(a, b). A threshold of 0.08 catches
# visually near-identical shots; 0.15 catches similar compositions.
DEFAULT_CLIP_THRESHOLD = 0.10
def _hex_to_int(h: str) -> int:
"""Parse a 16-char hex pHash to a Python int. Returns -1 on bad input
@@ -119,14 +112,12 @@ class _UnionFind:
async def regroup_duplicates(
phash_threshold: int = DEFAULT_PHASH_THRESHOLD,
clip_threshold: float = DEFAULT_CLIP_THRESHOLD,
**_ignored,
) -> dict:
"""Full recompute of duplicate groups using pHash + CLIP similarity.
"""Full recompute of duplicate groups using pHash similarity.
Idempotent — safe to call as often as you like. Returns a summary dict.
"""
embedder_model = settings.vision.embedder.name
async with AsyncSessionLocal() as session:
# Pull all visible photos with a phash or embedding.
rows = (
@@ -162,15 +153,6 @@ async def regroup_duplicates(
if _hamming(hi, hj) <= phash_threshold:
uf.union_by_key(phash_ids[i], phash_ids[j])
# ── Phase 2: CLIP similarity via pgvector ──
# For each photo with an embedding, find its nearest neighbors
# within the cosine distance threshold using the HNSW index.
clip_matches = await _clip_neighbor_scan(
session, ids, embedder_model, clip_threshold
)
for photo_id, neighbor_id in clip_matches:
uf.union_by_key(photo_id, neighbor_id)
# ── Write results ──
await _clear_all_groups(session)
@@ -202,16 +184,9 @@ async def regroup_duplicates(
async def incremental_regroup(
since: Optional[datetime] = None,
phash_threshold: int = DEFAULT_PHASH_THRESHOLD,
clip_threshold: float = DEFAULT_CLIP_THRESHOLD,
**_ignored,
) -> dict:
"""Incremental duplicate detection for newly added photos.
Only photos added after `since` are compared against the full library.
Much faster than a full regroup for post-scan updates:
O(new × log N) via HNSW instead of O(N²).
"""
embedder_model = settings.vision.embedder.name
"""Incremental duplicate detection for newly added photos using pHash."""
async with AsyncSessionLocal() as session:
# If no watermark, fall back to full regroup.
if since is None:
@@ -282,13 +257,6 @@ async def incremental_regroup(
if _hamming(nh, eh) <= phash_threshold:
uf.union_by_key(new_id, existing_id)
# ── Phase 2: CLIP — vector similarity for new photos only ──
clip_matches = await _clip_neighbor_scan(
session, new_ids, embedder_model, clip_threshold
)
for photo_id, neighbor_id in clip_matches:
uf.union_by_key(photo_id, neighbor_id)
# ── Write results ──
# Only update groups that contain at least one new photo.
# Clear all groups first, then rewrite.
@@ -323,73 +291,6 @@ async def incremental_regroup(
}
async def _clip_neighbor_scan(
session,
photo_ids: list[str],
embedder_model: str,
threshold: float,
) -> list[tuple[str, str]]:
"""For each photo in `photo_ids` that has a CLIP embedding, find
neighbors within cosine distance `threshold` using pgvector HNSW.
Returns a list of (photo_id, neighbor_id) pairs.
"""
matches: list[tuple[str, str]] = []
if not photo_ids:
return matches
# Batch: get all embeddings for the target photos.
target_embeddings = (
await session.execute(
select(Embedding.photo_id, Embedding.vector)
.where(Embedding.photo_id.in_(photo_ids))
.where(Embedding.model == embedder_model)
)
).all()
if not target_embeddings:
return matches
# For each target, query nearest neighbors via pgvector.
# We use raw SQL for the <=> cosine distance operator.
for photo_id, vector in target_embeddings:
# pgvector cosine distance: <=> operator
# Find top 20 nearest neighbors within threshold.
# Serialize the vector as "[a,b,c,...]" — pgvector's text
# format uses commas; numpy's default str() joins with spaces
# which Postgres rejects with "invalid input syntax for vector".
if hasattr(vector, 'tolist'):
vec_seq = vector.tolist()
else:
vec_seq = list(vector)
vec_text = '[' + ','.join(f'{float(x):.8f}' for x in vec_seq) + ']'
result = await session.execute(
text("""
SELECT e.photo_id, (e.vector <=> :vec) AS distance
FROM embeddings e
JOIN photos p ON p.id = e.photo_id
WHERE e.model = :model
AND e.photo_id != :pid
AND p.is_trashed = false
AND p.is_hidden = false
AND (e.vector <=> :vec) < :threshold
ORDER BY e.vector <=> :vec
LIMIT 20
"""),
{
'vec': vec_text,
'pid': photo_id,
'model': embedder_model,
'threshold': threshold,
}
)
for row in result.all():
matches.append((photo_id, row[0]))
return matches
async def _clear_all_groups(session) -> None:
"""Reset duplicate_group_id / is_duplicate on every photo."""
await session.execute(

View File

@@ -1,23 +1,9 @@
"""
Runtime feature flags for expensive pipeline stages.
Runtime feature flags for the vision pipeline.
The YAML config (``mulita.yml``) ships reasonable defaults. Admins can
toggle these at runtime from the Settings → AI Features tab without
rebuilding the image or editing the bind-mounted YAML; the overrides
live in Redis so both the FastAPI backend and the Celery workers see
the same value within ~1s of the write.
The key namespace is:
mulita:flags:<name> → "true" | "false"
An unset key means "fall back to the YAML default" — so an admin who
has never touched the tab sees exactly the config-file behaviour.
Only bool flags live here. Thresholds, batch sizes, model names etc.
stay in the YAML file because flipping them safely requires restarting
the vision workers (model reload, ONNX session re-init); that's not
something a single admin click should do.
Only one flag now — the master vision switch. Runtime overrides live in
Redis under ``mulita:flags:<name>``; an unset key falls back to the
YAML default.
"""
from __future__ import annotations
@@ -31,30 +17,16 @@ from app.config import settings
logger = logging.getLogger(__name__)
# Feature identifiers. The public name is what the admin UI sends; the
# ``yaml_default`` getter returns the value the YAML would have set.
# Keep these in sync with the VisionSettings fields in ``config.py``.
FLAG_VISION_ENABLED = 'vision.enabled'
FLAG_OCR_ENABLED = 'vision.ocr.enabled'
FLAG_DETECTOR_ENABLED = 'vision.detector.enabled'
FLAG_FACES_ENABLED = 'vision.faces.enabled'
FLAG_CLASSIFIER_ENABLED = 'vision.classifier.enabled'
ALL_FLAGS = (
FLAG_VISION_ENABLED,
FLAG_OCR_ENABLED,
FLAG_DETECTOR_ENABLED,
FLAG_FACES_ENABLED,
FLAG_CLASSIFIER_ENABLED,
)
ALL_FLAGS = (FLAG_VISION_ENABLED,)
_VISION_QUEUE = 'vision'
_REDIS: Optional[redis.Redis] = None
def _redis() -> Optional[redis.Redis]:
"""Lazy Redis client. Returns None if the broker is unreachable so
callers can fall back to YAML defaults instead of crashing."""
global _REDIS
if _REDIS is None:
try:
@@ -69,19 +41,8 @@ def _redis() -> Optional[redis.Redis]:
def _yaml_default(name: str) -> bool:
"""Return the YAML-configured default for a flag. Used when Redis
has no value set (fresh install or admin never touched the tab)."""
v = settings.vision
if name == FLAG_VISION_ENABLED:
return bool(v.enabled)
if name == FLAG_OCR_ENABLED:
return bool(v.ocr.enabled)
if name == FLAG_DETECTOR_ENABLED:
return bool(v.detector.enabled)
if name == FLAG_FACES_ENABLED:
return bool(v.faces.enabled)
if name == FLAG_CLASSIFIER_ENABLED:
return bool(v.classifier.enabled)
return bool(settings.vision.enabled)
raise ValueError(f"Unknown feature flag: {name!r}")
@@ -90,16 +51,6 @@ def _redis_key(name: str) -> str:
def is_enabled(name: str) -> bool:
"""Return True if feature ``name`` is currently enabled.
Order of precedence:
1. Redis override (set by PATCH /admin/feature-flags)
2. YAML default
Reads are cheap (~ms) and we intentionally do NOT add a local
process cache — the whole point of runtime flags is that a toggle
takes effect on the next task without a worker restart.
"""
r = _redis()
if r is not None:
try:
@@ -112,9 +63,6 @@ def is_enabled(name: str) -> bool:
def set_flag(name: str, value: bool) -> None:
"""Persist a flag override to Redis. No-op if Redis is unreachable
(we don't silently pretend to have written; raise so the admin
request returns a 500 instead of misleading success)."""
if name not in ALL_FLAGS:
raise ValueError(f"Unknown feature flag: {name!r}")
r = _redis()
@@ -125,9 +73,6 @@ def set_flag(name: str, value: bool) -> None:
def reset_flag(name: str) -> None:
"""Delete the Redis override so the flag falls back to its YAML
default. Useful if an admin wants a clean slate without guessing
what the config defaults are."""
if name not in ALL_FLAGS:
raise ValueError(f"Unknown feature flag: {name!r}")
r = _redis()
@@ -137,150 +82,41 @@ def reset_flag(name: str) -> None:
_apply_worker_side_effects(name)
# ---------------------------------------------------------------------------
# Worker-level side effects: when the admin flips a flag we don't just want
# gating at task-start (which still executes the message, it just returns
# 'skipped'). We also want queued work gone and the vision worker genuinely
# idle when the master switch is off.
# ---------------------------------------------------------------------------
_VISION_QUEUE = 'vision'
# Flag → celery task name(s) whose queued messages should be dropped when
# the flag goes off. Keeps the queue from replaying yesterday's work the
# moment someone re-enables the stage.
_TASKS_BY_FLAG: dict[str, tuple[str, ...]] = {
FLAG_VISION_ENABLED: (
'embed_photo', 'ocr_photo', 'detect_objects', 'extract_faces',
'classify_content', 'vision_fanout', 'recluster_faces',
),
FLAG_OCR_ENABLED: ('ocr_photo',),
FLAG_DETECTOR_ENABLED: ('detect_objects',),
FLAG_FACES_ENABLED: ('extract_faces', 'recluster_faces'),
FLAG_CLASSIFIER_ENABLED: ('classify_content',),
}
def _apply_worker_side_effects(name: str) -> None:
"""Bring the live workers in line with the new flag value.
For the master ``vision.enabled`` flag we go beyond task gating and
actually stop consumption from the ``vision`` queue — flipping it
off puts the vision worker to sleep (no CPU, no model memory
churn) until it's flipped back on. For per-feature flags, the
running tasks already skip via ``is_enabled``; we just purge any
messages already sitting in the queue so the admin doesn't pay for
a backlog on re-enable.
All operations are best-effort — if control messaging or a Redis
op fails, we log and return; the flag state itself is already
persisted so the gating path continues to work.
"""
"""Attach or detach the vision consumer and purge queued work when
the master flag flips. Best-effort — state is already persisted."""
if name != FLAG_VISION_ENABLED:
return
try:
# Lazy import: avoids a circular dependency between the services
# module (imported from tasks.vision) and the celery app config.
from app.tasks.celery import celery_app
except Exception as e:
logger.warning(f"feature_flags: celery app unavailable for side effects ({e})")
return
try:
if name == FLAG_VISION_ENABLED:
if is_enabled(FLAG_VISION_ENABLED):
# Re-attach the vision consumer so workers pick up tasks
# again. broadcast=True ensures every running worker
# receives the command.
celery_app.control.add_consumer(_VISION_QUEUE, reply=False)
logger.info("feature_flags: vision re-enabled; consumer added")
else:
celery_app.control.cancel_consumer(_VISION_QUEUE, reply=False)
_purge_queue(_VISION_QUEUE)
logger.info(
"feature_flags: vision disabled; consumer cancelled "
"and queue purged"
)
return
# Per-feature flag going off → drop pending tasks of its types.
if not is_enabled(name):
targets = _TASKS_BY_FLAG.get(name, ())
if targets:
removed = _purge_queue_by_task_names(_VISION_QUEUE, targets)
logger.info(
f"feature_flags: {name} disabled; removed {removed} "
f"pending messages from {_VISION_QUEUE}"
)
if is_enabled(FLAG_VISION_ENABLED):
celery_app.control.add_consumer(_VISION_QUEUE, reply=False)
logger.info("feature_flags: vision re-enabled; consumer added")
else:
celery_app.control.cancel_consumer(_VISION_QUEUE, reply=False)
_purge_queue(_VISION_QUEUE)
logger.info("feature_flags: vision disabled; consumer cancelled and queue purged")
except Exception as e:
logger.warning(f"feature_flags: worker side effects failed for {name}: {e}")
logger.warning(f"feature_flags: worker side effects failed: {e}")
def _purge_queue(queue: str) -> int:
"""Drop every pending message from ``queue``. Returns the count
deleted. Celery's control.purge() purges the default queue only,
so we delete the Redis key directly (the broker's queue list)."""
r = _redis()
if r is None:
return 0
try:
removed = r.delete(queue)
return int(removed or 0)
return int(r.delete(queue) or 0)
except Exception as e:
logger.warning(f"feature_flags: purge {queue} failed: {e}")
return 0
def _purge_queue_by_task_names(queue: str, task_names: tuple[str, ...]) -> int:
"""Walk ``queue`` and drop any message whose Celery task name is in
``task_names``. Other messages are preserved (pushed back in order)
so we don't flush embed tasks when the admin disabled only OCR.
Celery stores each message as a JSON blob in a Redis list; the
task name lives at ``headers.task``.
"""
import json
r = _redis()
if r is None:
return 0
try:
# Snapshot the queue, then rebuild it without the filtered names.
# Done inside a Redis transaction so a concurrent enqueue doesn't
# race with us (worst case it gets re-delivered after we release,
# which is the normal enqueue path anyway).
pipe = r.pipeline()
pipe.lrange(queue, 0, -1)
pipe.delete(queue)
raw_items, _ = pipe.execute()
kept: list[bytes | str] = []
removed = 0
for raw in raw_items or []:
try:
# Messages can be bytes or str depending on decode_responses.
payload = raw.decode() if isinstance(raw, bytes) else raw
msg = json.loads(payload)
task = (
msg.get('headers', {}).get('task')
or msg.get('task')
)
if task in task_names:
removed += 1
continue
except Exception:
# Unparseable message — keep it, better to leak than
# to silently drop a message we can't identify.
pass
kept.append(raw)
if kept:
r.rpush(queue, *kept)
return removed
except Exception as e:
logger.warning(f"feature_flags: selective purge failed on {queue}: {e}")
return 0
def snapshot() -> dict[str, dict[str, object]]:
"""Return every flag's current effective value, YAML default, and
whether it's overridden. Powers the admin UI tab.
"""
r = _redis()
out: dict[str, dict[str, object]] = {}
for name in ALL_FLAGS:

View File

@@ -1,19 +1,13 @@
"""
Unified search service — hybrid FTS + semantic (RRF) search.
Phase 1 (PR4): semantic-only via pgvector cosine similarity.
Phase 2 (PR5): adds FTS via tsvector, enables RRF fusion.
FTS search over photos.search_vector with optional tag/date filters.
"""
import logging
from typing import Optional
import numpy as np
from sqlalchemy import select, text, func
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Photo
from app.models.embeddings import Embedding
from app.config import settings
logger = logging.getLogger(__name__)
@@ -27,135 +21,57 @@ async def hybrid_search(
limit: int = 50,
offset: int = 0,
) -> list[dict]:
"""Run hybrid search (FTS + semantic) with RRF fusion.
Currently semantic-only; FTS leg added in PR5.
"""
model_name = settings.vision.embedder.name
results = {}
# ── Semantic search (CLIP text → pgvector cosine) ─────────────────
"""Full-text search using photos.search_vector. No embeddings, no OCR."""
if q:
try:
from app.services.vision.registry import registry
embedder = registry.get_embedder()
query_vec = embedder.embed_text(q)
# pgvector cosine distance: <=> returns distance (lower = closer).
# Join photos so we can filter out discarded / hidden rows
# inside the same query — otherwise a hidden-folder photo
# can take a top-N rank and starve the visible results.
vec_str = "[" + ",".join(str(float(v)) for v in query_vec) + "]"
stmt = text("""
SELECT e.photo_id,
(e.vector <=> :qvec::vector) AS distance
FROM embeddings e
JOIN photos p ON p.id = e.photo_id
WHERE e.model = :model
AND p.is_trashed = false
AND p.is_hidden = false
ORDER BY e.vector <=> :qvec::vector
LIMIT 200
""")
rows = (await db.execute(stmt, {"qvec": vec_str, "model": model_name})).fetchall()
for rank, (photo_id, distance) in enumerate(rows):
if photo_id not in results:
results[photo_id] = {"semantic_rank": rank, "fts_rank": None}
else:
results[photo_id]["semantic_rank"] = rank
except Exception as e:
logger.warning("Semantic search failed (models may not be loaded): %s", e)
# ── FTS search (photos.search_vector + ocr_text) ────────────────
if q:
try:
# Same discarded/hidden filter as the semantic leg.
# The OCR branch joins photos (through photo_id) so we can
# filter there too; otherwise OCR hits in hidden folders
# would leak into results.
fts_stmt = text("""
SELECT id, ts_rank(search_vector, plainto_tsquery('english', :q)) AS rank
FROM photos
WHERE search_vector @@ plainto_tsquery('english', :q)
AND is_trashed = false
AND is_hidden = false
UNION
SELECT o.photo_id AS id,
MAX(o.confidence) AS rank
FROM ocr_text o
JOIN photos p ON p.id = o.photo_id
WHERE to_tsvector('english', o.text) @@ plainto_tsquery('english', :q)
AND p.is_trashed = false
AND p.is_hidden = false
GROUP BY o.photo_id
ORDER BY rank DESC
LIMIT 200
LIMIT 500
""")
fts_rows = (await db.execute(fts_stmt, {"q": q})).fetchall()
for rank, (photo_id, score) in enumerate(fts_rows):
if photo_id not in results:
results[photo_id] = {"semantic_rank": None, "fts_rank": rank}
else:
results[photo_id]["fts_rank"] = rank
rows = (await db.execute(fts_stmt, {"q": q})).fetchall()
except Exception as e:
logger.warning("FTS search failed: %s", e)
rows = []
# ── RRF fusion ────────────────────────────────────────────────────
k = 60
scored = []
for photo_id, ranks in results.items():
score = 0.0
if ranks["semantic_rank"] is not None:
score += 1.0 / (k + ranks["semantic_rank"])
if ranks.get("fts_rank") is not None:
score += 1.0 / (k + ranks["fts_rank"])
scored.append((photo_id, score))
scored = [(pid, float(rank)) for pid, rank in rows]
scored.sort(key=lambda x: -x[1])
# If no text query, fall back to recent photos. Always filter out
# discarded + hidden here — this path backs the "Tags" and "People"
# browse views, which should honor the folder hide flag.
if not q:
if tag_ids:
from app.models.tags import photo_tags
# Subquery to get distinct photo_ids matching the tag filter
sub = select(photo_tags.c.photo_id).where(
photo_tags.c.tag_id.in_(tag_ids)
).distinct().subquery()
stmt = select(Photo.id).join(sub, Photo.id == sub.c.photo_id)
else:
stmt = select(Photo.id)
stmt = stmt.where(
Photo.is_discarded.is_(False),
Photo.is_hidden.is_(False),
)
stmt = stmt.order_by(Photo.added_at.desc())
if date_from:
stmt = stmt.where(Photo.taken_at >= date_from)
if date_to:
stmt = stmt.where(Photo.taken_at <= date_to)
stmt = stmt.offset(offset).limit(limit)
rows = (await db.execute(stmt)).fetchall()
return [{"photo_id": row[0], "score": 0.0} for row in rows]
photo_ids = [pid for pid, _ in scored]
if not photo_ids:
return []
stmt = select(photo_tags.c.photo_id).where(
photo_tags.c.photo_id.in_(photo_ids),
photo_tags.c.tag_id.in_(tag_ids),
).distinct()
valid = {row[0] for row in (await db.execute(stmt)).fetchall()}
scored = [(pid, s) for pid, s in scored if pid in valid]
# Apply filters to scored results
photo_ids = [pid for pid, _ in scored]
if not photo_ids:
return []
page = scored[offset : offset + limit]
return [{"photo_id": pid, "score": s} for pid, s in page]
# Filter by tags if requested
# No text query — recent photos with tag/date filters.
if tag_ids:
from app.models.tags import photo_tags
stmt = select(photo_tags.c.photo_id).where(
photo_tags.c.photo_id.in_(photo_ids),
photo_tags.c.tag_id.in_(tag_ids),
).distinct()
valid_ids = {row[0] for row in (await db.execute(stmt)).fetchall()}
scored = [(pid, s) for pid, s in scored if pid in valid_ids]
# Paginate
page = scored[offset : offset + limit]
return [{"photo_id": pid, "score": score} for pid, score in page]
sub = select(photo_tags.c.photo_id).where(
photo_tags.c.tag_id.in_(tag_ids)
).distinct().subquery()
stmt = select(Photo.id).join(sub, Photo.id == sub.c.photo_id)
else:
stmt = select(Photo.id)
stmt = stmt.where(
Photo.is_discarded.is_(False),
Photo.is_hidden.is_(False),
)
if date_from:
stmt = stmt.where(Photo.taken_at >= date_from)
if date_to:
stmt = stmt.where(Photo.taken_at <= date_to)
stmt = stmt.order_by(Photo.added_at.desc()).offset(offset).limit(limit)
rows = (await db.execute(stmt)).fetchall()
return [{"photo_id": row[0], "score": 0.0} for row in rows]

View File

@@ -1,105 +1,25 @@
"""
Abstract base classes for vision backends.
Abstract base classes for the vision backend.
Each ABC defines the contract a backend must satisfy. The default
implementation is ONNXBackend (onnx_backend.py). A ROCm backend can be
added later by subclassing these ABCs and registering via
settings.vision.backend.
The pipeline is now a single binary classifier: photography vs other.
Feature extraction is an internal detail of the classifier and is not
exposed as a separate service.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass
import numpy as np
@dataclass
class DetectionBox:
"""A single object detection result."""
label: str
confidence: float
bbox: list[float] # [x1, y1, x2, y2] normalized 0-1
@dataclass
class OCRResult:
"""A single OCR text region."""
text: str
confidence: float
bbox: list[float] # [x1, y1, x2, y2] normalized 0-1
language: str = ""
@dataclass
class FaceDetection:
"""A detected face with its recognition embedding."""
bbox: list[float] # [x1, y1, x2, y2] normalized 0-1
embedding: np.ndarray # float32 vector (128-d for SFace)
quality: float
@dataclass
class ClassificationResult:
"""A content-type classification."""
label: str
confidence: float
class Embedder(ABC):
"""Generates image and text embeddings (e.g. OpenCLIP ViT-B/32)."""
@abstractmethod
def embed_image(self, image: np.ndarray) -> np.ndarray:
"""Return a normalized float32 embedding vector for an RGB image."""
...
@abstractmethod
def embed_text(self, text: str) -> np.ndarray:
"""Return a normalized float32 embedding vector for a text query."""
...
@property
@abstractmethod
def dim(self) -> int:
"""Dimensionality of the output embedding."""
...
class OCREngine(ABC):
"""Extracts text from images (e.g. rapidocr-onnxruntime)."""
@abstractmethod
def run(self, image: np.ndarray) -> list[OCRResult]:
"""Return OCR results for an RGB image."""
...
class ObjectDetector(ABC):
"""Detects objects in images (e.g. YOLOv8n)."""
@abstractmethod
def detect(self, image: np.ndarray) -> list[DetectionBox]:
"""Return detections for an RGB image."""
...
class ContentClassifier(ABC):
"""Classifies images into content types (screenshot, document, etc.)."""
"""Classifies an image into 'photography' or 'other'."""
@abstractmethod
def classify(self, image: np.ndarray) -> list[ClassificationResult]:
"""Return content type classifications for an RGB image."""
...
class FaceProcessor(ABC):
"""Detects faces and extracts recognition embeddings (e.g. YuNet + SFace)."""
@abstractmethod
def process(self, image: np.ndarray) -> list[FaceDetection]:
"""Return face detections with embeddings for an RGB image."""
...
@property
@abstractmethod
def embedding_dim(self) -> int:
"""Dimensionality of face embedding vectors."""
def classify(self, image: np.ndarray) -> ClassificationResult:
...

View File

@@ -1,124 +1,46 @@
"""
Download vision model weights on first worker boot.
Run as: python -m app.services.vision.bootstrap_models
Or called from the vision worker entrypoint before Celery starts.
Downloads are idempotent — existing files are skipped.
For models that require export (OpenCLIP, YOLOv8n), see export_models.py.
Those must be exported once on any machine with pip, then placed in
the models volume before the worker starts.
Ensure the OpenCLIP ViT-B/32 visual encoder is present on worker boot.
Exported via export_models.py if missing.
"""
import logging
import os
from pathlib import Path
from urllib.request import urlretrieve
from app.config import settings
logger = logging.getLogger(__name__)
# (relative_path, url, description)
# Models with url=None must be pre-exported via export_models.py.
# InsightFace (RetinaFace + ArcFace) auto-downloads via the insightface
# package on first use — no manual download entries needed.
DOWNLOADS = []
# Models that need manual export via export_models.py
EXPORTS = [
REQUIRED = [
("embed/visual.onnx", "OpenCLIP ViT-B/32 visual encoder"),
("embed/textual.onnx", "OpenCLIP ViT-B/32 textual encoder"),
("detect/yolov8n.onnx", "YOLOv8n object detector"),
]
def bootstrap(models_dir: str | None = None):
"""Ensure all model files are present. Download what we can, warn about
files that need manual export."""
base = Path(models_dir or settings.vision.models_dir)
base.mkdir(parents=True, exist_ok=True)
# Download auto-downloadable models
for rel_path, url, desc in DOWNLOADS:
dest = base / rel_path
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists():
logger.debug("Already exists: %s (%s)", dest, desc)
continue
logger.info("Downloading %s%s", desc, dest)
try:
urlretrieve(url, str(dest))
size_kb = dest.stat().st_size / 1024
logger.info("Downloaded %s (%.0f KB)", desc, size_kb)
except Exception as e:
logger.error("Failed to download %s: %s", desc, e)
if dest.exists():
dest.unlink()
# Check for manually-exported models
missing = []
for rel_path, desc in EXPORTS:
dest = base / rel_path
if not dest.exists():
missing.append((rel_path, desc))
missing = [(rel, desc) for rel, desc in REQUIRED if not (base / rel).exists()]
if missing:
logger.warning(
"Missing %d model file(s); attempting automatic export:",
len(missing),
)
for rel_path, desc in missing:
logger.warning(" %s%s", base / rel_path, desc)
logger.warning("Missing %d model file(s); attempting automatic export", len(missing))
try:
from app.services.vision import export_models
export_models.export_openclip_visual(base)
except Exception as e:
logger.error(
"Export failed: %s. Run `python -m app.services.vision.export_models "
"--models-dir %s` manually to retry.",
e, base,
)
from app.services.vision import export_models
missing_paths = {rel for rel, _ in missing}
# Only run the export functions whose outputs are actually missing.
# Each function is mapped to the file(s) it produces.
export_map = [
(export_models.export_openclip, ["embed/visual.onnx", "embed/textual.onnx"]),
(export_models.export_siglip2, ["embed_siglip2/visual.onnx", "embed_siglip2/textual.onnx"]),
(export_models.export_yolov8n, ["detect/yolov8n.onnx"]),
]
for export_fn, outputs in export_map:
if not any(o in missing_paths for o in outputs):
continue
try:
export_fn(base)
except Exception as e:
logger.error(
"Export step %s failed: %s. "
"Run `python -m app.services.vision.export_models "
"--models-dir %s` manually to retry.",
export_fn.__name__,
e,
base,
)
# Re-check what's still missing after the export pass.
still_missing = [
(rel_path, desc)
for rel_path, desc in EXPORTS
if not (base / rel_path).exists()
]
if still_missing:
for rel_path, desc in still_missing:
logger.error(" still missing: %s%s", base / rel_path, desc)
else:
logger.info("All model files present in %s", base)
still_missing = [(r, d) for r, d in REQUIRED if not (base / r).exists()]
if still_missing:
for rel, desc in still_missing:
logger.error(" still missing: %s%s", base / rel, desc)
else:
logger.info("All model files present in %s", base)
# Signal readiness via Redis so the scan pipeline knows the vision
# worker can accept tasks.
try:
import redis as _redis
r = _redis.from_url(settings.redis_url)
r.set("mulita:vision:ready", "1")
_redis.from_url(settings.redis_url).set("mulita:vision:ready", "1")
logger.info("Set mulita:vision:ready in Redis")
except Exception as e:
logger.warning("Could not set vision readiness flag in Redis: %s", e)

View File

@@ -1,117 +1,106 @@
"""
CLIP zero-shot content-type classifier.
Binary content classifier: 'photography' vs 'other'.
Uses the native OpenCLIP PyTorch text encoder for high-quality text
embeddings (the ONNX text encoder has degraded quality due to the
eot_indices workaround). Image embeddings use the ONNX visual encoder
which works well.
Uses OpenCLIP ViT-B/32 image features (ONNX) and two pre-computed text
prompt centroids. Text centroids are computed once with the native
open_clip text encoder and cached to {models_dir}/classifier/vectors.npz
so steady-state worker startup doesn't pay the PyTorch cost.
"""
from __future__ import annotations
import logging
from pathlib import Path
import numpy as np
import torch
from app.config import VisionSettings
from app.services.vision.base import ContentClassifier, ClassificationResult
from app.services.vision.base import ClassificationResult, ContentClassifier
from app.services.vision.embed import CLIPVisualEncoder
logger = logging.getLogger(__name__)
CATEGORY_PROMPTS = {
"screenshot": [
"a screenshot of a computer screen",
"a screenshot of a phone screen",
"a screen capture of a user interface",
],
"document": [
"a scanned document",
"a photo of a document with printed text",
"a photo of a page of text on paper",
],
"receipt": [
"a photo of a receipt",
"a photo of a bill or invoice",
],
"meme": [
"an internet meme with text overlay",
"a funny image with caption text",
],
"artwork": [
"a painting or drawing",
"a sketch or illustration",
"digital art or graphic design",
],
"photograph": [
PROMPTS = {
"photography": [
"a photograph taken with a camera",
"a real photo of a real scene or person",
"a candid photograph",
"a portrait photograph",
"a landscape photograph",
],
"other": [
"a screenshot of a computer screen",
"a screenshot of a phone screen",
"a screen capture of a user interface",
"a scanned document",
"a photo of a document with printed text",
"a photo of a receipt",
"a photo of a bill or invoice",
"an internet meme with text overlay",
"a funny image with caption text",
"a digital illustration or graphic design",
],
}
def _compute_text_centroids() -> dict[str, np.ndarray]:
"""Compute the 'photography' and 'other' centroid vectors using the
open_clip text encoder. Only called on the cache-miss path."""
import open_clip
import torch
logger.info("Computing CLIP text centroids for binary classifier")
model, _, _ = open_clip.create_model_and_transforms(
"ViT-B-32", pretrained="laion2b_s34b_b79k"
)
model.eval()
tokenizer = open_clip.get_tokenizer("ViT-B-32")
centroids: dict[str, np.ndarray] = {}
for label, prompts in PROMPTS.items():
tokens = tokenizer(prompts)
with torch.no_grad():
feats = model.encode_text(tokens)
feats = feats / feats.norm(dim=-1, keepdim=True)
avg = feats.mean(dim=0)
avg = avg / avg.norm()
centroids[label] = avg.numpy().astype(np.float32)
return centroids
class CLIPContentClassifier(ContentClassifier):
"""Zero-shot content classifier using CLIP text-image similarity.
Uses native PyTorch for text encoding, ONNX for image encoding."""
def __init__(self, settings: VisionSettings):
import open_clip
self._min_confidence = settings.classifier.min_confidence
self._encoder = CLIPVisualEncoder(settings)
# Load native model for text encoding only.
# Use whichever model family the embedder is configured for so
# the classification text vectors live in the same space as the
# image embeddings.
embedder_name = settings.embedder.name
if embedder_name.startswith("siglip2"):
model_arch = "ViT-B-16-SigLIP-384"
pretrained = "webli"
cache_dir = Path(settings.models_dir) / "classifier"
cache_dir.mkdir(parents=True, exist_ok=True)
cache_path = cache_dir / "vectors.npz"
if cache_path.exists():
logger.info("Loading cached text centroids from %s", cache_path)
data = np.load(cache_path)
self._photo = data["photography"].astype(np.float32)
self._other = data["other"].astype(np.float32)
else:
model_arch = "ViT-B-32"
pretrained = "laion2b_s34b_b79k"
centroids = _compute_text_centroids()
self._photo = centroids["photography"]
self._other = centroids["other"]
np.savez(cache_path, photography=self._photo, other=self._other)
logger.info("Cached text centroids to %s", cache_path)
logger.info("Loading %s text encoder for content classification", model_arch)
model, _, _ = open_clip.create_model_and_transforms(
model_arch, pretrained=pretrained
)
model.eval()
self._model = model
self._tokenizer = open_clip.get_tokenizer(model_arch)
def classify(self, image: np.ndarray) -> ClassificationResult:
vec = self._encoder.encode(image)
s_photo = float(np.dot(vec, self._photo))
s_other = float(np.dot(vec, self._other))
# Get the ONNX image embedder from the registry
from app.services.vision.registry import registry
self._embedder = registry.get_embedder()
if s_photo >= s_other:
label = "photography"
margin = s_photo - s_other
else:
label = "other"
margin = s_other - s_photo
# Pre-compute text embeddings for each category
self._category_embeddings: dict[str, np.ndarray] = {}
for category, prompts in CATEGORY_PROMPTS.items():
tokens = self._tokenizer(prompts)
with torch.no_grad():
text_features = model.encode_text(tokens)
text_features /= text_features.norm(dim=-1, keepdim=True)
avg = text_features.mean(dim=0)
avg /= avg.norm()
self._category_embeddings[category] = avg.numpy().astype(np.float32)
logger.info("Content classifier ready with %d categories", len(self._category_embeddings))
def classify(self, image: np.ndarray) -> list[ClassificationResult]:
img_vec = self._embedder.embed_image(image)
# Cosine similarity against each category
scores = {}
for category, cat_vec in self._category_embeddings.items():
scores[category] = float(np.dot(img_vec, cat_vec))
# Sort by score descending
ranked = sorted(scores.items(), key=lambda x: -x[1])
best_cat, best_score = ranked[0]
second_score = ranked[1][1]
margin = best_score - second_score
# Normalize: 0.01 margin → ~0.5 confidence, 0.03+ → ~1.0
# 0.01 margin → ~0.3 conf, 0.03+ → ~1.0
confidence = min(1.0, margin * 30)
if confidence >= self._min_confidence:
return [ClassificationResult(label=best_cat, confidence=confidence)]
return []
return ClassificationResult(label=label, confidence=confidence)

View File

@@ -1,42 +0,0 @@
"""
Face embedding clustering using DBSCAN with cosine distance.
Called by the periodic `recluster_faces` Celery task (PR7).
"""
import logging
import numpy as np
from sklearn.cluster import DBSCAN
logger = logging.getLogger(__name__)
def cluster_faces(
embeddings: np.ndarray,
eps: float = 0.35,
min_samples: int = 2,
) -> np.ndarray:
"""Cluster face embeddings using DBSCAN with cosine metric.
Args:
embeddings: (N, D) float32 array of L2-normalized face embeddings.
eps: Maximum cosine distance between two samples to be in the
same neighborhood. Lower = tighter clusters.
min_samples: Minimum cluster size.
Returns:
(N,) int array of cluster labels. -1 = noise / unclustered.
"""
if len(embeddings) < min_samples:
return np.full(len(embeddings), -1, dtype=int)
db = DBSCAN(eps=eps, min_samples=min_samples, metric="cosine")
labels = db.fit_predict(embeddings)
n_clusters = len(set(labels) - {-1})
n_noise = (labels == -1).sum()
logger.info(
"Face clustering: %d embeddings → %d clusters, %d noise",
len(embeddings), n_clusters, n_noise,
)
return labels

View File

@@ -1,138 +0,0 @@
"""
YOLOv8n object detector using raw ONNX Runtime.
Expects {models_dir}/detect/yolov8n.onnx, exported from ultralytics
via bootstrap_models.py. We do NOT ship ultralytics at runtime to
avoid dragging in torch.
"""
import logging
from pathlib import Path
import numpy as np
import onnxruntime as ort
from app.config import VisionSettings
from app.services.vision.base import ObjectDetector, DetectionBox
logger = logging.getLogger(__name__)
_INPUT_SIZE = 640
# COCO class names (80 classes)
COCO_LABELS = [
"person", "bicycle", "car", "motorcycle", "airplane", "bus", "train",
"truck", "boat", "traffic light", "fire hydrant", "stop sign",
"parking meter", "bench", "bird", "cat", "dog", "horse", "sheep",
"cow", "elephant", "bear", "zebra", "giraffe", "backpack", "umbrella",
"handbag", "tie", "suitcase", "frisbee", "skis", "snowboard",
"sports ball", "kite", "baseball bat", "baseball glove", "skateboard",
"surfboard", "tennis racket", "bottle", "wine glass", "cup", "fork",
"knife", "spoon", "bowl", "banana", "apple", "sandwich", "orange",
"broccoli", "carrot", "hot dog", "pizza", "donut", "cake", "chair",
"couch", "potted plant", "bed", "dining table", "toilet", "tv",
"laptop", "mouse", "remote", "keyboard", "cell phone", "microwave",
"oven", "toaster", "sink", "refrigerator", "book", "clock", "vase",
"scissors", "teddy bear", "hair drier", "toothbrush",
]
def _preprocess(image: np.ndarray) -> tuple[np.ndarray, float, float]:
"""Letterbox-resize + normalize to NCHW float32. Returns input tensor
and scale factors for mapping boxes back to original coords."""
from PIL import Image
img = Image.fromarray(image).convert("RGB")
orig_w, orig_h = img.size
scale = min(_INPUT_SIZE / orig_w, _INPUT_SIZE / orig_h)
new_w = int(orig_w * scale)
new_h = int(orig_h * scale)
img = img.resize((new_w, new_h), Image.BICUBIC)
# Paste onto gray canvas
canvas = np.full((_INPUT_SIZE, _INPUT_SIZE, 3), 114, dtype=np.uint8)
pad_x = (_INPUT_SIZE - new_w) // 2
pad_y = (_INPUT_SIZE - new_h) // 2
canvas[pad_y : pad_y + new_h, pad_x : pad_x + new_w] = np.array(img)
blob = canvas.astype(np.float32) / 255.0
blob = blob.transpose(2, 0, 1)[np.newaxis] # NCHW
return blob, scale, pad_x, pad_y
def _postprocess(
outputs: np.ndarray,
scale: float,
pad_x: int,
pad_y: int,
orig_w: int,
orig_h: int,
conf_threshold: float,
max_detections: int,
) -> list[DetectionBox]:
"""Parse YOLOv8 output (1, 84, N) → list of DetectionBox."""
# outputs shape: (1, 84, N) where 84 = 4 box coords + 80 class scores
preds = outputs[0] # (84, N)
preds = preds.T # (N, 84)
boxes_xywh = preds[:, :4]
scores = preds[:, 4:]
class_ids = np.argmax(scores, axis=1)
confidences = scores[np.arange(len(scores)), class_ids]
mask = confidences >= conf_threshold
boxes_xywh = boxes_xywh[mask]
class_ids = class_ids[mask]
confidences = confidences[mask]
if len(confidences) == 0:
return []
# Sort by confidence, take top N
order = np.argsort(-confidences)[:max_detections]
boxes_xywh = boxes_xywh[order]
class_ids = class_ids[order]
confidences = confidences[order]
results = []
for i in range(len(confidences)):
cx, cy, w, h = boxes_xywh[i]
# Remove letterbox padding and rescale to original image
x1 = (cx - w / 2 - pad_x) / scale
y1 = (cy - h / 2 - pad_y) / scale
x2 = (cx + w / 2 - pad_x) / scale
y2 = (cy + h / 2 - pad_y) / scale
# Normalize to 0-1
bbox = [
max(0, x1 / orig_w),
max(0, y1 / orig_h),
min(1, x2 / orig_w),
min(1, y2 / orig_h),
]
label = COCO_LABELS[class_ids[i]] if class_ids[i] < len(COCO_LABELS) else f"class_{class_ids[i]}"
results.append(DetectionBox(label=label, confidence=float(confidences[i]), bbox=bbox))
return results
class YOLOv8Detector(ObjectDetector):
def __init__(self, settings: VisionSettings):
model_path = Path(settings.models_dir) / "detect" / "yolov8n.onnx"
from app.services.vision.providers import create_session
logger.info("Loading YOLOv8n from %s", model_path)
self._session = create_session(str(model_path), configured_providers=settings.execution_providers)
self._conf_threshold = settings.detector.min_confidence
self._max_detections = settings.detector.max_detections
def detect(self, image: np.ndarray) -> list[DetectionBox]:
orig_h, orig_w = image.shape[:2]
blob, scale, pad_x, pad_y = _preprocess(image)
input_name = self._session.get_inputs()[0].name
outputs = self._session.run(None, {input_name: blob})[0]
return _postprocess(
outputs, scale, pad_x, pad_y, orig_w, orig_h,
self._conf_threshold, self._max_detections,
)

View File

@@ -1,146 +1,58 @@
"""
CLIP / SigLIP2 embedder using ONNX Runtime.
Supports two model families:
- OpenCLIP ViT-B/32 (512-d) — legacy, config name "openclip_vitb32"
- SigLIP2 ViT-B/16 (768-d) — default, config name "siglip2_vitb16"
Expects two ONNX files under {models_dir}/embed/:
- visual.onnx (image encoder)
- textual.onnx (text encoder)
These are exported from open_clip via export_models.py / bootstrap_models.py.
OpenCLIP ViT-B/32 visual encoder (ONNX). Produces 512-d image features
consumed by the content classifier. Not exposed as a standalone service;
the classifier owns the lifecycle.
"""
import logging
from pathlib import Path
import numpy as np
import onnxruntime as ort
import onnxruntime as ort # noqa: F401 (provider plumbing relies on this)
from app.config import VisionSettings
from app.services.vision.base import Embedder
logger = logging.getLogger(__name__)
# ── Model-specific constants ──────────────────────────────────────────
# OpenCLIP ViT-B/32 (ImageNet norm, 224px)
_OPENCLIP_MEAN = np.array([0.48145466, 0.4578275, 0.40821073], dtype=np.float32)
_OPENCLIP_STD = np.array([0.26862954, 0.26130258, 0.27577711], dtype=np.float32)
_OPENCLIP_SIZE = 224
# SigLIP2 ViT-B/16 (SigLIP norm, 384px)
_SIGLIP2_MEAN = np.array([0.5, 0.5, 0.5], dtype=np.float32)
_SIGLIP2_STD = np.array([0.5, 0.5, 0.5], dtype=np.float32)
_SIGLIP2_SIZE = 384
_MEAN = np.array([0.48145466, 0.4578275, 0.40821073], dtype=np.float32)
_STD = np.array([0.26862954, 0.26130258, 0.27577711], dtype=np.float32)
_SIZE = 224
def _preprocess_image(
image: np.ndarray,
input_size: int,
mean: np.ndarray,
std: np.ndarray,
) -> np.ndarray:
"""Resize, center-crop, normalize an RGB uint8 image to NCHW float32."""
def _preprocess(image: np.ndarray) -> np.ndarray:
from PIL import Image
img = Image.fromarray(image).convert("RGB")
w, h = img.size
scale = input_size / min(w, h)
scale = _SIZE / min(w, h)
img = img.resize((int(w * scale), int(h * scale)), Image.BICUBIC)
w, h = img.size
left = (w - input_size) // 2
top = (h - input_size) // 2
img = img.crop((left, top, left + input_size, top + input_size))
left = (w - _SIZE) // 2
top = (h - _SIZE) // 2
img = img.crop((left, top, left + _SIZE, top + _SIZE))
arr = np.array(img, dtype=np.float32) / 255.0
arr = (arr - mean) / std
arr = arr.transpose(2, 0, 1) # HWC → CHW
return arr[np.newaxis] # NCHW
arr = (arr - _MEAN) / _STD
arr = arr.transpose(2, 0, 1)
return arr[np.newaxis]
class OpenCLIPEmbedder(Embedder):
"""Legacy OpenCLIP ViT-B/32 embedder (512-d)."""
class CLIPVisualEncoder:
"""OpenCLIP ViT-B/32 image encoder, 512-d normalized output."""
def __init__(self, settings: VisionSettings):
model_dir = Path(settings.models_dir) / "embed"
visual_path = model_dir / "visual.onnx"
textual_path = model_dir / "textual.onnx"
model_path = Path(settings.models_dir) / "embed" / "visual.onnx"
from app.services.vision.providers import create_session
from app.config import settings as app_settings
providers = app_settings.vision.execution_providers
logger.info("Loading OpenCLIP visual encoder from %s", visual_path)
self._visual = create_session(str(visual_path), configured_providers=providers)
logger.info("Loading CLIP visual encoder from %s", model_path)
self._session = create_session(
str(model_path),
configured_providers=app_settings.vision.execution_providers,
)
logger.info("Loading OpenCLIP textual encoder from %s", textual_path)
self._textual = create_session(str(textual_path), configured_providers=providers)
def embed_image(self, image: np.ndarray) -> np.ndarray:
inp = _preprocess_image(image, _OPENCLIP_SIZE, _OPENCLIP_MEAN, _OPENCLIP_STD)
input_name = self._visual.get_inputs()[0].name
out = self._visual.run(None, {input_name: inp})[0][0]
def encode(self, image: np.ndarray) -> np.ndarray:
inp = _preprocess(image)
name = self._session.get_inputs()[0].name
out = self._session.run(None, {name: inp})[0][0]
out = out / np.linalg.norm(out)
return out.astype(np.float32)
def embed_text(self, text: str) -> np.ndarray:
import open_clip
tokenizer = open_clip.get_tokenizer("ViT-B-32")
tokens = tokenizer([text]).numpy().astype(np.int64)
eot_indices = tokens.argmax(axis=-1).astype(np.int64)
inputs = self._textual.get_inputs()
out = self._textual.run(None, {
inputs[0].name: tokens,
inputs[1].name: eot_indices,
})[0][0]
out = out / np.linalg.norm(out)
return out.astype(np.float32)
@property
def dim(self) -> int:
return 512
class SigLIP2Embedder(Embedder):
"""SigLIP2 ViT-B/16 embedder (768-d) — higher recall than OpenCLIP."""
def __init__(self, settings: VisionSettings):
model_dir = Path(settings.models_dir) / "embed_siglip2"
visual_path = model_dir / "visual.onnx"
textual_path = model_dir / "textual.onnx"
from app.services.vision.providers import create_session
from app.config import settings as app_settings
providers = app_settings.vision.execution_providers
logger.info("Loading SigLIP2 visual encoder from %s", visual_path)
self._visual = create_session(str(visual_path), configured_providers=providers)
logger.info("Loading SigLIP2 textual encoder from %s", textual_path)
self._textual = create_session(str(textual_path), configured_providers=providers)
def embed_image(self, image: np.ndarray) -> np.ndarray:
inp = _preprocess_image(image, _SIGLIP2_SIZE, _SIGLIP2_MEAN, _SIGLIP2_STD)
input_name = self._visual.get_inputs()[0].name
out = self._visual.run(None, {input_name: inp})[0][0]
out = out / np.linalg.norm(out)
return out.astype(np.float32)
def embed_text(self, text: str) -> np.ndarray:
import open_clip
tokenizer = open_clip.get_tokenizer("ViT-B-16-SigLIP-384")
tokens = tokenizer([text]).numpy().astype(np.int64)
inputs = self._textual.get_inputs()
feed = {inputs[0].name: tokens}
# SigLIP2 text encoder may need attention mask
if len(inputs) > 1:
attention_mask = (tokens != 0).astype(np.int64)
feed[inputs[1].name] = attention_mask
out = self._textual.run(None, feed)[0][0]
out = out / np.linalg.norm(out)
return out.astype(np.float32)
@property
def dim(self) -> int:
return 768

View File

@@ -1,253 +1,61 @@
"""
Export / download all vision model weights to ONNX format.
Export the OpenCLIP ViT-B/32 visual encoder to ONNX.
Run ONCE on any machine with Python + pip (doesn't need GPU):
Run once on any machine with Python + pip (no GPU needed):
pip install open-clip-torch ultralytics onnx
pip install open-clip-torch onnx
python -m app.services.vision.export_models [--models-dir /data/models]
This produces:
embed/visual.onnx (~350 MB)
embed/textual.onnx (~250 MB)
detect/yolov8n.onnx (~12 MB)
YuNet and SFace are downloaded by bootstrap_models.py at worker boot
(Apache 2.0, lightweight, no export step needed).
After export, copy the /data/models directory into your Docker volume:
docker cp /data/models mulita-worker:/data/models
Or mount a host path in docker-compose.yml.
Produces:
embed/visual.onnx (~350 MB)
"""
import argparse
import logging
import sys
from pathlib import Path
logger = logging.getLogger(__name__)
def export_openclip(models_dir: Path):
"""Export OpenCLIP ViT-B/32 to two ONNX files (visual + textual)."""
def export_openclip_visual(models_dir: Path):
import torch
import open_clip
out_dir = models_dir / "embed"
out_dir.mkdir(parents=True, exist_ok=True)
visual_path = out_dir / "visual.onnx"
textual_path = out_dir / "textual.onnx"
if visual_path.exists() and textual_path.exists():
logger.info("OpenCLIP ONNX files already exist, skipping export")
if visual_path.exists():
logger.info("OpenCLIP visual.onnx already exists, skipping export")
return
logger.info("Loading OpenCLIP ViT-B-32 laion2b_s34b_b79k...")
model, _, preprocess = open_clip.create_model_and_transforms(
model, _, _ = open_clip.create_model_and_transforms(
"ViT-B-32", pretrained="laion2b_s34b_b79k"
)
model.eval()
# Use dynamo=False to get the legacy TorchScript exporter which
# produces IR version 9 (compatible with onnxruntime 1.17.x).
# The new torch.onnx.export default (dynamo=True) emits IR 10.
export_kwargs = dict(opset_version=14, dynamo=False)
# ── Visual encoder ────────────────────────────────────────────────
if not visual_path.exists():
logger.info("Exporting visual encoder → %s", visual_path)
dummy_image = torch.randn(1, 3, 224, 224)
torch.onnx.export(
model.visual,
dummy_image,
str(visual_path),
input_names=["image"],
output_names=["embedding"],
dynamic_axes={"image": {0: "batch"}},
**export_kwargs,
)
size_mb = visual_path.stat().st_size / 1e6
logger.info("Visual encoder exported (%.1f MB)", size_mb)
# ── Textual encoder ───────────────────────────────────────────────
if not textual_path.exists():
logger.info("Exporting textual encoder → %s", textual_path)
tokenizer = open_clip.get_tokenizer("ViT-B-32")
dummy_text = tokenizer(["a photo"]).to(torch.int64)
class TextEncoder(torch.nn.Module):
"""Wrap the CLIP text encoder to avoid argmax in the ONNX graph.
OpenCLIP uses argmax to find the EOT token position, but ORT
ARM64 doesn't support ArgMax(13). We pre-compute the EOT index
from the token sequence and pass it directly."""
def __init__(self, clip_model):
super().__init__()
self.transformer = clip_model.transformer
self.token_embedding = clip_model.token_embedding
self.positional_embedding = clip_model.positional_embedding
self.ln_final = clip_model.ln_final
self.text_projection = clip_model.text_projection
def forward(self, text, eot_indices):
x = self.token_embedding(text)
x = x + self.positional_embedding
x = x.permute(1, 0, 2) # NLD -> LND
x = self.transformer(x)
x = x.permute(1, 0, 2) # LND -> NLD
x = self.ln_final(x)
# Take the feature at the EOT token. The EOT index is
# passed in as a separate input (computed outside ONNX)
# to avoid ArgMax(13) which ORT ARM64 doesn't support.
x = x[torch.arange(x.shape[0]), eot_indices]
x = x @ self.text_projection
return x
text_enc = TextEncoder(model)
text_enc.eval()
# Compute EOT indices from dummy tokens (argmax of token ids)
dummy_eot = dummy_text.argmax(dim=-1)
torch.onnx.export(
text_enc,
(dummy_text, dummy_eot),
str(textual_path),
input_names=["text", "eot_indices"],
output_names=["embedding"],
dynamic_axes={"text": {0: "batch"}, "eot_indices": {0: "batch"}},
**export_kwargs,
)
size_mb = textual_path.stat().st_size / 1e6
logger.info("Textual encoder exported (%.1f MB)", size_mb)
def export_siglip2(models_dir: Path):
"""Export SigLIP2 ViT-B/16 to two ONNX files (visual + textual)."""
import torch
import open_clip
out_dir = models_dir / "embed_siglip2"
out_dir.mkdir(parents=True, exist_ok=True)
visual_path = out_dir / "visual.onnx"
textual_path = out_dir / "textual.onnx"
if visual_path.exists() and textual_path.exists():
logger.info("SigLIP2 ONNX files already exist, skipping export")
return
logger.info("Loading SigLIP2 ViT-B-16-SigLIP-384 webli...")
model, _, preprocess = open_clip.create_model_and_transforms(
"ViT-B-16-SigLIP-384", pretrained="webli"
logger.info("Exporting visual encoder → %s", visual_path)
dummy = torch.randn(1, 3, 224, 224)
torch.onnx.export(
model.visual,
dummy,
str(visual_path),
input_names=["image"],
output_names=["embedding"],
dynamic_axes={"image": {0: "batch"}},
opset_version=14,
dynamo=False,
)
model.eval()
export_kwargs = dict(opset_version=14, dynamo=False)
# ── Visual encoder ────────────────────────────────────────────────
if not visual_path.exists():
logger.info("Exporting SigLIP2 visual encoder → %s", visual_path)
dummy_image = torch.randn(1, 3, 384, 384)
torch.onnx.export(
model.visual,
dummy_image,
str(visual_path),
input_names=["image"],
output_names=["embedding"],
dynamic_axes={"image": {0: "batch"}},
**export_kwargs,
)
size_mb = visual_path.stat().st_size / 1e6
logger.info("SigLIP2 visual encoder exported (%.1f MB)", size_mb)
# ── Textual encoder ───────────────────────────────────────────────
if not textual_path.exists():
logger.info("Exporting SigLIP2 textual encoder → %s", textual_path)
tokenizer = open_clip.get_tokenizer("ViT-B-16-SigLIP-384")
dummy_text = tokenizer(["a photo"]).to(torch.int64)
class SigLIP2TextEncoder(torch.nn.Module):
"""Wrap the SigLIP2 text transformer for ONNX export."""
def __init__(self, clip_model):
super().__init__()
self.text = clip_model.text
def forward(self, text):
return self.text(text)
text_enc = SigLIP2TextEncoder(model)
text_enc.eval()
torch.onnx.export(
text_enc,
dummy_text,
str(textual_path),
input_names=["text"],
output_names=["embedding"],
dynamic_axes={"text": {0: "batch"}},
**export_kwargs,
)
size_mb = textual_path.stat().st_size / 1e6
logger.info("SigLIP2 textual encoder exported (%.1f MB)", size_mb)
def export_yolov8n(models_dir: Path):
"""Export YOLOv8n to ONNX."""
out_dir = models_dir / "detect"
out_dir.mkdir(parents=True, exist_ok=True)
onnx_path = out_dir / "yolov8n.onnx"
if onnx_path.exists():
logger.info("YOLOv8n ONNX already exists, skipping export")
return
logger.info("Exporting YOLOv8n → %s", onnx_path)
from ultralytics import YOLO
model = YOLO("yolov8n.pt")
model.export(format="onnx", imgsz=640, simplify=True)
# ultralytics exports to cwd as yolov8n.onnx — move to target. Use
# shutil.move rather than Path.rename so it works across filesystems
# (the cwd is typically /app inside the container, while the target
# /data/models is a separately-mounted volume — Path.rename raises
# "Invalid cross-device link" in that case).
import shutil
exported = Path("yolov8n.onnx")
if exported.exists():
shutil.move(str(exported), str(onnx_path))
size_mb = onnx_path.stat().st_size / 1e6
logger.info("YOLOv8n exported (%.1f MB)", size_mb)
logger.info("Visual encoder exported (%.1f MB)", visual_path.stat().st_size / 1e6)
def main():
parser = argparse.ArgumentParser(description="Export vision model weights to ONNX")
parser.add_argument(
"--models-dir",
type=Path,
default=Path("/data/models"),
help="Directory to write model files (default: /data/models)",
)
parser = argparse.ArgumentParser()
parser.add_argument("--models-dir", type=Path, default=Path("/data/models"))
args = parser.parse_args()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
models_dir = args.models_dir
models_dir.mkdir(parents=True, exist_ok=True)
logger.info("Exporting models to %s", models_dir)
export_openclip(models_dir)
export_siglip2(models_dir)
export_yolov8n(models_dir)
logger.info("Done. Run bootstrap_models.py next to download YuNet + SFace.")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
args.models_dir.mkdir(parents=True, exist_ok=True)
export_openclip_visual(args.models_dir)
if __name__ == "__main__":

View File

@@ -1,146 +0,0 @@
"""
Face detection (YuNet) + recognition (SFace) using OpenCV DNN.
YuNet is loaded via cv2.FaceDetectorYN which handles the multi-scale
anchor decoding and NMS internally. SFace recognition uses raw ONNX
Runtime for the 128-d embedding.
Both models are from opencv_zoo (Apache 2.0 license).
Expects {models_dir}/face/:
- yunet.onnx (~233 KB)
- sface.onnx (~37 MB, 128-d embeddings)
"""
import logging
from pathlib import Path
import numpy as np
import cv2
import onnxruntime as ort
from app.config import VisionSettings
from app.services.vision.base import FaceProcessor, FaceDetection
logger = logging.getLogger(__name__)
def _align_face(image: np.ndarray, landmarks: np.ndarray) -> np.ndarray:
"""Align and crop a 112x112 face patch using 5-point landmarks."""
left_eye = landmarks[0]
right_eye = landmarks[1]
dx = right_eye[0] - left_eye[0]
dy = right_eye[1] - left_eye[1]
angle = np.degrees(np.arctan2(dy, dx))
eye_center = ((left_eye[0] + right_eye[0]) / 2, (left_eye[1] + right_eye[1]) / 2)
eye_dist = np.sqrt(dx * dx + dy * dy)
M = cv2.getRotationMatrix2D(eye_center, angle, 1.0)
rotated = cv2.warpAffine(image, M, (image.shape[1], image.shape[0]))
# Crop around face center
scale = 64.0 / max(eye_dist, 1e-6)
cx, cy = eye_center
half = 56.0 / scale
x1 = max(0, int(cx - half))
y1 = max(0, int(cy - half * 0.8))
x2 = min(rotated.shape[1], int(cx + half))
y2 = min(rotated.shape[0], int(cy + half * 1.2))
crop = rotated[y1:y2, x1:x2]
if crop.size == 0:
return np.zeros((112, 112, 3), dtype=np.float32)
return cv2.resize(crop, (112, 112)).astype(np.float32)
class YuNetSFaceProcessor(FaceProcessor):
def __init__(self, settings: VisionSettings):
face_dir = Path(settings.models_dir) / "face"
yunet_path = str(face_dir / "yunet.onnx")
sface_path = str(face_dir / "sface.onnx")
# YuNet via OpenCV's FaceDetectorYN — handles anchor decoding + NMS
self._detector = cv2.FaceDetectorYN.create(
yunet_path,
"",
(640, 640),
settings.faces.recognition_threshold,
0.3, # NMS threshold
5000, # top_k
)
logger.info("YuNet face detector loaded via OpenCV")
# SFace via ONNX Runtime
from app.services.vision.providers import create_session
ort.set_default_logger_severity(3)
self._recognizer = create_session(sface_path, configured_providers=settings.execution_providers)
logger.info("SFace recognizer loaded via ONNX Runtime")
self._min_face_size = settings.faces.min_face_size
def process(self, image: np.ndarray) -> list[FaceDetection]:
orig_h, orig_w = image.shape[:2]
# Convert RGB → BGR for OpenCV
bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
# Set input size to actual image dimensions
self._detector.setInputSize((orig_w, orig_h))
# Detect faces
_, faces_raw = self._detector.detect(bgr)
if faces_raw is None or len(faces_raw) == 0:
return []
results = []
for face in faces_raw:
# face: [x, y, w, h, right_eye_x, right_eye_y, left_eye_x, left_eye_y,
# nose_x, nose_y, right_mouth_x, right_mouth_y, left_mouth_x, left_mouth_y, score]
x, y, w, h = int(face[0]), int(face[1]), int(face[2]), int(face[3])
score = float(face[14])
# Filter small faces
face_size = max(w, h)
if face_size < self._min_face_size:
continue
# Normalized bbox
bbox = [
max(0, x / orig_w),
max(0, y / orig_h),
min(1, (x + w) / orig_w),
min(1, (y + h) / orig_h),
]
# Extract 5-point landmarks for alignment
landmarks = np.array([
[face[4], face[5]], # right eye
[face[6], face[7]], # left eye
[face[8], face[9]], # nose
[face[10], face[11]], # right mouth
[face[12], face[13]], # left mouth
], dtype=np.float32)
# Align face for recognition
face_crop = _align_face(image, landmarks)
# SFace expects (1, 3, 112, 112) float32, BGR
face_bgr = cv2.cvtColor(face_crop.astype(np.uint8), cv2.COLOR_RGB2BGR)
face_blob = (face_bgr.astype(np.float32) / 255.0).transpose(2, 0, 1)[np.newaxis]
rec_input = self._recognizer.get_inputs()[0].name
embedding = self._recognizer.run(None, {rec_input: face_blob})[0][0]
embedding = embedding / np.linalg.norm(embedding)
results.append(FaceDetection(
bbox=bbox,
embedding=embedding.astype(np.float32),
quality=score,
))
return results
@property
def embedding_dim(self) -> int:
return 128

View File

@@ -1,73 +0,0 @@
"""
Face detection + recognition using InsightFace (RetinaFace + ArcFace).
Uses the buffalo_l model pack which auto-downloads on first use (~300MB).
Produces 512-d ArcFace embeddings. Non-commercial research license —
fine for homelab self-hosting.
"""
import logging
from pathlib import Path
import numpy as np
from app.config import VisionSettings
from app.services.vision.base import FaceProcessor, FaceDetection
logger = logging.getLogger(__name__)
class InsightFaceProcessor(FaceProcessor):
def __init__(self, settings: VisionSettings):
from insightface.app import FaceAnalysis
model_root = str(Path(settings.models_dir) / "face" / "insightface")
logger.info("Loading InsightFace buffalo_l from %s", model_root)
from app.services.vision.providers import get_providers
providers = get_providers(settings.execution_providers)
self._app = FaceAnalysis(
name="buffalo_l",
root=model_root,
providers=providers,
)
self._app.prepare(ctx_id=-1, det_size=(640, 640))
self._min_det_score = settings.faces.recognition_threshold
def process(self, image: np.ndarray) -> list[FaceDetection]:
orig_h, orig_w = image.shape[:2]
# InsightFace expects BGR
bgr = image[:, :, ::-1].copy()
faces = self._app.get(bgr)
if not faces:
return []
results = []
for face in faces:
if face.det_score < self._min_det_score:
continue
# face.bbox is [x1, y1, x2, y2] in pixel coords
x1, y1, x2, y2 = face.bbox
bbox = [
max(0, float(x1) / orig_w),
max(0, float(y1) / orig_h),
min(1, float(x2) / orig_w),
min(1, float(y2) / orig_h),
]
embedding = face.normed_embedding # already L2-normalized, 512-d
results.append(FaceDetection(
bbox=bbox,
embedding=embedding.astype(np.float32),
quality=float(face.det_score),
))
return results
@property
def embedding_dim(self) -> int:
return 512

View File

@@ -1,45 +0,0 @@
"""
OCR engine using rapidocr-onnxruntime (PP-OCRv4 weights).
No PaddlePaddle dependency — pure ONNX Runtime. Language packs are
downloaded automatically by rapidocr on first use.
"""
import logging
import numpy as np
from app.config import VisionSettings
from app.services.vision.base import OCREngine, OCRResult
logger = logging.getLogger(__name__)
class RapidOCREngine(OCREngine):
def __init__(self, settings: VisionSettings):
from rapidocr_onnxruntime import RapidOCR
self._min_confidence = settings.ocr.min_confidence
self._engine = RapidOCR()
logger.info("RapidOCR engine initialized")
def run(self, image: np.ndarray) -> list[OCRResult]:
result, _ = self._engine(image)
if not result:
return []
out = []
for box, text, score in result:
if score < self._min_confidence:
continue
# box is [[x1,y1],[x2,y2],[x3,y3],[x4,y4]] — take bounding rect
xs = [p[0] for p in box]
ys = [p[1] for p in box]
h, w = image.shape[:2]
bbox = [
min(xs) / w,
min(ys) / h,
max(xs) / w,
max(ys) / h,
]
out.append(OCRResult(text=text, confidence=float(score), bbox=bbox))
return out

View File

@@ -1,46 +0,0 @@
"""
ONNX Runtime backend — default CPU inference for all vision models.
Each create_* method returns a concrete implementation of the
corresponding ABC from base.py. Models are loaded from ONNX files
under settings.vision.models_dir, downloaded on first boot by
bootstrap_models.py.
"""
import logging
from app.config import VisionSettings
from app.services.vision.base import Embedder, OCREngine, ObjectDetector, FaceProcessor, ContentClassifier
logger = logging.getLogger(__name__)
class ONNXBackend:
"""Factory for ONNX Runtime-based vision model instances."""
def __init__(self, vision_settings: VisionSettings):
self._settings = vision_settings
def create_embedder(self) -> Embedder:
model_name = self._settings.embedder.name
if model_name.startswith("siglip2"):
from app.services.vision.embed import SigLIP2Embedder
return SigLIP2Embedder(self._settings)
else:
from app.services.vision.embed import OpenCLIPEmbedder
return OpenCLIPEmbedder(self._settings)
def create_ocr(self) -> OCREngine:
from app.services.vision.ocr import RapidOCREngine
return RapidOCREngine(self._settings)
def create_detector(self) -> ObjectDetector:
from app.services.vision.detect import YOLOv8Detector
return YOLOv8Detector(self._settings)
def create_face_processor(self) -> FaceProcessor:
from app.services.vision.insightface_processor import InsightFaceProcessor
return InsightFaceProcessor(self._settings)
def create_classifier(self) -> ContentClassifier:
from app.services.vision.classify import CLIPContentClassifier
return CLIPContentClassifier(self._settings)

View File

@@ -1,84 +1,29 @@
"""
ModelRegistry — singleton that lazy-loads vision models per worker process.
Usage from Celery tasks:
from app.services.vision.registry import registry
embedder = registry.get_embedder()
vec = embedder.embed_image(img)
Models are created on first access and cached for the worker's lifetime.
The registry reads settings.vision to decide which backend to use and
where model weights live.
ModelRegistry — lazy-loads the single content classifier per worker.
"""
import logging
from functools import lru_cache
from app.config import settings
from app.services.vision.base import Embedder, OCREngine, ObjectDetector, FaceProcessor, ContentClassifier
from app.services.vision.base import ContentClassifier
logger = logging.getLogger(__name__)
class ModelRegistry:
"""Central access point for all vision models."""
def __init__(self):
self._vision = settings.vision
@lru_cache(maxsize=1)
def get_embedder(self) -> Embedder:
logger.info("Loading embedder: %s (backend=%s)", self._vision.embedder.name, self._vision.backend)
return self._load_backend().create_embedder()
@lru_cache(maxsize=1)
def get_ocr(self) -> OCREngine:
logger.info("Loading OCR engine (backend=%s)", self._vision.backend)
return self._load_backend().create_ocr()
@lru_cache(maxsize=1)
def get_detector(self) -> ObjectDetector:
logger.info("Loading object detector (backend=%s)", self._vision.backend)
return self._load_backend().create_detector()
@lru_cache(maxsize=1)
def get_face_processor(self) -> FaceProcessor:
logger.info("Loading face processor (backend=%s)", self._vision.backend)
return self._load_backend().create_face_processor()
@lru_cache(maxsize=1)
def get_classifier(self) -> ContentClassifier:
logger.info("Loading content classifier (backend=%s)", self._vision.backend)
return self._load_backend().create_classifier()
@lru_cache(maxsize=1)
def _load_backend(self):
"""Import and instantiate the configured backend."""
backend_name = self._vision.backend
if backend_name == "onnx":
from app.services.vision.onnx_backend import ONNXBackend
return ONNXBackend(self._vision)
elif backend_name == "rocm":
from app.services.vision.rocm_backend import ROCmBackend
return ROCmBackend(self._vision)
else:
raise ValueError(f"Unknown vision backend: {backend_name}")
from app.services.vision.classify import CLIPContentClassifier
return CLIPContentClassifier(self._vision)
def warmup(self):
"""Pre-load all enabled models. Called from Celery worker_process_init
on the vision queue to avoid cold-start latency on the first task."""
logger.info("Warming up vision models...")
self.get_embedder()
if self._vision.ocr.enabled:
self.get_ocr()
if self._vision.detector.enabled:
self.get_detector()
if self._vision.faces.enabled:
self.get_face_processor()
if self._vision.classifier.enabled:
self.get_classifier()
logger.info("Vision model warmup complete")
logger.info("Warming up vision classifier...")
self.get_classifier()
logger.info("Vision warmup complete")
# Module-level singleton. Import this from tasks.
registry = ModelRegistry()

View File

@@ -1,25 +0,0 @@
"""
ROCm backend — GPU-accelerated inference for Radeon 760M-class hardware.
Stub: raises NotImplementedError on all factory methods. To enable,
set `vision.backend: rocm` in mulita.yml once ROCm support is implemented.
"""
from app.config import VisionSettings
from app.services.vision.base import Embedder, OCREngine, ObjectDetector, FaceProcessor
class ROCmBackend:
def __init__(self, vision_settings: VisionSettings):
self._settings = vision_settings
def create_embedder(self) -> Embedder:
raise NotImplementedError("ROCm backend not yet implemented — use 'onnx'")
def create_ocr(self) -> OCREngine:
raise NotImplementedError("ROCm backend not yet implemented — use 'onnx'")
def create_detector(self) -> ObjectDetector:
raise NotImplementedError("ROCm backend not yet implemented — use 'onnx'")
def create_face_processor(self) -> FaceProcessor:
raise NotImplementedError("ROCm backend not yet implemented — use 'onnx'")

View File

@@ -39,14 +39,9 @@ celery_app.conf.update(
# Explicit routes for every task name. Wildcard patterns don't match
# short names produced by @shared_task(name='...').
task_routes={
# Vision queue — GPU/CPU-bound inference
'embed_photo': {'queue': 'vision'},
'ocr_photo': {'queue': 'vision'},
'detect_objects': {'queue': 'vision'},
'extract_faces': {'queue': 'vision'},
# Vision queue — CPU-bound binary classification
'classify_content': {'queue': 'vision'},
'vision_fanout': {'queue': 'vision'},
'recluster_faces': {'queue': 'vision'},
# High-priority queue — thumbnails & duplicates
'generate_thumbnails': {'queue': 'high'},
'regenerate_all_thumbnails': {'queue': 'high'},

View File

@@ -422,7 +422,7 @@ async def _scan_all_source_roots_async():
still hit Settings → Re-detect duplicates to force a fresh pass.
"""
from app.tasks.thumbs import incremental_regroup_duplicates_task
from app.tasks.vision import backfill_vision, recluster_faces
from app.tasks.vision import backfill_vision
async with AsyncSessionLocal() as session:
result = await session.execute(
@@ -461,14 +461,6 @@ async def _scan_all_source_roots_async():
except Exception as e:
logger.warning(f"Could not queue post-scan vision backfill: {e}")
# 300s gives face extraction time to run before reclustering.
# Fires even if some faces are still in-flight — the task is
# idempotent and the user can re-trigger from Settings.
try:
recluster_faces.apply_async(countdown=300)
except Exception as e:
logger.warning(f"Could not queue post-scan face recluster: {e}")
# Re-extract metadata for photos missing GPS coordinates.
# Runs on every startup so photos scanned before the GPS fix
# eventually get their coordinates populated.

View File

@@ -1,33 +1,21 @@
"""
Celery tasks for the vision pipeline — embedding, OCR, object detection,
face recognition.
Celery tasks for the vision pipeline.
All tasks run on the dedicated `vision` queue with limited concurrency
(memory-bound CPU inference). They read thumbnails generated by
generate_thumbnails, so they MUST run after thumbs complete.
DB access uses sync psycopg2 sessions (not asyncpg) because Celery
forks workers and asyncpg connections can't be shared across forks.
A single binary classifier decides whether a photo is 'photography' or
'other'. Photos classified as 'other' get needs_review=true so the user
can triage screenshots / documents / memes in the UI.
"""
import logging
from pathlib import Path
import numpy as np
from celery import shared_task
from sqlalchemy import create_engine, text as sa_text, select, delete
from sqlalchemy import create_engine, text as sa_text, select, delete, update
from sqlalchemy.orm import Session, sessionmaker
from PIL import Image
from app.models.embeddings import Embedding
from app.config import settings
from app.services.feature_flags import (
is_enabled,
FLAG_VISION_ENABLED,
FLAG_OCR_ENABLED,
FLAG_DETECTOR_ENABLED,
FLAG_FACES_ENABLED,
FLAG_CLASSIFIER_ENABLED,
)
from app.services.feature_flags import is_enabled, FLAG_VISION_ENABLED
logger = logging.getLogger(__name__)
@@ -35,7 +23,6 @@ VISION_READY_KEY = "mulita:vision:ready"
def _vision_worker_ready() -> bool:
"""Check whether the vision worker has finished model bootstrap."""
try:
import redis as _redis
return bool(_redis.from_url(settings.redis_url).exists(VISION_READY_KEY))
@@ -47,7 +34,6 @@ _sync_engine = None
def _get_sync_engine():
"""Return a module-level singleton engine (one per worker process)."""
global _sync_engine
if _sync_engine is None:
sync_url = settings.database_url.replace("+asyncpg", "+psycopg2").replace("+aiosqlite", "")
@@ -56,21 +42,13 @@ def _get_sync_engine():
def _get_sync_session() -> Session:
"""Create a sync DB session backed by the shared engine."""
return sessionmaker(bind=_get_sync_engine())()
def _load_thumb(photo_id: str, size: str = "medium") -> np.ndarray | None:
"""Load a thumbnail as an RGB numpy array.
Thumbnails may live at ``/data/thumbs/{photo_id}/`` (legacy) or
``/data/thumbs/{user_id}/{photo_id}/`` (multi-user). Try both.
"""
thumb_base = Path("/data/thumbs")
# Try legacy flat path first.
thumb_path = thumb_base / photo_id / f"{size}.webp"
if not thumb_path.exists():
# Try user-prefixed paths: /data/thumbs/*/photo_id/size.webp
matches = list(thumb_base.glob(f"*/{photo_id}/{size}.webp"))
if matches:
thumb_path = matches[0]
@@ -79,7 +57,7 @@ def _load_thumb(photo_id: str, size: str = "medium") -> np.ndarray | None:
return None
try:
img = Image.open(thumb_path).convert("RGB")
img.load() # force decode to catch corruption early
img.load()
arr = np.array(img)
img.close()
return arr
@@ -88,207 +66,25 @@ def _load_thumb(photo_id: str, size: str = "medium") -> np.ndarray | None:
return None
@shared_task(name='embed_photo', queue='vision', bind=True, max_retries=3)
def embed_photo(self, photo_id: str):
"""Generate CLIP embedding for a photo and store in pgvector."""
if not is_enabled(FLAG_VISION_ENABLED):
return {'status': 'skipped', 'reason': 'vision disabled'}
image = _load_thumb(photo_id, "medium") # 640px
if image is None:
return {'status': 'error', 'message': 'thumbnail not found'}
try:
from app.services.vision.registry import registry
embedder = registry.get_embedder()
vector = embedder.embed_image(image)
except Exception as exc:
logger.exception("embed_photo failed for %s", photo_id)
raise self.retry(exc=exc, countdown=60)
model_name = settings.vision.embedder.name
session = _get_sync_session()
try:
session.execute(
delete(Embedding).where(
Embedding.photo_id == photo_id,
Embedding.model == model_name,
)
)
emb = Embedding(
photo_id=photo_id,
model=model_name,
vector=vector.tolist(),
)
session.add(emb)
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
logger.info("[%s] Embedded photo %s with %s", self.request.id, photo_id, model_name)
return {'status': 'success', 'photo_id': photo_id}
@shared_task(name='vision_fanout', queue='vision')
def vision_fanout(photo_id: str):
"""Dispatch all enabled vision tasks for a photo."""
"""Dispatch vision work for a photo. Today this is just the binary
classifier; the indirection stays so scanner/upload code keeps one
entrypoint."""
if not is_enabled(FLAG_VISION_ENABLED):
return {'status': 'skipped', 'reason': 'vision disabled'}
embed_photo.delay(photo_id)
if is_enabled(FLAG_OCR_ENABLED):
ocr_photo.delay(photo_id)
if is_enabled(FLAG_DETECTOR_ENABLED):
detect_objects.delay(photo_id)
if is_enabled(FLAG_FACES_ENABLED):
extract_faces.delay(photo_id)
if is_enabled(FLAG_CLASSIFIER_ENABLED):
classify_content.delay(photo_id)
classify_content.delay(photo_id)
return {'status': 'dispatched', 'photo_id': photo_id}
@shared_task(name='ocr_photo', queue='vision', bind=True, max_retries=3)
def ocr_photo(self, photo_id: str):
"""Run OCR on a photo and store text regions."""
if not is_enabled(FLAG_VISION_ENABLED) or not is_enabled(FLAG_OCR_ENABLED):
return {'status': 'skipped', 'reason': 'OCR disabled'}
image = _load_thumb(photo_id, "large") # 1280px for better OCR accuracy
if image is None:
return {'status': 'error', 'message': 'thumbnail not found'}
try:
from app.services.vision.registry import registry
ocr_engine = registry.get_ocr()
results = ocr_engine.run(image)
except Exception as exc:
logger.exception("ocr_photo failed for %s", photo_id)
raise self.retry(exc=exc, countdown=60)
if not results:
logger.info("No OCR text found for photo %s", photo_id)
return {'status': 'success', 'photo_id': photo_id, 'regions': 0}
from app.models.ocr_text import OCRText
session = _get_sync_session()
try:
session.execute(delete(OCRText).where(OCRText.photo_id == photo_id))
for r in results:
session.add(OCRText(
photo_id=photo_id,
text=r.text,
language=r.language,
confidence=r.confidence,
bbox=r.bbox,
))
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
logger.info("[%s] OCR: %d text regions for photo %s", self.request.id, len(results), photo_id)
return {'status': 'success', 'photo_id': photo_id, 'regions': len(results)}
@shared_task(name='detect_objects', queue='vision', bind=True, max_retries=3)
def detect_objects(self, photo_id: str):
"""Detect objects in a photo, create Tag(kind=object) rows, and
link via photo_tags with confidence/bbox/source."""
if not is_enabled(FLAG_VISION_ENABLED) or not is_enabled(FLAG_DETECTOR_ENABLED):
return {'status': 'skipped', 'reason': 'detection disabled'}
image = _load_thumb(photo_id, "medium") # 640px
if image is None:
return {'status': 'error', 'message': 'thumbnail not found'}
try:
from app.services.vision.registry import registry
detector = registry.get_detector()
detections = detector.detect(image)
except Exception as exc:
logger.exception("detect_objects failed for %s", photo_id)
raise self.retry(exc=exc, countdown=60)
if not detections:
logger.info("No objects detected for photo %s", photo_id)
return {'status': 'success', 'photo_id': photo_id, 'objects': 0}
from app.models import Photo
from app.models.tags import Tag, photo_tags
source_name = "vision:yolov8n"
session = _get_sync_session()
try:
# Get the photo's user_id so tags inherit ownership.
photo = session.execute(
select(Photo).where(Photo.id == photo_id)
).scalar_one_or_none()
owner_id = photo.user_id if photo else None
# Wipe previous detection results for this photo from this model
session.execute(
delete(photo_tags).where(
photo_tags.c.photo_id == photo_id,
photo_tags.c.source == source_name,
)
)
# Group detections by label, keep highest confidence per label
best_per_label: dict[str, tuple[float, list]] = {}
for det in detections:
if det.label not in best_per_label or det.confidence > best_per_label[det.label][0]:
best_per_label[det.label] = (det.confidence, det.bbox)
for label, (confidence, bbox) in best_per_label.items():
# Find or create the object tag (scoped to user)
tag = session.execute(
select(Tag).where(Tag.name == label, Tag.kind == 'object', Tag.user_id == owner_id)
).scalar_one_or_none()
if not tag:
tag = Tag(name=label, kind='object', source=source_name, user_id=owner_id)
session.add(tag)
session.flush() # get tag.id
# Insert photo_tags association with ML metadata
session.execute(
photo_tags.insert().values(
photo_id=photo_id,
tag_id=tag.id,
confidence=confidence,
bbox=bbox,
source=source_name,
)
)
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
labels = [d.label for d in detections]
logger.info("[%s] Detected %d objects in photo %s: %s", self.request.id, len(detections), photo_id, labels)
return {'status': 'success', 'photo_id': photo_id, 'objects': len(detections)}
@shared_task(name='classify_content', queue='vision', bind=True, max_retries=3)
def classify_content(self, photo_id: str):
"""Classify image content type (screenshot, document, artwork, etc.)
using CLIP zero-shot classification. Writes Tag(kind=content_type)."""
if not is_enabled(FLAG_VISION_ENABLED) or not is_enabled(FLAG_CLASSIFIER_ENABLED):
return {'status': 'skipped', 'reason': 'classifier disabled'}
"""Run the binary classifier and write:
- a Tag(kind='content_type', name IN ('photography','other'))
- Photo.needs_review = (label == 'other')
"""
if not is_enabled(FLAG_VISION_ENABLED):
return {'status': 'skipped', 'reason': 'vision disabled'}
image = _load_thumb(photo_id, "medium")
if image is None:
@@ -297,30 +93,28 @@ def classify_content(self, photo_id: str):
try:
from app.services.vision.registry import registry
classifier = registry.get_classifier()
results = classifier.classify(image)
result = classifier.classify(image)
except Exception as exc:
logger.exception("classify_content failed for %s", photo_id)
raise self.retry(exc=exc, countdown=60)
if not results:
logger.info("No confident classification for photo %s", photo_id)
return {'status': 'success', 'photo_id': photo_id, 'content_type': None}
from app.models import Photo
from app.models.tags import Tag, photo_tags
source_name = "vision:clip_classifier"
best = results[0]
label = result.label
confidence = result.confidence
session = _get_sync_session()
try:
# Get the photo's user_id so tags inherit ownership.
photo = session.execute(
select(Photo).where(Photo.id == photo_id)
).scalar_one_or_none()
owner_id = photo.user_id if photo else None
if photo is None:
return {'status': 'error', 'message': 'photo not found'}
owner_id = photo.user_id
# Wipe previous classification for this photo
# Drop any previous classification for this photo.
session.execute(
delete(photo_tags).where(
photo_tags.c.photo_id == photo_id,
@@ -328,13 +122,13 @@ def classify_content(self, photo_id: str):
)
)
# Find or create content_type tag (scoped to user)
tag = session.execute(
select(Tag).where(Tag.name == best.label, Tag.kind == 'content_type', Tag.user_id == owner_id)
select(Tag).where(
Tag.name == label, Tag.kind == 'content_type', Tag.user_id == owner_id
)
).scalar_one_or_none()
if not tag:
tag = Tag(name=best.label, kind='content_type', source=source_name, user_id=owner_id)
tag = Tag(name=label, kind='content_type', source=source_name, user_id=owner_id)
session.add(tag)
session.flush()
@@ -342,232 +136,16 @@ def classify_content(self, photo_id: str):
photo_tags.insert().values(
photo_id=photo_id,
tag_id=tag.id,
confidence=best.confidence,
confidence=confidence,
source=source_name,
)
)
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
logger.info("[%s] Classified photo %s as '%s' (%.2f)", self.request.id, photo_id, best.label, best.confidence)
return {'status': 'success', 'photo_id': photo_id, 'content_type': best.label}
def _load_original(photo_id: str) -> np.ndarray | None:
"""Load the original photo file as an RGB numpy array, resized to
max 1280px on the longest edge for face detection."""
from sqlalchemy import create_engine, select as sa_select, text as sa_text
from app.models import Photo
session = _get_sync_session()
try:
photo = session.execute(
sa_select(Photo).where(Photo.id == photo_id)
).scalar_one_or_none()
if not photo or not photo.filepath:
return None
filepath = photo.filepath
finally:
session.close()
if not Path(filepath).exists():
logger.warning("Original file not found: %s", filepath)
return None
try:
img = Image.open(filepath).convert("RGB")
# Cap at 4000px on longest edge to avoid OOM, but keep as large
# as possible for face detection accuracy
max_dim = 4000
w, h = img.size
if max(w, h) > max_dim:
scale = max_dim / max(w, h)
resized = img.resize((int(w * scale), int(h * scale)), Image.BICUBIC)
img.close()
img = resized
arr = np.array(img)
img.close()
return arr
except Exception as e:
logger.warning("Failed to load original %s: %s", filepath, e)
return None
@shared_task(name='extract_faces', queue='vision', bind=True, max_retries=3)
def extract_faces(self, photo_id: str):
"""Detect faces and store recognition embeddings using InsightFace
(RetinaFace + ArcFace). No YOLO workaround needed — RetinaFace has
strong human-vs-non-human precision on its own."""
if not is_enabled(FLAG_VISION_ENABLED) or not is_enabled(FLAG_FACES_ENABLED):
return {'status': 'skipped', 'reason': 'faces disabled'}
image = _load_original(photo_id)
if image is None:
image = _load_thumb(photo_id, "large")
if image is None:
return {'status': 'error', 'message': 'no image available'}
try:
from app.services.vision.registry import registry
face_proc = registry.get_face_processor()
faces = face_proc.process(image)
except Exception as exc:
logger.exception("extract_faces failed for %s", photo_id)
raise self.retry(exc=exc, countdown=60)
if not faces:
logger.info("No faces detected for photo %s", photo_id)
return _save_faces(photo_id, faces)
def _save_faces(photo_id: str, faces) -> dict:
from app.models.face_embedding import FaceEmbedding
session = _get_sync_session()
try:
session.execute(delete(FaceEmbedding).where(FaceEmbedding.photo_id == photo_id))
for face in faces:
session.add(FaceEmbedding(
photo_id=photo_id,
bbox=face.bbox,
vector=face.embedding.tolist(),
quality=face.quality,
cluster_id=None,
))
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
if faces:
logger.info("Extracted %d verified face(s) from photo %s", len(faces), photo_id)
_schedule_recluster_debounced()
return {'status': 'success', 'photo_id': photo_id, 'faces': len(faces)}
RECLUSTER_DEBOUNCE_KEY = "mule:recluster_faces:pending"
RECLUSTER_DELAY = 120 # seconds after last face extraction
def _schedule_recluster_debounced():
"""Schedule a recluster_faces run, debounced so rapid-fire face
extractions don't spawn hundreds of redundant cluster jobs."""
try:
import redis as _redis
r = _redis.from_url(settings.redis_url)
already_pending = r.set(RECLUSTER_DEBOUNCE_KEY, "1",
ex=RECLUSTER_DELAY, nx=True)
if already_pending:
recluster_faces.apply_async(countdown=RECLUSTER_DELAY)
logger.info("Scheduled debounced recluster_faces in %ds", RECLUSTER_DELAY)
except Exception as e:
logger.debug("recluster debounce check failed: %s", e)
@shared_task(name='recluster_faces', queue='vision', bind=True, max_retries=10)
def recluster_faces(self):
"""Run DBSCAN clustering over all face embeddings and assign/create
Tag(kind=face_cluster) entries."""
# Clear debounce key so new face extractions can schedule another round.
try:
import redis as _redis
_redis.from_url(settings.redis_url).delete(RECLUSTER_DEBOUNCE_KEY)
except Exception:
pass
if not _vision_worker_ready():
logger.info("Vision worker not ready yet — retrying in 30s")
raise self.retry(countdown=30)
if not is_enabled(FLAG_VISION_ENABLED) or not is_enabled(FLAG_FACES_ENABLED):
return {'status': 'skipped', 'reason': 'faces disabled'}
from app.models import Photo
from app.models.face_embedding import FaceEmbedding
from app.models.tags import Tag, photo_tags
from app.services.vision.clustering import cluster_faces
source_name = "vision:sface"
session = _get_sync_session()
try:
face_rows = session.execute(
select(FaceEmbedding).order_by(FaceEmbedding.created_at)
).scalars().all()
if len(face_rows) < 2:
logger.info("Not enough faces for clustering (%d)", len(face_rows))
return {'status': 'success', 'clusters': 0}
embeddings = np.array([f.vector for f in face_rows], dtype=np.float32)
labels = cluster_faces(embeddings, eps=settings.vision.faces.cluster_eps)
# Clean up old face_cluster tags and their photo_tags
old_cluster_tags = session.execute(
select(Tag).where(Tag.kind == 'face_cluster', Tag.source == source_name)
).scalars().all()
for old_tag in old_cluster_tags:
session.execute(
delete(photo_tags).where(
photo_tags.c.tag_id == old_tag.id,
photo_tags.c.source == source_name,
)
)
session.delete(old_tag)
session.flush()
# Build new clusters
cluster_tag_map: dict[int, str] = {}
# Track which photos belong to which cluster
cluster_photos: dict[int, set[str]] = {}
for i, label in enumerate(labels):
if label == -1:
face_rows[i].cluster_id = None
continue
if label not in cluster_photos:
cluster_photos[label] = set()
cluster_photos[label].add(face_rows[i].photo_id)
if label not in cluster_tag_map:
cluster_name = f"Person {label + 1}"
# Inherit user_id from the representative photo.
rep_photo = session.execute(
select(Photo.user_id).where(Photo.id == face_rows[i].photo_id)
).scalar_one_or_none()
tag = Tag(
name=cluster_name,
kind='face_cluster',
source=source_name,
representative_photo_id=face_rows[i].photo_id,
user_id=rep_photo,
)
session.add(tag)
session.flush()
cluster_tag_map[label] = tag.id
face_rows[i].cluster_id = cluster_tag_map[label]
# Write photo_tags associations so the tag count and tag_ids
# filter work for face clusters
for label, photo_ids in cluster_photos.items():
tag_id = cluster_tag_map[label]
for pid in photo_ids:
session.execute(
photo_tags.insert().values(
photo_id=pid,
tag_id=tag_id,
source=source_name,
)
)
session.execute(
update(Photo)
.where(Photo.id == photo_id)
.values(needs_review=(label == 'other'))
)
session.commit()
except Exception:
@@ -576,103 +154,41 @@ def recluster_faces(self):
finally:
session.close()
n_clusters = len(cluster_tag_map)
logger.info("Face clustering: %d clusters from %d faces", n_clusters, len(face_rows))
return {'status': 'success', 'clusters': n_clusters, 'faces': len(face_rows)}
logger.info("[%s] Classified %s as %s (%.2f)", self.request.id, photo_id, label, confidence)
return {'status': 'success', 'photo_id': photo_id, 'label': label}
@shared_task(name='backfill_vision', bind=True, max_retries=10)
def backfill_vision(self, task: str | None = None, limit: int | None = None):
"""Queue vision tasks for photos that haven't been processed yet.
Uses a sync DB connection to avoid asyncpg conflicts in Celery."""
def backfill_vision(self, limit: int | None = None, **_ignored):
"""Queue classify_content for photos without a content_type tag."""
if not _vision_worker_ready():
logger.info("Vision worker not ready yet — retrying in 30s")
raise self.retry(countdown=30)
model_name = settings.vision.embedder.name
ordering = "ORDER BY p.taken_at DESC NULLS LAST, p.added_at DESC NULLS LAST"
limit_clause = " LIMIT :lim" if limit else ""
params: dict = {"model": model_name}
params: dict = {}
if limit:
params["lim"] = int(limit)
session = _get_sync_session()
try:
# Each query finds photos missing a specific pipeline output so
# enabling a new processor after import still back-fills.
embed_ids = []
if task in ('embed', None):
sql = f"""
SELECT p.id FROM photos p
LEFT JOIN embeddings e ON e.photo_id = p.id AND e.model = :model
WHERE e.photo_id IS NULL AND p.processing_status = 'completed'
{ordering}{limit_clause}
"""
embed_ids = [r[0] for r in session.execute(sa_text(sql), params).fetchall()]
ocr_ids = []
if task in ('ocr', None) and is_enabled(FLAG_OCR_ENABLED):
sql = f"""
SELECT p.id FROM photos p
LEFT JOIN ocr_text o ON o.photo_id = p.id
WHERE o.photo_id IS NULL AND p.processing_status = 'completed'
{ordering}{limit_clause}
"""
ocr_ids = [r[0] for r in session.execute(sa_text(sql), params).fetchall()]
detect_ids = []
if task in ('detect', None) and is_enabled(FLAG_DETECTOR_ENABLED):
sql = f"""
SELECT p.id FROM photos p
WHERE p.processing_status = 'completed'
AND NOT EXISTS (
SELECT 1 FROM photo_tags pt WHERE pt.photo_id = p.id
AND pt.source = 'vision:yolov8n'
)
{ordering}{limit_clause}
"""
detect_ids = [r[0] for r in session.execute(sa_text(sql), params).fetchall()]
face_ids = []
if task in ('faces', None) and is_enabled(FLAG_FACES_ENABLED):
sql = f"""
SELECT p.id FROM photos p
LEFT JOIN face_embeddings fe ON fe.photo_id = p.id
WHERE fe.photo_id IS NULL AND p.processing_status = 'completed'
{ordering}{limit_clause}
"""
face_ids = [r[0] for r in session.execute(sa_text(sql), params).fetchall()]
classify_ids = []
if task in ('classify', None) and is_enabled(FLAG_CLASSIFIER_ENABLED):
sql = f"""
SELECT p.id FROM photos p
WHERE p.processing_status = 'completed'
AND NOT EXISTS (
SELECT 1 FROM photo_tags pt WHERE pt.photo_id = p.id
AND pt.source = 'vision:clip_classifier'
)
{ordering}{limit_clause}
"""
classify_ids = [r[0] for r in session.execute(sa_text(sql), params).fetchall()]
except Exception:
session.rollback()
raise
sql = f"""
SELECT p.id FROM photos p
WHERE p.processing_status = 'completed'
AND NOT EXISTS (
SELECT 1 FROM photo_tags pt
WHERE pt.photo_id = p.id
AND pt.source = 'vision:clip_classifier'
)
{ordering}{limit_clause}
"""
ids = [r[0] for r in session.execute(sa_text(sql), params).fetchall()]
finally:
session.close()
# Dispatch — deduplicate across query results.
all_ids = set(embed_ids) | set(ocr_ids) | set(detect_ids) | set(face_ids) | set(classify_ids)
for pid in embed_ids:
embed_photo.delay(pid)
for pid in ocr_ids:
ocr_photo.delay(pid)
for pid in detect_ids:
detect_objects.delay(pid)
for pid in face_ids:
extract_faces.delay(pid)
for pid in classify_ids:
for pid in ids:
classify_content.delay(pid)
logger.info("Backfill queued %d photos for vision processing", len(all_ids))
return {'status': 'queued', 'count': len(all_ids)}
logger.info("Backfill queued %d photos for classification", len(ids))
return {'status': 'queued', 'count': len(ids)}

View File

@@ -8,7 +8,6 @@ sqlalchemy[asyncio]==2.0.25
aiosqlite==0.19.0 # SQLite escape hatch (docker-compose.sqlite.yml override)
asyncpg==0.29.0 # async Postgres driver (default)
psycopg2-binary==2.9.9 # sync Postgres driver, used by Alembic CLI
pgvector==0.2.5 # pgvector SQLAlchemy types
alembic==1.13.1
# Redis and Celery
@@ -42,11 +41,6 @@ watchfiles==0.21.0
# Vision pipeline (ONNX Runtime CPU inference)
onnxruntime==1.18.1
open-clip-torch==2.24.0 # tokenizer + export helper; inference via ONNX
transformers>=4.37.0 # HuggingFace tokenizer for SigLIP models
ultralytics==8.4.37 # YOLOv8n export helper; inference via ONNX
rapidocr-onnxruntime==1.3.22
scikit-learn==1.4.0 # DBSCAN for face clustering
insightface>=0.7.3 # RetinaFace + ArcFace face detection/recognition
numpy>=1.26.0,<2.0
# Utilities

20
frontend/components.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": false,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"hooks": "@/hooks",
"lib": "@/lib"
}
}

View File

@@ -11,34 +11,44 @@
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.1.5",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-query": "^5.17.0",
"@tanstack/react-virtual": "^3.0.1",
"axios": "^1.6.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.0",
"cmdk": "^1.1.1",
"date-fns": "^3.2.0",
"framer-motion": "^10.18.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.303.0",
"react": "^18.2.0",
"react-day-picker": "^8.10.1",
"react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.4.3",
"react-intersection-observer": "^9.5.3",
"react-leaflet": "^4.2.1",
"react-leaflet-cluster": "^2.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^2.2.0",
"tailwindcss-animate": "^1.0.7",
"zustand": "^4.4.7"
},
"devDependencies": {
@@ -63,7 +73,6 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -973,7 +982,6 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -995,7 +1003,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -1005,14 +1012,12 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -1023,7 +1028,6 @@
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
@@ -1037,7 +1041,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -1047,7 +1050,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
@@ -1128,6 +1130,24 @@
}
}
},
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
@@ -1237,6 +1257,24 @@
}
}
},
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
@@ -1331,6 +1369,24 @@
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
@@ -1506,24 +1562,6 @@
}
}
},
"node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-slot": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
@@ -1564,6 +1602,24 @@
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
@@ -1601,6 +1657,24 @@
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
@@ -1704,6 +1778,56 @@
}
}
},
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-radio-group": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",
"integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
@@ -1809,6 +1933,24 @@
}
}
},
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz",
@@ -1855,24 +1997,6 @@
}
}
},
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
@@ -1907,9 +2031,9 @@
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
@@ -2017,6 +2141,60 @@
}
}
},
"node_modules/@radix-ui/react-toggle": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
"integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle-group": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz",
"integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-toggle": "1.1.10",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
@@ -2051,6 +2229,24 @@
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
@@ -3073,14 +3269,12 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
"dev": true,
"license": "MIT"
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
@@ -3094,7 +3288,6 @@
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
@@ -3204,7 +3397,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -3227,7 +3419,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@@ -3297,7 +3488,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -3345,7 +3535,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
@@ -3370,7 +3559,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -3379,6 +3567,18 @@
"node": ">= 6"
}
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
"license": "Apache-2.0",
"dependencies": {
"clsx": "^2.1.1"
},
"funding": {
"url": "https://polar.sh/cva"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -3388,6 +3588,22 @@
"node": ">=6"
}
},
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3424,7 +3640,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -3463,7 +3678,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true,
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
@@ -3533,7 +3747,6 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dir-glob": {
@@ -3553,7 +3766,6 @@
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"dev": true,
"license": "MIT"
},
"node_modules/doctrine": {
@@ -3906,7 +4118,6 @@
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
@@ -3923,7 +4134,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -3950,7 +4160,6 @@
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
@@ -3973,7 +4182,6 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -4106,7 +4314,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -4208,7 +4415,6 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
@@ -4406,7 +4612,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
@@ -4419,7 +4624,6 @@
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
@@ -4435,7 +4639,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -4445,7 +4648,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -4458,7 +4660,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@@ -4485,7 +4686,6 @@
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@@ -4600,7 +4800,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
@@ -4613,7 +4812,6 @@
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true,
"license": "MIT"
},
"node_modules/locate-path": {
@@ -4683,7 +4881,6 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -4693,7 +4890,6 @@
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
@@ -4751,7 +4947,6 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0",
@@ -4763,7 +4958,6 @@
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
"type": "github",
@@ -4796,7 +4990,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -4806,7 +4999,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -4816,7 +5008,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -4929,7 +5120,6 @@
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true,
"license": "MIT"
},
"node_modules/path-type": {
@@ -4946,14 +5136,12 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -4966,7 +5154,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -4976,7 +5163,6 @@
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -4986,7 +5172,6 @@
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -5015,7 +5200,6 @@
"version": "15.1.0",
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.0.0",
@@ -5033,7 +5217,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
"integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -5059,7 +5242,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -5102,7 +5284,6 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -5128,7 +5309,6 @@
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
@@ -5142,7 +5322,6 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true,
"license": "MIT"
},
"node_modules/prelude-ls": {
@@ -5178,7 +5357,6 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
@@ -5207,6 +5385,20 @@
"node": ">=0.10.0"
}
},
"node_modules/react-day-picker": {
"version": "8.10.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
"integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==",
"license": "MIT",
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"date-fns": "^2.28.0 || ^3.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@@ -5357,7 +5549,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pify": "^2.3.0"
@@ -5367,7 +5558,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
@@ -5380,7 +5570,6 @@
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.1",
@@ -5411,7 +5600,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
@@ -5484,7 +5672,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -5559,11 +5746,20 @@
"node": ">=8"
}
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -5599,7 +5795,6 @@
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
@@ -5635,7 +5830,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -5658,7 +5852,6 @@
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
@@ -5692,6 +5885,15 @@
"node": ">=14.0.0"
}
},
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
"integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
"license": "MIT",
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders"
}
},
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -5703,7 +5905,6 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0"
@@ -5713,7 +5914,6 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"thenify": ">= 3.1.0 < 4"
@@ -5726,7 +5926,6 @@
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@@ -5743,7 +5942,6 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@@ -5761,7 +5959,6 @@
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -5774,7 +5971,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
@@ -5800,7 +5996,6 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/tslib": {
@@ -5946,7 +6141,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": {

View File

@@ -13,34 +13,44 @@
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.1.5",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-query": "^5.17.0",
"@tanstack/react-virtual": "^3.0.1",
"axios": "^1.6.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.0",
"cmdk": "^1.1.1",
"date-fns": "^3.2.0",
"framer-motion": "^10.18.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.303.0",
"react": "^18.2.0",
"react-day-picker": "^8.10.1",
"react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.4.3",
"react-intersection-observer": "^9.5.3",
"react-leaflet": "^4.2.1",
"react-leaflet-cluster": "^2.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^2.2.0",
"tailwindcss-animate": "^1.0.7",
"zustand": "^4.4.7"
},
"devDependencies": {

View File

@@ -1,9 +1,8 @@
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Timeline } from './components/timeline/Timeline'
import { DuplicatesView } from './components/duplicates/DuplicatesView'
import { MapView } from './components/map/MapView'
import { MemoriesView } from './components/memories/MemoriesView'
import { PeopleView } from './components/people/PeopleView'
import { TagsView } from './components/tags/TagsView'
import { ColorsView } from './components/colors/ColorsView'
import { RatedView } from './components/rated/RatedView'
@@ -25,13 +24,30 @@ import { usePhotosQuery } from './hooks/usePhotosQuery'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import { LoginPage } from './components/auth/LoginPage'
import { SetupPage } from './components/auth/SetupPage'
import { TooltipProvider } from '@/components/ui/tooltip'
function MainApp() {
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
const [rightSidebarOpen, setRightSidebarOpen] = useState(true)
const viewMode = usePhotoStore((state) => state.viewMode)
const activePhotoId = usePhotoStore((state) => state.activePhotoId)
const currentSection = useFilterStore((s) => s.currentSection)
// Close the metadata panel when the user switches between sections so
// it doesn't carry over a now-irrelevant selection. It re-opens once a
// photo gains focus in the new section (effect below).
const prevSectionRef = useRef(currentSection)
useEffect(() => {
if (prevSectionRef.current !== currentSection) {
prevSectionRef.current = currentSection
setRightSidebarOpen(false)
}
}, [currentSection])
useEffect(() => {
setRightSidebarOpen(!!activePhotoId)
}, [activePhotoId])
// Bidirectional sync of filter store with URL query params.
useFilterUrlSync()
@@ -56,13 +72,9 @@ function MainApp() {
const showRightSidebar = rightSidebarOpen && !isSettings
return (
<TooltipProvider delayDuration={300}>
<div className="flex flex-col h-screen bg-bg text-text">
<TopBar
leftSidebarOpen={leftSidebarOpen}
rightSidebarOpen={showRightSidebar}
onExpandLeft={() => setLeftSidebarOpen(true)}
onExpandRight={() => setRightSidebarOpen(true)}
/>
<TopBar />
<div className="flex flex-1 overflow-hidden">
{/* Left Sidebar */}
@@ -71,9 +83,7 @@ function MainApp() {
leftSidebarOpen ? 'w-60' : 'w-0'
} overflow-hidden border-r border-border bg-surface`}
>
<LeftSidebar
onCollapse={() => setLeftSidebarOpen(false)}
/>
<LeftSidebar />
</div>
{/* Main column — filter bar, discard bar, timeline. Lives to the
@@ -81,7 +91,14 @@ function MainApp() {
* across the sidebar. relative so the KeyboardHints overlay
* centers against this column, not the viewport. */}
<div className="relative flex min-w-0 flex-1 flex-col">
{!isSettings && <FilterBar />}
{!isSettings && (
<FilterBar
leftSidebarOpen={leftSidebarOpen}
rightSidebarOpen={showRightSidebar}
onToggleLeftSidebar={() => setLeftSidebarOpen(!leftSidebarOpen)}
onToggleRightSidebar={() => setRightSidebarOpen(!rightSidebarOpen)}
/>
)}
{!isSettings && <DiscardActionBar />}
<div className="flex-1 overflow-auto">
{currentSection === 'settings' ? (
@@ -92,8 +109,6 @@ function MainApp() {
<MemoriesView />
) : currentSection === 'duplicates' ? (
<DuplicatesView />
) : currentSection === 'people' ? (
<PeopleView />
) : currentSection === 'tags' ? (
<TagsView />
) : currentSection === 'colors' ? (
@@ -104,7 +119,7 @@ function MainApp() {
<Timeline />
)}
</div>
{!isSettings && <KeyboardHints />}
{!isSettings && viewMode !== 'preview' && <KeyboardHints />}
</div>
{/* Right Sidebar */}
@@ -113,7 +128,7 @@ function MainApp() {
showRightSidebar ? 'w-72' : 'w-0'
} overflow-hidden border-l border-border bg-surface`}
>
<RightSidebar onCollapse={() => setRightSidebarOpen(false)} />
<RightSidebar />
</div>
</div>
@@ -127,6 +142,7 @@ function MainApp() {
{viewMode === 'preview' && <PreviewView />}
</div>
</TooltipProvider>
)
}

View File

@@ -1,62 +1,143 @@
import { useEffect, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { ChevronDown, ChevronUp } from 'lucide-react'
import clsx from 'clsx'
import { usePhotoStore } from '../store/photoStore'
import { useFilterStore } from '../store/filterStore'
const STORAGE_KEY = 'keyboard-hints-collapsed'
interface Hint {
key: string
action: string
}
/** Build the hint list for the current context. Returns an empty array
* when no shortcuts apply, which lets the caller hide the panel
* entirely instead of rendering an empty pill. */
function getHints(opts: {
selectedCount: number
currentSection: string
viewMode: string
}): Hint[] {
const { selectedCount, currentSection, viewMode } = opts
// Preview mode: culling shortcuts apply to the photo on screen, plus
// arrow nav between photos and Esc to close.
if (viewMode === 'preview') {
const preview: Hint[] = [
{ key: '←→', action: 'Navigate' },
{ key: '1-5', action: 'Rate' },
{ key: 'S', action: 'Select → heap' },
]
if (currentSection === 'discarded') {
preview.push({ key: 'U', action: 'Restore' })
} else {
preview.push({ key: 'X', action: 'Discard' })
}
preview.push(
{ key: 'I', action: 'Info panel' },
{ key: 'Space', action: 'Close' },
{ key: 'Esc', action: 'Close' }
)
return preview
}
if (selectedCount > 0) {
const base: Hint[] = [
{ key: '1-5', action: 'Rate' },
{ key: 'S', action: 'Select → heap' },
]
if (currentSection === 'discarded') {
base.push({ key: 'U', action: 'Restore' })
} else {
base.push({ key: 'X', action: 'Discard' })
}
base.push(
{ key: 'Space', action: 'Preview' },
{ key: 'I', action: 'Info panel' },
{ key: 'Esc', action: 'Deselect' }
)
return base
}
return [
{ key: '↑↓←→', action: 'Navigate' },
{ key: 'Space', action: 'Preview' },
{ key: 'Tab', action: 'Library panel' },
{ key: 'I', action: 'Info panel' },
{ key: '/', action: 'Search' },
]
}
export function KeyboardHints() {
const selectedCount = usePhotoStore((state) => state.selectedPhotos.length)
const viewMode = usePhotoStore((state) => state.viewMode)
const selectedCount = usePhotoStore((s) => s.selectedPhotos.length)
const viewMode = usePhotoStore((s) => s.viewMode)
const currentSection = useFilterStore((s) => s.currentSection)
// In preview mode the viewer has its own context, so the grid hints
// would just be confusing. Hide them.
if (viewMode === 'preview') return null
const [collapsed, setCollapsed] = useState(
() => typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1'
)
useEffect(() => {
localStorage.setItem(STORAGE_KEY, collapsed ? '1' : '0')
}, [collapsed])
const hints = selectedCount > 0
? [
{ key: '1-5', action: 'Rate' },
{ key: 'P', action: 'Pick → heap' },
{ key: 'X', action: 'Discard' },
{ key: 'Space', action: 'Preview' },
{ key: 'I', action: 'Info panel' },
{ key: 'Esc', action: 'Deselect' },
]
: [
{ key: '↑↓←→', action: 'Navigate' },
{ key: 'Space', action: 'Preview' },
{ key: 'Tab', action: 'Library panel' },
{ key: 'I', action: 'Info panel' },
{ key: '/', action: 'Search' },
]
// `H` toggles the panel. `?` (shift+/) collides with the global `/`
// search shortcut, so we use a plain letter instead.
useHotkeys('h', () => setCollapsed((c) => !c), { preventDefault: true })
const hints = getHints({ selectedCount, currentSection, viewMode })
// Nothing relevant to show — hide entirely.
if (hints.length === 0) return null
return (
// Absolute (not fixed) so the parent's flex/position context can
// center it relative to the timeline area, not the viewport. Mount
// inside the main column in App.tsx so it isn't offset by the
// sidebar widths.
<div className="pointer-events-none absolute bottom-4 left-1/2 z-30 -translate-x-1/2">
{/* Near-opaque dark pill so the hints stay legible against busy
* thumbnails. The previous bg-surface/40 + 5% white ring left
* text washed out when a bright photo sat directly behind it. */}
<div className="pointer-events-auto flex items-center gap-3 whitespace-nowrap rounded-full border border-white/15 bg-black/80 px-4 py-1.5 shadow-xl ring-1 ring-black/40 backdrop-blur-md">
{hints.map((hint, i) => (
<div key={i} className="flex items-center gap-1.5">
<kbd className="rounded bg-white/15 px-1.5 py-0.5 text-[11px] font-medium text-white shadow-sm">
{hint.key}
</kbd>
<span className="whitespace-nowrap text-xs text-white/85">
{hint.action}
</span>
{i < hints.length - 1 && (
<div className="pointer-events-none absolute bottom-0 left-1/2 z-30 -translate-x-1/2 pb-4">
{collapsed ? (
// Collapsed handle: a small pill peeking from the bottom so the
// user can re-open the panel without remembering the shortcut.
<button
type="button"
onClick={() => setCollapsed(false)}
className="pointer-events-auto flex items-center gap-1.5 rounded-full border border-white/15 bg-black/80 px-3 py-1 text-[11px] text-white/80 shadow-xl backdrop-blur-md transition-colors hover:bg-black/90 hover:text-white"
title="Show shortcuts (H)"
>
<ChevronUp className="h-3 w-3" />
Shortcuts
<kbd className="rounded bg-white/15 px-1 py-0.5 text-[10px] font-medium text-white">
H
</kbd>
</button>
) : (
<div
className={clsx(
'pointer-events-auto flex items-center gap-3 whitespace-nowrap rounded-full border border-white/15 bg-black/80 px-4 py-1.5 shadow-xl ring-1 ring-black/40 backdrop-blur-md'
)}
>
{hints.map((hint, i) => (
<div key={i} className="flex items-center gap-1.5">
<kbd className="rounded bg-white/15 px-1.5 py-0.5 text-[11px] font-medium text-white shadow-sm">
{hint.key}
</kbd>
<span className="whitespace-nowrap text-xs text-white/85">
{hint.action}
</span>
<span className="ml-1 text-white/30"></span>
)}
</div>
))}
{selectedCount > 0 && (
<>
<span className="text-white/30"></span>
<span className="whitespace-nowrap text-xs font-semibold text-primary">
{selectedCount} selected
</span>
</>
)}
</div>
</div>
))}
<button
type="button"
onClick={() => setCollapsed(true)}
className="-mr-1 flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[11px] text-white/60 transition-colors hover:bg-white/10 hover:text-white"
title="Hide shortcuts (H)"
>
<kbd className="rounded bg-white/15 px-1 py-0.5 text-[10px] font-medium text-white">
H
</kbd>
<ChevronDown className="h-3 w-3" />
</button>
</div>
)}
</div>
)
}

View File

@@ -1,8 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { FolderOpen, Loader2, Check, AlertCircle, X, Brain, Sparkles } from 'lucide-react'
import { useEffect, useRef } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { library, WorkerStatus } from '../services/api'
import clsx from 'clsx'
interface ScanStatus {
is_scanning: boolean
@@ -14,9 +12,14 @@ interface ScanStatus {
type Phase = 'idle' | 'scanning' | 'processing' | 'done'
/**
* Headless background-activity orchestrator. Polls scan + worker status
* and invalidates affected query caches when a scan/processing pass
* completes. The visible status indicator now lives inline in the
* LeftSidebar (small spinner next to the FOLDERS header / specific
* folder rows) — see useScanActivity.
*/
export function ScanProgress() {
const [isVisible, setIsVisible] = useState(false)
const [isMinimized, setIsMinimized] = useState(false)
const queryClient = useQueryClient()
const wasScanningRef = useRef(false)
const wasProcessingRef = useRef(false)
@@ -43,7 +46,6 @@ export function ScanProgress() {
enabled: true,
})
const visionActive = visionQueued(workerStatus)
const totalActive = totalQueued(workerStatus)
const phase: Phase = isScanning
@@ -54,18 +56,11 @@ export function ScanProgress() {
useEffect(() => {
if (phase === 'scanning') {
setIsVisible(true)
setIsMinimized(false)
wasScanningRef.current = true
wasProcessingRef.current = false
} else if (phase === 'processing') {
// Show widget when processing starts (even without a prior scan,
// e.g. backfill triggered from Settings).
if (!isVisible) setIsVisible(true)
wasProcessingRef.current = true
if (wasScanningRef.current) {
// Scan just finished — invalidate data caches.
wasScanningRef.current = false
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: ['folders'] })
@@ -76,7 +71,6 @@ export function ScanProgress() {
}
} else if (phase === 'idle') {
if (wasScanningRef.current) {
// Scan finished with no queued processing (small import).
wasScanningRef.current = false
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: ['folders'] })
@@ -86,172 +80,15 @@ export function ScanProgress() {
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
}
if (wasProcessingRef.current) {
// Processing just drained — refresh tags (new clusters/objects).
wasProcessingRef.current = false
queryClient.invalidateQueries({ queryKey: ['tags'] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
}
if (isVisible) {
setTimeout(() => setIsVisible(false), 3000)
}
}
}, [phase, isVisible, queryClient])
}, [phase, queryClient])
if (!isVisible) return null
// Scan progress percentage
const scanProgress = scanStatus && scanStatus.total_files > 0
? (scanStatus.processed_files / scanStatus.total_files) * 100
: 0
const isComplete = phase === 'idle' && (scanStatus?.processed_files ?? 0) > 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">
{phase === 'scanning' ? (
<Loader2 className="h-4 w-4 animate-spin text-primary" />
) : phase === 'processing' ? (
<Sparkles className="h-4 w-4 animate-pulse text-amber-400" />
) : 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">
{phase === 'scanning'
? 'Scanning Folders'
: phase === 'processing'
? 'Processing Photos'
: isComplete
? 'Complete'
: '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">
{/* Scan phase */}
{phase === 'scanning' && scanStatus && (
<>
{scanStatus.current_folder && (
<div className="mb-2 text-xs text-text-muted">
<span className="font-mono">{scanStatus.current_folder}</span>
</div>
)}
<div className="mb-2">
<div className="h-1.5 overflow-hidden rounded-full bg-surface-offset">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${scanProgress}%` }}
/>
</div>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-text-muted">
{scanStatus.processed_files} / {scanStatus.total_files || '?'} files
</span>
<span className="font-medium text-primary">
{Math.round(scanProgress)}%
</span>
</div>
</>
)}
{/* Processing phase */}
{phase === 'processing' && workerStatus && (
<>
<div className="mb-2 flex items-center gap-2 text-xs text-text-muted">
<Brain className="h-3.5 w-3.5 text-amber-400" />
<span>Analyzing photos&hellip;</span>
</div>
<div className="space-y-1 text-xs">
{visionActive > 0 && (
<div className="flex items-center justify-between">
<span className="text-text-muted">Vision pipeline</span>
<span className="font-mono text-amber-400">{visionActive} queued</span>
</div>
)}
{(totalActive - visionActive) > 0 && (
<div className="flex items-center justify-between">
<span className="text-text-muted">Other tasks</span>
<span className="font-mono text-text-muted">{totalActive - visionActive} queued</span>
</div>
)}
</div>
<div className="mt-2 text-[11px] text-text-muted/60">
Thumbnails, embeddings, faces, tags &mdash; runs in background
</div>
</>
)}
{/* Done phase */}
{phase === 'idle' && isComplete && (
<div className="flex items-center gap-2 text-xs text-pick">
<Check className="h-3.5 w-3.5" />
<span>All processing complete</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>
)
}
function visionQueued(ws: WorkerStatus | undefined): number {
if (!ws) return 0
const queued = ws.queues?.['vision'] ?? 0
const active = ws.workers
?.filter(w => w.queues?.includes('vision'))
.reduce((sum, w) => sum + (w.active ?? 0) + (w.reserved ?? 0), 0) ?? 0
return queued + active
return null
}
function totalQueued(ws: WorkerStatus | undefined): number {

View File

@@ -1,132 +1,34 @@
import { useEffect, useState } from 'react'
import { CheckCircle, XCircle, Info, AlertCircle, X } from 'lucide-react'
import clsx from 'clsx'
import { toast as sonnerToast } from 'sonner'
import { Toaster } from '@/components/ui/sonner'
export interface ToastAction {
label: string
onClick: () => void
}
export interface Toast {
id: string
type: 'success' | 'error' | 'info' | 'warning'
title: string
message?: string
duration?: number
action?: ToastAction
}
// Global toast state (in production, use Zustand or Context)
let toastListeners: ((toasts: Toast[]) => void)[] = []
let toastList: Toast[] = []
/** Shim that preserves the legacy `(title, message?, action?)` call
* shape used throughout the codebase while delegating to sonner for
* actual rendering. Call sites don't need to change. Actions auto-
* extend the toast duration to 8s so users have time to hit Undo. */
const build = (message?: string, action?: ToastAction, duration = 5000) => ({
description: message,
action: action && { label: action.label, onClick: action.onClick },
duration: action ? Math.max(duration, 8000) : duration,
})
export const toast = {
success: (title: string, message?: string, action?: ToastAction) =>
addToast('success', title, message, 5000, action),
sonnerToast.success(title, build(message, action)),
error: (title: string, message?: string, action?: ToastAction) =>
addToast('error', title, message, 5000, action),
sonnerToast.error(title, build(message, action)),
info: (title: string, message?: string, action?: ToastAction) =>
addToast('info', title, message, 5000, action),
sonnerToast.info(title, build(message, action)),
warning: (title: string, message?: string, action?: ToastAction) =>
addToast('warning', title, message, 5000, action),
}
function addToast(
type: Toast['type'],
title: string,
message?: string,
duration = 5000,
action?: ToastAction
) {
const id = Date.now().toString() + Math.random().toString(36).slice(2, 6)
const newToast: Toast = { id, type, title, message, duration, action }
toastList = [...toastList, newToast]
toastListeners.forEach(listener => listener(toastList))
// Auto-remove after duration. Toasts with an action get a longer window
// so the user has time to actually click Undo.
const removeAfter = action ? Math.max(duration, 8000) : duration
setTimeout(() => {
removeToast(id)
}, removeAfter)
}
function removeToast(id: string) {
toastList = toastList.filter(t => t.id !== id)
toastListeners.forEach(listener => listener(toastList))
sonnerToast.warning(title, build(message, action)),
}
/** Mounted once near the App root. Delegates to sonner's `<Toaster />`
* with palette-matched class overrides (see `@/components/ui/sonner`). */
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)
}
}, [])
// Subdued icons — smaller and muted so the toast reads as a
// background notification rather than a modal. The colored tint
// comes from the border-left accent, not a filled background.
const icons = {
success: <CheckCircle className="h-3.5 w-3.5 text-pick" />,
error: <XCircle className="h-3.5 w-3.5 text-reject" />,
info: <Info className="h-3.5 w-3.5 text-primary" />,
warning: <AlertCircle className="h-3.5 w-3.5 text-star" />,
}
// Single thin left accent bar per type instead of a full-border +
// tinted fill. Keeps the toast visually quiet — the user can still
// glance it but it doesn't compete with the rest of the UI.
const accents = {
success: 'border-l-pick',
error: 'border-l-reject',
info: 'border-l-primary',
warning: 'border-l-star',
}
return (
<div className="pointer-events-none fixed bottom-4 left-4 z-50 flex flex-col gap-1.5">
{toasts.map((toast) => (
<div
key={toast.id}
className={clsx(
'pointer-events-auto flex items-start gap-2 rounded-md border border-border border-l-2 bg-surface/80 px-2.5 py-1.5 text-xs shadow-md backdrop-blur-md transition-all duration-300',
'animate-slide-up',
accents[toast.type]
)}
style={{ minWidth: '220px', maxWidth: '320px' }}
>
<div className="mt-0.5 flex-shrink-0">{icons[toast.type]}</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-text">{toast.title}</div>
{toast.message && (
<div className="mt-0.5 truncate text-[11px] text-text-muted">
{toast.message}
</div>
)}
</div>
{toast.action && (
<button
onClick={() => {
toast.action!.onClick()
removeToast(toast.id)
}}
className="pointer-events-auto self-center rounded border border-border bg-surface px-1.5 py-0.5 text-[11px] font-medium text-text hover:bg-surface-2"
>
{toast.action.label}
</button>
)}
<button
onClick={() => removeToast(toast.id)}
className="pointer-events-auto rounded p-0.5 text-text-faint hover:bg-surface-offset hover:text-text"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)
}
return <Toaster />
}

View File

@@ -1,12 +1,32 @@
import { useState, useEffect, useCallback } from 'react'
import { Plus, Pencil, UserX, Shield, User as UserIcon } from 'lucide-react'
import { admin, type AdminUser } from '../../services/api'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { ConfirmDialog } from '../dialogs/ConfirmDialog'
export function UserManagement() {
const [users, setUsers] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [editingUser, setEditingUser] = useState<AdminUser | null>(null)
const [deactivatingUser, setDeactivatingUser] = useState<AdminUser | null>(null)
const [error, setError] = useState<string | null>(null)
const fetchUsers = useCallback(async () => {
@@ -24,6 +44,18 @@ export function UserManagement() {
fetchUsers()
}, [fetchUsers])
const handleDeactivate = async () => {
if (!deactivatingUser) return
try {
await admin.deleteUser(deactivatingUser.id)
setDeactivatingUser(null)
fetchUsers()
} catch (err: any) {
setError(err.response?.data?.detail ?? 'Failed to deactivate user.')
setDeactivatingUser(null)
}
}
if (loading) {
return <div className="p-4 text-sm text-text-muted">Loading users&hellip;</div>
}
@@ -32,19 +64,16 @@ export function UserManagement() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-text">Users</h3>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-1 rounded bg-accent px-2 py-1 text-xs text-white hover:bg-accent/80"
>
<Plus className="h-3 w-3" />
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="mr-1 h-3 w-3" />
Add User
</button>
</Button>
</div>
{error && (
<div className="rounded bg-red-900/30 px-3 py-2 text-xs text-red-300">
{error}
</div>
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<table className="w-full text-xs">
@@ -87,29 +116,25 @@ export function UserManagement() {
</td>
<td className="py-1.5">
<div className="flex gap-1">
<button
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setEditingUser(u)}
className="rounded p-1 text-text-muted hover:bg-bg hover:text-text"
title="Edit user"
>
<Pencil className="h-3 w-3" />
</button>
</Button>
{u.is_active && (
<button
onClick={async () => {
if (!confirm(`Deactivate user "${u.username}"? Their photos will be preserved.`)) return
try {
await admin.deleteUser(u.id)
fetchUsers()
} catch (err: any) {
setError(err.response?.data?.detail ?? 'Failed to deactivate user.')
}
}}
className="rounded p-1 text-text-muted hover:bg-bg hover:text-red-400"
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:text-red-400"
onClick={() => setDeactivatingUser(u)}
title="Deactivate user"
>
<UserX className="h-3 w-3" />
</button>
</Button>
)}
</div>
</td>
@@ -118,26 +143,33 @@ export function UserManagement() {
</tbody>
</table>
{showCreate && (
<CreateUserModal
onClose={() => setShowCreate(false)}
onCreated={() => {
setShowCreate(false)
fetchUsers()
}}
/>
)}
<CreateUserModal
open={showCreate}
onClose={() => setShowCreate(false)}
onCreated={() => {
setShowCreate(false)
fetchUsers()
}}
/>
{editingUser && (
<EditUserModal
user={editingUser}
onClose={() => setEditingUser(null)}
onSaved={() => {
setEditingUser(null)
fetchUsers()
}}
/>
)}
<EditUserModal
user={editingUser}
onClose={() => setEditingUser(null)}
onSaved={() => {
setEditingUser(null)
fetchUsers()
}}
/>
<ConfirmDialog
isOpen={!!deactivatingUser}
title={`Deactivate "${deactivatingUser?.username}"?`}
message="Their photos will be preserved."
confirmLabel="Deactivate"
destructive
onConfirm={handleDeactivate}
onClose={() => setDeactivatingUser(null)}
/>
</div>
)
}
@@ -145,9 +177,11 @@ export function UserManagement() {
// ── Create User Modal ──────────────────────────────────────────────────
function CreateUserModal({
open,
onClose,
onCreated,
}: {
open: boolean
onClose: () => void
onCreated: () => void
}) {
@@ -157,6 +191,16 @@ function CreateUserModal({
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
if (open) {
setUsername('')
setPassword('')
setRole('user')
setError(null)
setLoading(false)
}
}, [open])
const handleSubmit = async () => {
setError(null)
setLoading(true)
@@ -171,56 +215,61 @@ function CreateUserModal({
}
return (
<ModalOverlay onClose={onClose} title="Add User">
{error && (
<div className="rounded bg-red-900/30 px-3 py-2 text-xs text-red-300">
{error}
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Add User</DialogTitle>
</DialogHeader>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-3">
<div className="space-y-1">
<Label htmlFor="new-username">Username</Label>
<Input
id="new-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
/>
</div>
<div className="space-y-1">
<Label htmlFor="new-password">Password</Label>
<Input
id="new-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label>Role</Label>
<Select
value={role}
onValueChange={(v) => setRole(v as 'user' | 'admin')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
<div className="space-y-3">
<Field label="Username">
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent"
autoFocus
/>
</Field>
<Field label="Password">
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent"
/>
</Field>
<Field label="Role">
<select
value={role}
onChange={(e) => setRole(e.target.value as 'user' | 'admin')}
className="rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</Field>
</div>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={onClose}
className="rounded border border-border px-3 py-1 text-xs text-text hover:bg-bg"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={loading}
className="rounded bg-accent px-3 py-1 text-xs text-white hover:bg-accent/80 disabled:opacity-50"
>
{loading ? 'Creating\u2026' : 'Create'}
</button>
</div>
</ModalOverlay>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading ? 'Creating\u2026' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -231,16 +280,26 @@ function EditUserModal({
onClose,
onSaved,
}: {
user: AdminUser
user: AdminUser | null
onClose: () => void
onSaved: () => void
}) {
const [role, setRole] = useState(user.role)
const [role, setRole] = useState<'user' | 'admin'>('user')
const [newPassword, setNewPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
if (user) {
setRole(user.role as 'user' | 'admin')
setNewPassword('')
setError(null)
setLoading(false)
}
}, [user])
const handleSubmit = async () => {
if (!user) return
setError(null)
setLoading(true)
try {
@@ -259,78 +318,54 @@ function EditUserModal({
}
return (
<ModalOverlay onClose={onClose} title={`Edit: ${user.username}`}>
{error && (
<div className="rounded bg-red-900/30 px-3 py-2 text-xs text-red-300">
{error}
<Dialog open={!!user} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Edit: {user?.username}</DialogTitle>
</DialogHeader>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-3">
<div className="space-y-1">
<Label>Role</Label>
<Select
value={role}
onValueChange={(v) => setRole(v as 'user' | 'admin')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="edit-password">
New Password (leave blank to keep current)
</Label>
<Input
id="edit-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Unchanged"
/>
</div>
</div>
)}
<div className="space-y-3">
<Field label="Role">
<select
value={role}
onChange={(e) => setRole(e.target.value as 'user' | 'admin')}
className="rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</Field>
<Field label="New Password (leave blank to keep current)">
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent"
placeholder="Unchanged"
/>
</Field>
</div>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={onClose}
className="rounded border border-border px-3 py-1 text-xs text-text hover:bg-bg"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={loading}
className="rounded bg-accent px-3 py-1 text-xs text-white hover:bg-accent/80 disabled:opacity-50"
>
{loading ? 'Saving\u2026' : 'Save'}
</button>
</div>
</ModalOverlay>
)
}
// ── Shared helpers ─────────────────────────────────────────────────────
function ModalOverlay({
onClose: _onClose,
title,
children,
}: {
onClose: () => void
title: string
children: React.ReactNode
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={_onClose}>
<div className="w-full max-w-sm rounded-lg border border-border bg-surface p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
<h4 className="mb-3 text-sm font-semibold text-text">{title}</h4>
{children}
</div>
</div>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="space-y-1">
<label className="block text-[11px] text-text-muted">{label}</label>
{children}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading ? 'Saving\u2026' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,5 +1,9 @@
import { useState, type FormEvent } from 'react'
import { useAuth } from '../../contexts/AuthContext'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
export function LoginPage() {
const { login } = useAuth()
@@ -34,47 +38,37 @@ export function LoginPage() {
</h1>
{error && (
<div className="rounded bg-red-900/30 px-3 py-2 text-sm text-red-300">
{error}
</div>
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-1">
<label htmlFor="login-user" className="block text-sm text-text-muted">
Username
</label>
<input
<div className="space-y-1.5">
<Label htmlFor="login-user">Username</Label>
<Input
id="login-user"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
className="w-full rounded border border-border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-accent"
/>
</div>
<div className="space-y-1">
<label htmlFor="login-pass" className="block text-sm text-text-muted">
Password
</label>
<input
<div className="space-y-1.5">
<Label htmlFor="login-pass">Password</Label>
<Input
id="login-pass"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full rounded border border-border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-accent"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent/80 disabled:opacity-50"
>
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Signing in\u2026' : 'Sign In'}
</button>
</Button>
</form>
</div>
)

View File

@@ -1,6 +1,10 @@
import { useState, type FormEvent } from 'react'
import { useAuth } from '../../contexts/AuthContext'
import api from '../../services/api'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
export function SetupPage() {
const { onSetupComplete } = useAuth()
@@ -58,61 +62,48 @@ export function SetupPage() {
</div>
{error && (
<div className="rounded bg-red-900/30 px-3 py-2 text-sm text-red-300">
{error}
</div>
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-1">
<label htmlFor="setup-user" className="block text-sm text-text-muted">
Username
</label>
<input
<div className="space-y-1.5">
<Label htmlFor="setup-user">Username</Label>
<Input
id="setup-user"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
className="w-full rounded border border-border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-accent"
/>
</div>
<div className="space-y-1">
<label htmlFor="setup-pass" className="block text-sm text-text-muted">
Password
</label>
<input
<div className="space-y-1.5">
<Label htmlFor="setup-pass">Password</Label>
<Input
id="setup-pass"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full rounded border border-border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-accent"
/>
</div>
<div className="space-y-1">
<label htmlFor="setup-confirm" className="block text-sm text-text-muted">
Confirm Password
</label>
<input
<div className="space-y-1.5">
<Label htmlFor="setup-confirm">Confirm Password</Label>
<Input
id="setup-confirm"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="w-full rounded border border-border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-accent"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent/80 disabled:opacity-50"
>
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Creating account\u2026' : 'Create Admin Account'}
</button>
</Button>
</form>
</div>
)

View File

@@ -1,6 +1,7 @@
import { useState, useMemo, useCallback } from 'react'
import { Palette, ArrowLeft, Loader2 } from 'lucide-react'
import clsx from 'clsx'
import { Button } from '@/components/ui/button'
import { photos as photosApi } from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
@@ -90,13 +91,15 @@ export function ColorsView() {
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<button
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-text-muted"
onClick={exitDetail}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
title="Back to colors"
>
<ArrowLeft className="h-4 w-4" />
</button>
</Button>
<div className="flex items-center gap-2">
<span className={`inline-block h-3 w-3 rounded-full ${selectedGroup.className}`} />
<h2 className="text-sm font-semibold text-text">{selectedGroup.label}</h2>

View File

@@ -1,5 +1,12 @@
import { useEffect } from 'react'
import clsx from 'clsx'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
interface ConfirmDialogProps {
isOpen: boolean
@@ -14,8 +21,9 @@ interface ConfirmDialogProps {
}
/**
* Tiny modal-confirmation dialog. Mirrors the AddSourceFolderDialog overlay
* pattern (custom fixed inset-0 backdrop, no shadcn Dialog dep). Esc closes.
* Modal confirmation dialog. Built on the shadcn Dialog primitive:
* Radix handles focus trap, portal, overlay-click dismissal, Esc, and
* animations. We only supply title, body, and the two action buttons.
*/
export function ConfirmDialog({
isOpen,
@@ -27,49 +35,27 @@ export function ConfirmDialog({
onConfirm,
onClose,
}: ConfirmDialogProps) {
// Esc to close.
useEffect(() => {
if (!isOpen) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [isOpen, onClose])
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="relative z-10 w-96 rounded-lg border border-border bg-surface p-5 shadow-2xl">
<h2 className="mb-2 text-base font-semibold text-text">{title}</h2>
<div className="mb-4 text-sm text-text-muted">{message}</div>
<div className="flex justify-end gap-2">
<button
onClick={onClose}
className="rounded border border-border px-3 py-1.5 text-sm text-text hover:bg-surface-2"
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
className={clsx(
'rounded px-3 py-1.5 text-sm font-medium text-white',
destructive
? 'bg-reject hover:bg-reject/80'
: 'bg-primary hover:bg-primary/80'
)}
>
{confirmLabel}
</button>
</div>
</div>
</div>
</div>
<Dialog open={isOpen} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription asChild>
<div>{message}</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{cancelLabel}
</Button>
<Button
variant={destructive ? 'destructive' : 'default'}
onClick={onConfirm}
>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -2,6 +2,18 @@ import { useEffect, useState } from 'react'
import clsx from 'clsx'
import { Trash2, Archive } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Label } from '@/components/ui/label'
interface DeleteFolderDialogProps {
isOpen: boolean
folderName: string
@@ -13,14 +25,12 @@ interface DeleteFolderDialogProps {
onConfirm: (mode: 'discard' | 'permanent') => void
}
type Mode = 'discard' | 'permanent'
/**
* Two-mode folder delete dialog:
*
* - Move to discard pile (default, soft, recoverable)
* - Permanently delete (destructive, irreversible)
*
* The user picks a mode via the radio cards then clicks Delete. Esc /
* backdrop click cancels.
* Two-mode folder delete dialog built on the shadcn Dialog + RadioGroup
* primitives (instead of the old custom fixed-inset backdrop). Esc
* closes, overlay click closes, focus is trapped by Radix.
*/
export function DeleteFolderDialog({
isOpen,
@@ -29,110 +39,93 @@ export function DeleteFolderDialog({
onClose,
onConfirm,
}: DeleteFolderDialogProps) {
const [mode, setMode] = useState<'discard' | 'permanent'>('discard')
const [mode, setMode] = useState<Mode>('discard')
// Reset mode when re-opening so the safe option is always the default.
useEffect(() => {
if (isOpen) setMode('discard')
}, [isOpen])
// Esc to close.
useEffect(() => {
if (!isOpen) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [isOpen, onClose])
if (!isOpen) return null
const photoBlurb =
photoCount === undefined
? 'photos in this folder'
: photoCount === 0
? 'this empty folder'
: `${photoCount} photo${photoCount === 1 ? '' : 's'} in this folder`
? 'this empty folder'
: `${photoCount} photo${photoCount === 1 ? '' : 's'} in this folder`
return (
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="relative z-10 w-[420px] rounded-lg border border-border bg-surface p-5 shadow-2xl">
<h2 className="mb-1 text-base font-semibold text-text">
Delete folder "{folderName}"?
</h2>
<p className="mb-4 text-sm text-text-muted">
<Dialog open={isOpen} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-[420px]">
<DialogHeader>
<DialogTitle>Delete folder "{folderName}"?</DialogTitle>
<DialogDescription>
What should happen to {photoBlurb}?
</p>
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<ModeCard
icon={<Archive className="h-4 w-4" />}
title="Move photos to discard pile"
description="Photos can be restored later from Discarded. The folder and files stay on disk."
selected={mode === 'discard'}
onClick={() => setMode('discard')}
/>
<ModeCard
icon={<Trash2 className="h-4 w-4" />}
title="Permanently delete folder and photos"
description="Removes the folder, every photo inside it, and the directory from disk. This cannot be undone."
selected={mode === 'permanent'}
destructive
onClick={() => setMode('permanent')}
/>
</div>
<RadioGroup
value={mode}
onValueChange={(v) => setMode(v as Mode)}
className="gap-2"
>
<ModeCard
id="folder-delete-discard"
value="discard"
icon={<Archive className="h-4 w-4" />}
title="Move photos to discard pile"
description="Photos can be restored later from Discarded. The folder and files stay on disk."
selected={mode === 'discard'}
/>
<ModeCard
id="folder-delete-permanent"
value="permanent"
icon={<Trash2 className="h-4 w-4" />}
title="Permanently delete folder and photos"
description="Removes the folder, every photo inside it, and the directory from disk. This cannot be undone."
selected={mode === 'permanent'}
destructive
/>
</RadioGroup>
<div className="mt-5 flex justify-end gap-2">
<button
onClick={onClose}
className="rounded border border-border px-3 py-1.5 text-sm text-text hover:bg-surface-2"
>
Cancel
</button>
<button
onClick={() => onConfirm(mode)}
className={clsx(
'rounded px-3 py-1.5 text-sm font-medium text-white',
mode === 'permanent'
? 'bg-reject hover:bg-reject/80'
: 'bg-primary hover:bg-primary/80'
)}
>
{mode === 'permanent' ? 'Delete forever' : 'Move to discard pile'}
</button>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
variant={mode === 'permanent' ? 'destructive' : 'default'}
onClick={() => onConfirm(mode)}
>
{mode === 'permanent' ? 'Delete forever' : 'Move to discard pile'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/** Radio-group item rendered as a full-width descriptive card. */
function ModeCard({
id,
value,
icon,
title,
description,
selected,
destructive = false,
onClick,
}: {
id: string
value: string
icon: React.ReactNode
title: string
description: string
selected: boolean
destructive?: boolean
onClick: () => void
}) {
return (
<button
onClick={onClick}
<Label
htmlFor={id}
className={clsx(
'flex w-full gap-3 rounded-lg border p-3 text-left transition-colors',
'flex w-full cursor-pointer gap-3 rounded-lg border p-3 text-left transition-colors',
selected
? destructive
? 'border-reject/60 bg-reject/10'
@@ -140,10 +133,15 @@ function ModeCard({
: 'border-border bg-surface-2 hover:bg-surface-offset'
)}
>
<RadioGroupItem id={id} value={value} className="mt-0.5" />
<div
className={clsx(
'mt-0.5 flex-shrink-0',
selected ? (destructive ? 'text-reject' : 'text-primary') : 'text-text-muted'
selected
? destructive
? 'text-reject'
: 'text-primary'
: 'text-text-muted'
)}
>
{icon}
@@ -152,13 +150,17 @@ function ModeCard({
<div
className={clsx(
'text-sm font-medium',
selected ? (destructive ? 'text-reject' : 'text-primary') : 'text-text'
selected
? destructive
? 'text-reject'
: 'text-primary'
: 'text-text'
)}
>
{title}
</div>
<div className="mt-0.5 text-xs text-text-muted">{description}</div>
</div>
</button>
</Label>
)
}

View File

@@ -16,10 +16,6 @@ import {
FolderSearch,
Shield,
Brain,
ScanText,
UserSquare2,
Boxes,
Tags as TagsIcon,
RotateCcw,
} from 'lucide-react'
import clsx from 'clsx'
@@ -36,6 +32,9 @@ import {
import { toast } from '../ToastContainer'
import { useAuth } from '../../contexts/AuthContext'
import { UserManagement } from '../admin/UserManagement'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Switch } from '@/components/ui/switch'
import { Button } from '@/components/ui/button'
// React Query keys for the settings panels. Kept here (not in a shared
// hook module) since they're internal to this dialog and used by the
@@ -216,23 +215,24 @@ export function SettingsPage() {
const visibleTabs = TABS.filter((t) => !t.adminOnly || isAdmin)
return (
<div className="flex h-full flex-col">
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as SettingsTab)}
className="flex h-full flex-col"
>
{/* Tab bar */}
<div className="flex items-center gap-1 border-b border-border bg-surface px-4 py-1.5">
{visibleTabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={clsx(
'rounded px-3 py-1 text-xs font-medium transition-colors',
activeTab === tab.id
? 'bg-primary/20 text-primary'
: 'text-text-muted hover:bg-surface-2 hover:text-text',
)}
>
{tab.label}
</button>
))}
<div className="flex items-center border-b border-border bg-surface px-4 py-1.5">
<TabsList className="h-7 bg-transparent">
{visibleTabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className="h-6 px-3 text-xs data-[state=active]:bg-primary/20 data-[state=active]:text-primary"
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</div>
{/* Tab content */}
@@ -871,7 +871,7 @@ export function SettingsPage() {
</div>
</div>
</div>
</Tabs>
)
}
@@ -1107,56 +1107,20 @@ interface AiFeaturesTabProps {
) => Promise<void>
}
// Flags are keyed by the backend's canonical name ("vision.enabled",
// "vision.ocr.enabled", ...). The metadata here just adds presentation
// (label, short description, icon) so the tab layout stays data-driven.
const FLAG_META: Array<{
id: string
label: string
description: string
icon: React.ReactNode
// Optional "run this backfill" hook — lets the user kick off a stage's
// backfill right from the toggle row without hopping to a separate UI.
backfillTask?: 'embed' | 'ocr' | 'detect' | 'faces' | 'classify'
}> = [
{
id: 'vision.enabled',
label: 'Vision pipeline (master switch)',
label: 'Vision classifier',
description:
'When off, every AI stage below is skipped — including newly uploaded photos. ' +
'Existing results stay intact.',
'Binary photo-vs-other classifier. Flags screenshots, documents, memes and ' +
'scans with "needs review" so they can be triaged.',
icon: <Sparkles className="h-3.5 w-3.5" />,
},
{
id: 'vision.ocr.enabled',
label: 'Text recognition (OCR)',
description: 'Extract printed / handwritten text from photos so it becomes searchable.',
icon: <ScanText className="h-3.5 w-3.5" />,
backfillTask: 'ocr',
},
{
id: 'vision.detector.enabled',
label: 'Object detection',
description: 'Tag photos with detected objects (person, car, dog, …) via YOLOv8n.',
icon: <Boxes className="h-3.5 w-3.5" />,
backfillTask: 'detect',
},
{
id: 'vision.faces.enabled',
label: 'Face recognition',
description:
'Find and cluster faces across the library (RetinaFace + ArcFace). ' +
'Expensive on big libraries — disable if you don\'t need the People view.',
icon: <UserSquare2 className="h-3.5 w-3.5" />,
backfillTask: 'faces',
},
{
id: 'vision.classifier.enabled',
label: 'Content classification',
description: 'Zero-shot CLIP tags for scenes / activities (beach, wedding, …).',
icon: <TagsIcon className="h-3.5 w-3.5" />,
backfillTask: 'classify',
},
]
function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {
@@ -1183,12 +1147,11 @@ function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {
queryClient.invalidateQueries({ queryKey: ['features'] })
}
type BackfillTask = 'embed' | 'ocr' | 'detect' | 'faces' | 'classify' | null
const runBackfill = (task: BackfillTask) =>
const runBackfill = () =>
runAction(
`ai-backfill:${task ?? 'all'}`,
() => adminApi.triggerAiBackfill({ task }),
task ? `Backfill queued for ${task}` : 'Full backfill queued',
`ai-backfill:all`,
() => adminApi.triggerAiBackfill({}),
'Classifier backfill queued',
(r) => `Celery task ${r.task_id}`,
)
@@ -1252,51 +1215,28 @@ function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
role="switch"
aria-checked={state.effective}
onClick={() => applyFlag(meta.id, !state.effective)}
<Switch
checked={state.effective}
onCheckedChange={(v) => applyFlag(meta.id, v)}
disabled={isBusy || (dimmed && !isMaster)}
className={clsx(
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors',
state.effective ? 'bg-primary' : 'bg-surface-2 border border-border',
(isBusy || (dimmed && !isMaster)) && 'cursor-not-allowed opacity-50',
)}
title={state.effective ? 'Click to disable' : 'Click to enable'}
>
<span
aria-hidden="true"
className={clsx(
'inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform',
state.effective ? 'translate-x-[22px]' : 'translate-x-0.5',
)}
/>
</button>
/>
{state.overridden && (
<button
<Button
variant="outline"
size="icon"
onClick={() => applyFlag(meta.id, null)}
disabled={isBusy}
className="rounded border border-border p-1 text-text-muted hover:bg-surface-2 hover:text-text disabled:opacity-50"
className="h-6 w-6"
title="Reset to YAML default"
aria-label="Reset override"
>
<RotateCcw className="h-3 w-3" />
</button>
</Button>
)}
</div>
</div>
{meta.backfillTask && state.effective && !masterOff && (
<div className="mt-2">
<ActionButton
loading={!!busy[`ai-backfill:${meta.backfillTask}`]}
onClick={() => runBackfill(meta.backfillTask!)}
>
<RefreshCw className="h-3.5 w-3.5" />
Run {meta.backfillTask} backfill
</ActionButton>
</div>
)}
</div>
)
})}
@@ -1306,35 +1246,17 @@ function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {
<Section icon={<Cpu className="h-4 w-4" />} title="Manual pipeline triggers">
<p className="text-xs text-text-muted">
Run a full pass across the enabled stages, recompute face
clusters, or force a fresh filesystem scan. All three are safe
to run repeatedly — the backfill only touches photos that
don\'t yet have a given output, and the rescan skips files
that are already indexed.
Run the classifier over any photos that haven't been classified yet,
or force a fresh filesystem scan. Both are safe to run repeatedly.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<ActionButton
loading={!!busy['ai-backfill:all']}
onClick={() => runBackfill(null)}
onClick={() => runBackfill()}
disabled={masterOff}
>
<Sparkles className="h-4 w-4" />
Run full vision backfill
</ActionButton>
<ActionButton
loading={!!busy['recluster']}
onClick={() =>
runAction(
'recluster',
() => adminApi.triggerFaceRecluster(),
'Face recluster queued',
(r) => `Celery task ${r.task_id}`,
)
}
disabled={masterOff || !flags['vision.faces.enabled']?.effective}
>
<UserSquare2 className="h-4 w-4" />
Recluster faces
Run classifier backfill
</ActionButton>
<ActionButton
loading={!!busy['rescan-full']}

View File

@@ -7,6 +7,7 @@ import { usePhotosQuery, stripPhotosFromCache } from '../../hooks/usePhotosQuery
import { discard as discardApi, photos as photosApi } from '../../services/api'
import { toast } from '../ToastContainer'
import { ConfirmDialog } from '../dialogs/ConfirmDialog'
import { Button } from '@/components/ui/button'
import { registerUndoable } from '../../store/undoStore'
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
@@ -120,35 +121,38 @@ export function DiscardActionBar() {
<div className="flex items-center gap-2">
{selected > 0 && (
<>
<button
<Button
variant="secondary"
size="sm"
onClick={() => restoreMutation.mutate(selectedPhotos)}
disabled={restoreMutation.isPending}
className="flex items-center gap-1.5 rounded bg-surface-2 px-3 py-1 text-text hover:bg-surface-offset disabled:opacity-50"
title="Restore selected (U)"
>
<RotateCcw className="h-3.5 w-3.5" />
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
Restore {selected}
</button>
<button
</Button>
<Button
size="sm"
onClick={() => setDeleteSelectedOpen(true)}
disabled={deleteSelectedMutation.isPending}
className="flex items-center gap-1.5 rounded bg-reject/20 px-3 py-1 text-reject hover:bg-reject/30 disabled:opacity-50"
className="bg-reject/20 text-reject hover:bg-reject/30"
title="Permanently delete selected"
>
<Trash2 className="h-3.5 w-3.5" />
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete {selected}
</button>
</Button>
</>
)}
<button
<Button
size="sm"
onClick={() => setConfirmOpen(true)}
disabled={total === 0 || emptyMutation.isPending}
className="flex items-center gap-1.5 rounded bg-reject/20 px-3 py-1 text-reject hover:bg-reject/30 disabled:opacity-50"
className="bg-reject/20 text-reject hover:bg-reject/30"
title="Permanently delete all discarded photos and files"
>
<Trash2 className="h-3.5 w-3.5" />
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Empty discard pile
</button>
</Button>
</div>
</div>

View File

@@ -2,6 +2,7 @@ import { useMemo, useState, useEffect, useCallback } from 'react'
import { Copy, Layers, Sparkles, Trash2, Loader2, Info, Crown } from 'lucide-react'
import clsx from 'clsx'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Button } from '@/components/ui/button'
import {
useDuplicateGroupsQuery,
DUPLICATE_GROUPS_QUERY_KEY,
@@ -269,7 +270,9 @@ function DuplicateGroupSection({
)}
</span>
</div>
<button
<Button
variant="outline"
size="sm"
onClick={() => {
const discardIds = group.members
.filter((m) => m.id !== best.id)
@@ -278,16 +281,12 @@ function DuplicateGroupSection({
onKeepBest(discardIds)
}}
disabled={isPending}
className={clsx(
'flex items-center gap-1.5 rounded border border-border px-2 py-1 text-xs font-medium transition-colors',
'hover:border-reject/50 hover:bg-reject/10 hover:text-reject',
isPending && 'cursor-not-allowed opacity-50'
)}
className="hover:border-reject/50 hover:bg-reject/10 hover:text-reject"
title="Keep the highest-resolution copy and discard the rest"
>
<Trash2 className="h-3 w-3" />
<Trash2 className="mr-1.5 h-3 w-3" />
Keep best, discard {discardCount}
</button>
</Button>
</header>
<div

View File

@@ -1,5 +1,15 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Star, X, ArrowDown, ArrowUp, Search, AlertTriangle, Check } from 'lucide-react'
import {
Star,
X,
ArrowDown,
ArrowUp,
Search,
PanelLeftOpen,
PanelLeftClose,
PanelRightOpen,
PanelRightClose,
} from 'lucide-react'
import clsx from 'clsx'
import {
useFilterStore,
@@ -8,9 +18,20 @@ import {
type SortField,
} from '../../store/filterStore'
import { useTagsQuery } from '../../hooks/useTagsQuery'
import type { Tag } from '../../services/api'
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import { FilterPill } from './FilterPill'
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { MultiSelect } from '@/components/ui/multi-select'
import { Calendar } from '@/components/ui/calendar'
const SEARCH_DEBOUNCE_MS = 300
@@ -35,7 +56,19 @@ const SORT_OPTIONS: { value: SortField; label: string }[] = [
* underlying control, and shows a short value summary inline when active.
* Replaces the old expandable FilterBar + ActiveFilterChips combo.
*/
export function FilterBar() {
interface FilterBarProps {
leftSidebarOpen: boolean
rightSidebarOpen: boolean
onToggleLeftSidebar: () => void
onToggleRightSidebar: () => void
}
export function FilterBar({
leftSidebarOpen,
rightSidebarOpen,
onToggleLeftSidebar,
onToggleRightSidebar,
}: FilterBarProps) {
const filterState = useFilterStore()
const dateFrom = useFilterStore((s) => s.dateFrom)
const dateTo = useFilterStore((s) => s.dateTo)
@@ -46,6 +79,8 @@ export function FilterBar() {
const sortBy = useFilterStore((s) => s.sortBy)
const sortOrder = useFilterStore((s) => s.sortOrder)
const tagIds = useFilterStore((s) => s.tagIds)
const needsReview = useFilterStore((s) => s.needsReview)
const setNeedsReview = useFilterStore((s) => s.setNeedsReview)
const currentSection = useFilterStore((s) => s.currentSection)
// Only the Flag pill is hidden inside the Discarded section. Flag has
@@ -62,7 +97,6 @@ export function FilterBar() {
const setColorLabel = useFilterStore((s) => s.setColorLabel)
const setFlag = useFilterStore((s) => s.setFlag)
const setTagIds = useFilterStore((s) => s.setTagIds)
const toggleTagId = useFilterStore((s) => s.toggleTagId)
const setSortBy = useFilterStore((s) => s.setSortBy)
const toggleSortOrder = useFilterStore((s) => s.toggleSortOrder)
const clearAll = useFilterStore((s) => s.clearAll)
@@ -106,12 +140,14 @@ export function FilterBar() {
const colorActive = colorLabel !== null
const colorValue = colorActive ? colorLabel : null
const flagActive = flag !== 'any'
const flagValue = flagActive
? flag === 'date_warning'
? 'date issues'
: flag
: null
const flagActive = flag !== 'any' || needsReview
const flagValue = needsReview
? 'needs review'
: flag !== 'any'
? flag === 'date_warning'
? 'date issues'
: flag
: null
const tagActive = tagIds.length > 0
const activeTagNames = allTags
@@ -129,258 +165,281 @@ export function FilterBar() {
const anyActive = hasActiveFilters(filterState)
return (
// Fixed bar height + py-0 so neither the active filter pills nor the
// clear-all button can stretch the bar vertically. The fixed h-11
// matches the h-7 pills + 8px symmetric vertical padding.
<div className="flex h-11 items-center gap-3 border-b border-border bg-surface px-3 py-0">
<div className="flex h-9 items-center gap-3 border-b border-border bg-surface px-3 py-0">
{/* Left sidebar toggle — pinned to the far-left edge of the bar so it
* sits flush against the panel it controls (or the viewport edge
* when collapsed). */}
<button
onClick={onToggleLeftSidebar}
className="flex-shrink-0 rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
title={leftSidebarOpen ? 'Collapse panel (Tab)' : 'Expand panel (Tab)'}
aria-label={leftSidebarOpen ? 'Collapse left panel' : 'Expand left panel'}
>
{leftSidebarOpen ? (
<PanelLeftClose className="h-3.5 w-3.5" />
) : (
<PanelLeftOpen className="h-3.5 w-3.5" />
)}
</button>
{/* Pills — left side, scroll horizontally if they overflow. */}
<div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto">
{/* Date */}
<FilterPill
label="Date"
value={dateValue}
isActive={dateActive}
onClear={() => {
setDateFrom(null)
setDateTo(null)
}}
>
<div className="space-y-2">
<div>
<label className="mb-1 block text-[11px] text-text-muted">From</label>
<input
type="date"
value={dateFrom ?? ''}
onChange={(e) => setDateFrom(e.target.value || null)}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text"
/>
</div>
<div>
<label className="mb-1 block text-[11px] text-text-muted">To</label>
<input
type="date"
value={dateTo ?? ''}
onChange={(e) => setDateTo(e.target.value || null)}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text"
/>
</div>
</div>
</FilterPill>
{/* Type */}
<FilterPill
label="Type"
value={typeValue}
isActive={typeActive}
onClear={() => mediaTypes.forEach((t) => toggleMediaType(t))}
>
<div className="flex flex-wrap gap-1">
{MEDIA_TYPES.map(({ value, label }) => {
const active = mediaTypes.includes(value)
return (
<button
key={value}
onClick={() => toggleMediaType(value)}
className={clsx(
'rounded px-2 py-1 text-xs transition-colors',
active
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
{label}
</button>
)
})}
</div>
</FilterPill>
{/* Rating */}
<FilterPill
label="Rating"
value={ratingValue}
isActive={ratingActive}
onClear={() => setRatingMin(0)}
>
<div>
<p className="mb-1 text-[11px] text-text-muted">Minimum</p>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
onClick={() => setRatingMin(ratingMin === n ? 0 : n)}
className="p-0.5"
title={`At least ${n} star${n > 1 ? 's' : ''}`}
>
<Star
className={clsx(
'h-5 w-5 transition-colors',
n <= ratingMin
? 'fill-star text-star'
: 'text-text-muted hover:text-star'
)}
/>
</button>
))}
</div>
</div>
</FilterPill>
{/* Color */}
<FilterPill
label="Color"
value={colorValue}
isActive={colorActive}
onClear={() => setColorLabel(null)}
>
<div className="flex items-center gap-1.5">
{COLOR_LABEL_OPTIONS.map(({ value, className }) => {
const active = colorLabel === value
return (
<button
key={value}
onClick={() => setColorLabel(active ? null : value)}
className={clsx(
'h-5 w-5 rounded-full ring-offset-2 ring-offset-surface transition-all',
className,
active ? 'ring-2 ring-primary' : 'opacity-60 hover:opacity-100'
)}
title={value}
/>
)
})}
{colorLabel && (
<button
onClick={() => setColorLabel(null)}
className="ml-1 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear color"
>
<X className="h-3 w-3" />
</button>
)}
</div>
</FilterPill>
{/* Flag — hidden in the Discarded section, where the flag is
* pinned to "discarded" by the section preset. */}
{!hideFlagPill && (
{/* Date */}
<FilterPill
label="Flag"
value={flagValue}
isActive={flagActive}
onClear={() => setFlag('any')}
label="Date"
value={dateValue}
isActive={dateActive}
onClear={() => {
setDateFrom(null)
setDateTo(null)
}}
>
<div className="flex flex-col gap-1">
<button
onClick={() => setFlag('any')}
className={clsx(
'rounded px-2 py-1 text-left text-xs transition-colors',
flag === 'any'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
Any
</button>
<button
onClick={() => setFlag('discarded')}
className={clsx(
'rounded px-2 py-1 text-left text-xs transition-colors',
flag === 'discarded'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
Discarded
</button>
<button
onClick={() => setFlag('date_warning')}
className={clsx(
'flex items-center gap-1.5 rounded px-2 py-1 text-left text-xs transition-colors',
flag === 'date_warning'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
title="Photos whose folder/filename suggests a different date than the stored taken_at"
>
<AlertTriangle className="h-3 w-3" />
Date issues
</button>
</div>
</FilterPill>
)}
{/* Tags */}
{allTags.length > 0 && (
<FilterPill
label="Tags"
value={tagValue}
isActive={tagActive}
onClear={() => setTagIds([])}
>
<TagFilterPopover
allTags={allTags}
selectedIds={tagIds}
onToggle={toggleTagId}
onClear={() => setTagIds([])}
<DateRangePicker
from={dateFrom}
to={dateTo}
onFromChange={setDateFrom}
onToChange={setDateTo}
/>
</FilterPill>
)}
{/* Sort — always present, never "active/inactive" since there's
always a value. */}
<FilterPill label="Sort" value={sortValue} isActive>
<div className="space-y-2">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortField)}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text focus:border-primary focus:outline-none"
>
{SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button
onClick={toggleSortOrder}
className="flex w-full items-center justify-center gap-1 rounded bg-surface-2 px-2 py-1 text-xs text-text-muted hover:bg-surface-offset hover:text-text"
>
{sortOrder === 'desc' ? (
<>
<ArrowDown className="h-3.5 w-3.5" />
Descending
</>
) : (
<>
<ArrowUp className="h-3.5 w-3.5" />
Ascending
</>
{/* Type */}
<FilterPill
label="Type"
value={typeValue}
isActive={typeActive}
onClear={() => mediaTypes.forEach((t) => toggleMediaType(t))}
>
<MultiSelect
searchable={false}
pinSelected={false}
options={MEDIA_TYPES.map(({ value, label }) => ({
value,
label,
}))}
values={mediaTypes}
onChange={(next) => {
// Diff against current selection — store uses per-item toggles.
for (const t of MEDIA_TYPES.map((m) => m.value)) {
const wasOn = mediaTypes.includes(t)
const nowOn = next.includes(t as MediaType)
if (wasOn !== nowOn) toggleMediaType(t)
}
}}
/>
</FilterPill>
{/* Rating */}
<FilterPill
label="Rating"
value={ratingValue}
isActive={ratingActive}
onClear={() => setRatingMin(0)}
>
<div>
<p className="mb-1 text-[11px] text-text-muted">Minimum</p>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
onClick={() => setRatingMin(ratingMin === n ? 0 : n)}
className="p-0.5"
title={`At least ${n} star${n > 1 ? 's' : ''}`}
>
<Star
className={clsx(
'h-5 w-5 transition-colors',
n <= ratingMin
? 'fill-star text-star'
: 'text-text-muted hover:text-star'
)}
/>
</button>
))}
</div>
</div>
</FilterPill>
{/* Color */}
<FilterPill
label="Color"
value={colorValue}
isActive={colorActive}
onClear={() => setColorLabel(null)}
>
<div className="flex items-center gap-1.5">
{COLOR_LABEL_OPTIONS.map(({ value, className }) => {
const active = colorLabel === value
return (
<button
key={value}
onClick={() => setColorLabel(active ? null : value)}
className={clsx(
'h-5 w-5 rounded-full ring-offset-2 ring-offset-surface transition-all',
className,
active ? 'ring-2 ring-primary' : 'opacity-60 hover:opacity-100'
)}
title={value}
/>
)
})}
{colorLabel && (
<button
onClick={() => setColorLabel(null)}
className="ml-1 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear color"
>
<X className="h-3 w-3" />
</button>
)}
</button>
</div>
</FilterPill>
</div>
</FilterPill>
{/* Clear-all — borderless text affordance pinned next to the pill
{/* Flag — hidden in the Discarded section, where the flag is
* pinned to "discarded" by the section preset. The Needs review
* option lives here too: it sets a different store field
* (`needsReview`) but is mutually exclusive with the other flag
* values from the user's perspective. */}
{!hideFlagPill && (
<FilterPill
label="Flag"
value={flagValue}
isActive={flagActive}
onClear={() => {
setFlag('any')
setNeedsReview(false)
}}
>
<MultiSelect
searchable={false}
pinSelected={false}
options={[
{ value: 'discarded', label: 'Discarded' },
{ value: 'date_warning', label: 'Date issues' },
{ value: 'needs_review', label: 'Needs review' },
]}
values={
needsReview
? ['needs_review']
: flag !== 'any'
? [flag]
: []
}
onChange={(next) => {
// Flag state is mutually exclusive in the store; treat
// the just-added value as the new single selection, or
// clear everything when the user unchecks the current.
const added = next.find(
(v) =>
v !==
(needsReview ? 'needs_review' : flag !== 'any' ? flag : '')
)
if (!added) {
setFlag('any')
setNeedsReview(false)
return
}
if (added === 'needs_review') {
setFlag('any')
setNeedsReview(true)
} else {
setFlag(added as 'discarded' | 'date_warning')
setNeedsReview(false)
}
}}
/>
</FilterPill>
)}
{/* Tags */}
{allTags.length > 0 && (
<FilterPill
label="Tags"
value={tagValue}
isActive={tagActive}
onClear={() => setTagIds([])}
>
<MultiSelect
className="w-60"
searchPlaceholder="Search tags…"
emptyMessage="No tags match"
options={allTags
.slice()
.sort((a, b) => {
if (b.photo_count !== a.photo_count)
return b.photo_count - a.photo_count
return a.name.localeCompare(b.name)
})
.map((t) => ({
value: t.id,
label: t.name,
meta: t.photo_count,
}))}
values={tagIds}
onChange={setTagIds}
/>
</FilterPill>
)}
{/* Sort — always present, never "active/inactive" since there's
always a value. */}
<FilterPill label="Sort" value={sortValue} isActive>
<div className="space-y-2">
<Select
value={sortBy}
onValueChange={(v) => setSortBy(v as SortField)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="secondary"
size="sm"
onClick={toggleSortOrder}
className="w-full"
>
{sortOrder === 'desc' ? (
<>
<ArrowDown className="h-3.5 w-3.5" />
Descending
</>
) : (
<>
<ArrowUp className="h-3.5 w-3.5" />
Ascending
</>
)}
</Button>
</div>
</FilterPill>
{/* Clear-all — borderless text affordance pinned next to the pill
* cluster on the right. Lives inside the pills container so it
* shares the same flex group and gap and reads as "another
* pill". Only renders when any filter is active. */}
{anyActive && (
<button
onClick={clearAll}
className="ml-1 flex h-7 flex-shrink-0 items-center whitespace-nowrap px-1 text-xs text-text-muted underline-offset-2 hover:text-text hover:underline"
title="Clear all filters in this section"
>
Clear all
</button>
)}
{anyActive && (
<button
onClick={clearAll}
className="ml-1 flex h-7 flex-shrink-0 items-center whitespace-nowrap px-1 text-xs text-text-muted underline-offset-2 hover:text-text hover:underline"
title="Clear all filters in this section"
>
Clear all
</button>
)}
</div>
{/* Search — pinned to the right edge of the bar. Same id as before
* so the global "/" focus shortcut still finds it. */}
<div className="relative w-56 flex-shrink-0">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted" />
<input
<Input
id="topbar-search"
type="text"
value={searchQuery}
@@ -393,7 +452,7 @@ export function FilterBar() {
}
}}
placeholder="Search photos…"
className="h-7 w-full rounded-full border border-border bg-surface-2 pl-8 pr-7 text-xs text-text placeholder-text-muted focus:border-primary focus:outline-none"
className="h-7 w-full rounded-full bg-surface-2 pl-8 pr-7 text-xs"
/>
{searchQuery && (
<button
@@ -409,148 +468,129 @@ export function FilterBar() {
</button>
)}
</div>
</div>
)
}
interface TagFilterPopoverProps {
allTags: Tag[]
selectedIds: string[]
onToggle: (id: string) => void
onClear: () => void
}
function TagFilterPopover({
allTags,
selectedIds,
onToggle,
onClear,
}: TagFilterPopoverProps) {
const [query, setQuery] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
inputRef.current?.focus()
}, [])
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
// Selected tags pinned at top, remaining sorted by photo_count desc
// then name. Filtered by query (case-insensitive substring).
const orderedTags = useMemo(() => {
const q = query.trim().toLowerCase()
const match = (t: Tag) => !q || t.name.toLowerCase().includes(q)
const selected = allTags.filter((t) => selectedSet.has(t.id) && match(t))
const unselected = allTags
.filter((t) => !selectedSet.has(t.id) && match(t))
.sort((a, b) => {
if (b.photo_count !== a.photo_count) return b.photo_count - a.photo_count
return a.name.localeCompare(b.name)
})
return { selected, unselected }
}, [allTags, selectedSet, query])
const totalVisible = orderedTags.selected.length + orderedTags.unselected.length
return (
<div className="w-64">
<div className="relative mb-2">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape' && query) {
e.stopPropagation()
setQuery('')
}
}}
placeholder="Search tags…"
className="h-7 w-full rounded border border-border bg-bg pl-7 pr-6 text-xs text-text placeholder-text-muted focus:border-primary focus:outline-none"
/>
{query && (
<button
onClick={() => setQuery('')}
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
aria-label="Clear search"
>
<X className="h-3 w-3" />
</button>
)}
</div>
<div className="mb-1 flex items-center justify-between px-0.5 text-[11px] text-text-muted">
<span>
{selectedIds.length > 0
? `${selectedIds.length} selected`
: `${totalVisible} tag${totalVisible === 1 ? '' : 's'}`}
</span>
{selectedIds.length > 0 && (
<button
onClick={onClear}
className="underline-offset-2 hover:text-text hover:underline"
>
Clear
</button>
)}
</div>
<div className="max-h-64 overflow-y-auto rounded border border-border bg-bg">
{totalVisible === 0 ? (
<div className="px-2 py-3 text-center text-xs text-text-muted">
No tags match
</div>
) : (
<>
{orderedTags.selected.map((tag) => (
<TagRow key={tag.id} tag={tag} selected onToggle={onToggle} />
))}
{orderedTags.selected.length > 0 && orderedTags.unselected.length > 0 && (
<div className="my-0.5 border-t border-border" />
)}
{orderedTags.unselected.map((tag) => (
<TagRow key={tag.id} tag={tag} selected={false} onToggle={onToggle} />
))}
</>
)}
</div>
</div>
)
}
function TagRow({
tag,
selected,
onToggle,
}: {
tag: Tag
selected: boolean
onToggle: (id: string) => void
}) {
return (
<button
onClick={() => onToggle(tag.id)}
className={clsx(
'flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs transition-colors',
selected
? 'bg-primary/15 text-text hover:bg-primary/25'
: 'text-text-muted hover:bg-surface-2 hover:text-text'
)}
>
<span
className={clsx(
'flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center rounded border',
selected ? 'border-primary bg-primary text-white' : 'border-border'
)}
{/* Right sidebar toggle — pinned to the far-right edge. */}
<button
onClick={onToggleRightSidebar}
className="flex-shrink-0 rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
title={rightSidebarOpen ? 'Collapse panel (I)' : 'Expand panel (I)'}
aria-label={rightSidebarOpen ? 'Collapse right panel' : 'Expand right panel'}
>
{selected && <Check className="h-2.5 w-2.5" strokeWidth={3} />}
</span>
<span className="min-w-0 flex-1 truncate">{tag.name}</span>
<span className="flex-shrink-0 tabular-nums text-[10px] text-text-muted">
{tag.photo_count}
</span>
</button>
{rightSidebarOpen ? (
<PanelRightClose className="h-3.5 w-3.5" />
) : (
<PanelRightOpen className="h-3.5 w-3.5" />
)}
</button>
</div>
)
}
/** Date range picker used inside the Date FilterPill. A single
* shadcn/react-day-picker Calendar in `range` mode — first click picks
* the start, second click picks the end. Writes both bounds to the
* store as yyyy-mm-dd ISO strings.
*
* Days without matching photos are disabled + visually dimmed
* ("booked days" pattern from shadcn docs), so the user can see at a
* glance which dates are worth clicking. The booked set is derived
* from the currently-cached photos — reflects every other active
* filter, which is the intended UX: "which days have 5★ photos of
* screenshots" etc. The caption uses the dropdown layout so the user
* can jump across months/years without clicking the arrows. */
function DateRangePicker({
from,
to,
onFromChange,
onToChange,
}: {
from: string | null
to: string | null
onFromChange: (v: string | null) => void
onToChange: (v: string | null) => void
}) {
// Unique yyyy-mm-dd keys of every cached photo's taken_at. Derived
// from the shared photos cache so opening the calendar doesn't fire
// another network request.
const { data: photos = [] } = usePhotosQuery()
const bookedSet = useMemo(() => {
const s = new Set<string>()
for (const p of photos) {
if (!p.taken_at) continue
const d = new Date(p.taken_at)
if (Number.isNaN(d.getTime())) continue
s.add(formatIsoDate(d))
}
return s
}, [photos])
const currentYear = new Date().getFullYear()
const earliestYear = useMemo(() => {
let min = currentYear
for (const k of bookedSet) {
const y = parseInt(k.slice(0, 4), 10)
if (!Number.isNaN(y) && y < min) min = y
}
return min
}, [bookedSet, currentYear])
const selected = {
from: from ? parseIsoDate(from) : undefined,
to: to ? parseIsoDate(to) : undefined,
}
const handleSelect = (range: { from?: Date; to?: Date } | undefined) => {
onFromChange(range?.from ? formatIsoDate(range.from) : null)
onToChange(range?.to ? formatIsoDate(range.to) : null)
}
const label =
from && to
? from === to
? from
: `${from}${to}`
: from
? `From ${from}`
: to
? `Until ${to}`
: 'Click to pick a start date, then an end date'
return (
<div className="space-y-2">
<div className="text-[11px] text-text-muted">{label}</div>
<Calendar
mode="range"
selected={selected}
onSelect={handleSelect}
numberOfMonths={1}
defaultMonth={selected.from ?? selected.to ?? new Date()}
captionLayout="dropdown"
fromYear={earliestYear}
toYear={currentYear + 1}
modifiers={{
booked: (d) => bookedSet.has(formatIsoDate(d)),
}}
modifiersClassNames={{
// Days that DO have matching photos get a small primary dot
// below the number; unmatched days stay visible and
// clickable so the user can still pick any bound they want.
booked:
'relative font-semibold text-text after:absolute after:bottom-0.5 after:left-1/2 after:h-1 after:w-1 after:-translate-x-1/2 after:rounded-full after:bg-primary after:content-[""]',
}}
className="rounded-md border border-border bg-bg p-2"
/>
</div>
)
}
function parseIsoDate(s: string): Date {
// yyyy-mm-dd → Date with local components so a user's "2024-01-15"
// never rolls back to 2024-01-14 in a pacific timezone.
const [y, m, d] = s.split('-').map(Number)
return new Date(y, (m ?? 1) - 1, d ?? 1)
}
function formatIsoDate(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}

View File

@@ -1,7 +1,10 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { ChevronDown, X } from 'lucide-react'
import clsx from 'clsx'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
interface FilterPillProps {
/** Category label, always shown ("Date", "Type", etc.). */
@@ -19,13 +22,18 @@ interface FilterPillProps {
children: React.ReactNode
/** Force the popover open programmatically (rare). */
defaultOpen?: boolean
/** Override the PopoverContent className — use when the child has its
* own padding (e.g. MultiSelect rows) and the default `p-3` doubles
* up. */
contentClassName?: string
}
/**
* A toolbar pill that hosts a filter category. Click the pill to open a
* Toolbar pill that hosts a filter category. Click the pill to open a
* small popover with the actual control; the popover closes on outside
* click or Escape. Active filters tint the pill primary and show their
* current value inline.
* click or Escape. Built on Radix Popover so positioning, focus
* management, portal rendering, and dismissal come for free — the
* pill only owns the trigger styling and the "clear" affordance.
*/
export function FilterPill({
label,
@@ -34,128 +42,58 @@ export function FilterPill({
onClear,
children,
defaultOpen = false,
contentClassName,
}: FilterPillProps) {
const [open, setOpen] = useState(defaultOpen)
const buttonRef = useRef<HTMLButtonElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const [popoverPos, setPopoverPos] = useState<{ top: number; left: number } | null>(null)
// Compute the popover's screen position from the trigger button. Done
// imperatively (not via CSS absolute) so the popover can live in a portal
// and escape the FilterBar's overflow-x-auto clipping. Re-computed on
// open, scroll, and resize.
useLayoutEffect(() => {
if (!open) return
const update = () => {
const btn = buttonRef.current
if (!btn) return
const rect = btn.getBoundingClientRect()
// Default left-align under the trigger; clamp to viewport so the
// last pill on the right doesn't overflow.
const popWidth = popoverRef.current?.offsetWidth ?? 240
const margin = 8
let left = rect.left
if (left + popWidth + margin > window.innerWidth) {
left = Math.max(margin, window.innerWidth - popWidth - margin)
}
setPopoverPos({ top: rect.bottom + 4, left })
}
update()
window.addEventListener('resize', update)
window.addEventListener('scroll', update, true)
return () => {
window.removeEventListener('resize', update)
window.removeEventListener('scroll', update, true)
}
}, [open])
// Close on outside click + Escape. Outside means neither the trigger
// button nor the (portaled) popover.
useEffect(() => {
if (!open) return
const onDocMouseDown = (e: MouseEvent) => {
const target = e.target as Node
if (buttonRef.current?.contains(target)) return
if (popoverRef.current?.contains(target)) return
setOpen(false)
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', onDocMouseDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDocMouseDown)
document.removeEventListener('keydown', onKey)
}
}, [open])
return (
<>
<button
ref={buttonRef}
onClick={() => setOpen((v) => !v)}
// Hover to see the active value as a tooltip — keeps the pill at
// a constant width regardless of state. The popover is the
// canonical place to read/edit the filter value.
title={isActive && value ? `${label}: ${value}` : label}
className={clsx(
// Fixed height + py-0 so neither the X clear icon nor the
// chevron can stretch the pill vertically when the active
// state swaps them in.
'flex h-7 items-center gap-1 rounded-full border px-2.5 py-0 text-xs transition-colors',
isActive
? 'border-primary/40 bg-primary/15 text-primary'
: 'border-border bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
<span className={clsx(isActive && 'font-medium')}>{label}</span>
{isActive && onClear ? (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation()
onClear()
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
<Popover defaultOpen={defaultOpen}>
<PopoverTrigger asChild>
<button
title={isActive && value ? `${label}: ${value}` : label}
className={clsx(
// Fixed height + py-0 so neither the X clear icon nor the
// chevron can stretch the pill vertically when the active
// state swaps them in.
'flex h-7 items-center gap-1 rounded-full border px-2.5 py-0 text-xs transition-colors',
isActive
? 'border-primary/40 bg-primary/15 text-primary'
: 'border-border bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
<span className={clsx(isActive && 'font-medium')}>{label}</span>
{isActive && onClear ? (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation()
onClear()
}
}}
// Same h-4 w-4 as the chevron slot below so swapping the
// two doesn't change the pill's footprint.
className="ml-0.5 inline-flex h-4 w-4 cursor-pointer items-center justify-center rounded-full hover:bg-primary/30"
title={`Clear ${label}`}
aria-label={`Clear ${label}`}
>
<X className="h-3 w-3" />
</span>
) : (
<span className="inline-flex h-4 w-4 items-center justify-center">
<ChevronDown className="h-3 w-3 opacity-60" />
</span>
)}
</button>
{open &&
createPortal(
<div
ref={popoverRef}
style={{
position: 'fixed',
top: popoverPos?.top ?? -9999,
left: popoverPos?.left ?? -9999,
visibility: popoverPos ? 'visible' : 'hidden',
}}
className="z-50 min-w-[220px] rounded-lg border border-border bg-surface p-3 shadow-xl"
>
{children}
</div>,
document.body
)}
</>
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
onClear()
}
}}
className="ml-0.5 inline-flex h-3.5 w-4 cursor-pointer items-center justify-center rounded-full hover:bg-primary/30"
title={`Clear ${label}`}
aria-label={`Clear ${label}`}
>
<X className="h-3 w-3" />
</span>
) : (
<span className="inline-flex h-3.5 w-4 items-center justify-center">
<ChevronDown className="h-3 w-3 opacity-60" />
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className={clsx('w-auto', contentClassName ?? 'p-2')}
>
{children}
</PopoverContent>
</Popover>
)
}

View File

@@ -92,8 +92,14 @@ export function ActiveHeapCard() {
}}
>
{visible.length === 0 ? (
<div className="flex h-full items-center justify-center px-2 text-center text-[11px] text-text-faint">
Pick photos with P to fill the heap
<div className="flex h-full items-center justify-center px-2 text-center">
<span className="rounded bg-black/70 px-2 py-1 text-[11px] font-medium text-white shadow-sm backdrop-blur-sm">
Select photos with{' '}
<kbd className="rounded bg-white/20 px-1 font-mono text-[10px] text-white">
S
</kbd>{' '}
to fill the heap
</span>
</div>
) : (
<div className="relative h-full">

View File

@@ -1,7 +1,6 @@
import { useState, useEffect, useMemo } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { X, Folder, AlertCircle } from 'lucide-react'
import clsx from 'clsx'
import { Folder, AlertCircle } from 'lucide-react'
import {
heaps as heapsApi,
type Heap,
@@ -10,6 +9,26 @@ import {
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
import { toast } from '../ToastContainer'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Checkbox } from '@/components/ui/checkbox'
import { Alert, AlertDescription } from '@/components/ui/alert'
interface FlatFolder {
id: string
@@ -37,11 +56,6 @@ interface HeapConvertDialogProps {
onClose: () => void
}
/**
* Modal that converts a heap into a folder. The user picks a target folder
* (any source root, today — sub-folder picking is a follow-up), chooses
* move vs copy semantics, and optionally has the heap deleted on success.
*/
export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
const queryClient = useQueryClient()
const [targetId, setTargetId] = useState('')
@@ -49,19 +63,15 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
const [deleteHeap, setDeleteHeap] = useState(false)
const [subfolderName, setSubfolderName] = useState('')
// Use the recursive folder tree, not the flat source-root list, so the
// user can pick a sub-folder at any depth as the target.
const { data: tree = [] } = useFolderTreeQuery()
const folders = useMemo<FlatFolder[]>(() => flattenTree(tree), [tree])
// Default to the first folder when the dialog opens or folders load.
useEffect(() => {
if (!targetId && folders.length > 0) {
setTargetId(folders[0].id)
}
}, [folders, targetId])
// Reset state on close, prefill subfolder name when opened.
useEffect(() => {
if (heap) {
setSubfolderName(heap.name)
@@ -79,8 +89,6 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
target_id: targetId,
mode,
delete_heap: deleteHeap,
// Empty subfolder = drop directly into the parent. Trim and only
// send if the user kept it populated.
subfolder_name: subfolderName.trim() || null,
}),
onSuccess: (data) => {
@@ -99,149 +107,130 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
toast.error('Convert failed', e?.response?.data?.detail || e.message),
})
if (!heap) return null
const targetFolder = folders.find((f) => f.id === targetId)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<Dialog open={!!heap} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Convert "{heap?.name}" to folder</DialogTitle>
</DialogHeader>
<div className="relative z-10 w-full max-w-md rounded-lg border border-border bg-surface p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-text">
Convert "{heap.name}" to folder
</h2>
<button
onClick={onClose}
disabled={convertMutation.isPending}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-4">
{/* Target picker */}
<div className="space-y-1.5">
<Label>Target folder</Label>
{folders.length === 0 ? (
<div className="rounded border border-border bg-bg px-3 py-2 text-xs text-text-muted">
No folders available
</div>
) : (
<Select value={targetId} onValueChange={setTargetId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{folders.map((f) => (
<SelectItem key={f.id} value={f.id}>
{'\u00A0\u00A0'.repeat(f.depth) + f.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{targetFolder && (
<p className="flex items-center gap-1 text-xs text-text-faint">
<Folder className="h-3 w-3" />
{targetFolder.path}
</p>
)}
</div>
{/* Target picker */}
<div className="mb-4">
<label className="mb-1 block text-xs text-text-muted">Target folder</label>
{folders.length === 0 ? (
<div className="rounded border border-border bg-bg px-3 py-2 text-xs text-text-muted">
No folders available
</div>
) : (
<select
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
className="w-full rounded border border-border bg-bg px-2 py-1.5 text-sm text-text focus:border-primary focus:outline-none"
>
{folders.map((f) => (
<option key={f.id} value={f.id}>
{/* Two non-breaking spaces per depth so nested
* subfolders read as a tree in the native dropdown. */}
{'\u00A0\u00A0'.repeat(f.depth) + f.name}
</option>
))}
</select>
)}
{targetFolder && (
<p className="mt-1 flex items-center gap-1 text-xs text-text-faint">
<Folder className="h-3 w-3" />
{targetFolder.path}
{/* Subfolder name */}
<div className="space-y-1.5">
<Label htmlFor="heap-convert-subfolder">Subfolder name</Label>
<Input
id="heap-convert-subfolder"
type="text"
value={subfolderName}
onChange={(e) => setSubfolderName(e.target.value)}
placeholder="(none — use parent directly)"
/>
<p className="text-xs text-text-faint">
{subfolderName.trim() && targetFolder
? `Will create ${targetFolder.path}/${subfolderName.trim()} if missing.`
: 'Photos go directly into the parent folder.'}
</p>
</div>
{/* Mode */}
<div className="space-y-1.5">
<Label>Mode</Label>
<RadioGroup
value={mode}
onValueChange={(v) => setMode(v as 'move' | 'copy')}
className="grid grid-cols-2 gap-2"
>
<Label
htmlFor="heap-convert-move"
className="flex cursor-pointer items-center gap-2 rounded-md border border-border bg-surface-2 px-3 py-1.5 text-sm text-text hover:bg-surface-offset has-[[data-state=checked]]:border-primary has-[[data-state=checked]]:bg-primary/15"
>
<RadioGroupItem id="heap-convert-move" value="move" />
Move
</Label>
<Label
htmlFor="heap-convert-copy"
className="flex cursor-pointer items-center gap-2 rounded-md border border-border bg-surface-2 px-3 py-1.5 text-sm text-text hover:bg-surface-offset has-[[data-state=checked]]:border-primary has-[[data-state=checked]]:bg-primary/15"
>
<RadioGroupItem id="heap-convert-copy" value="copy" />
Copy
</Label>
</RadioGroup>
<p className="text-xs text-text-faint">
{mode === 'move'
? 'Files are moved on disk; original photos update their folder.'
: 'Files are copied on disk; new photo records are created.'}
</p>
</div>
{/* Delete heap */}
<div className="flex items-center gap-2">
<Checkbox
id="delete-heap"
checked={deleteHeap}
onCheckedChange={(v) => setDeleteHeap(v === true)}
/>
<Label htmlFor="delete-heap" className="text-sm text-text">
Delete heap after conversion
</Label>
</div>
{convertMutation.isError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{(convertMutation.error as any)?.message || 'Conversion failed'}
</AlertDescription>
</Alert>
)}
</div>
{/* Subfolder name */}
<div className="mb-4">
<label className="mb-1 block text-xs text-text-muted">
Subfolder name
</label>
<input
type="text"
value={subfolderName}
onChange={(e) => setSubfolderName(e.target.value)}
placeholder="(none — use parent directly)"
className="w-full rounded border border-border bg-bg px-2 py-1.5 text-sm text-text placeholder-text-faint focus:border-primary focus:outline-none"
/>
<p className="mt-1 text-xs text-text-faint">
{subfolderName.trim() && targetFolder
? `Will create ${targetFolder.path}/${subfolderName.trim()} if missing.`
: 'Photos go directly into the parent folder.'}
</p>
</div>
{/* Mode toggle */}
<div className="mb-4">
<label className="mb-1 block text-xs text-text-muted">Mode</label>
<div className="flex gap-2">
<button
onClick={() => setMode('move')}
className={clsx(
'flex-1 rounded px-3 py-1.5 text-sm transition-colors',
mode === 'move'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
Move
</button>
<button
onClick={() => setMode('copy')}
className={clsx(
'flex-1 rounded px-3 py-1.5 text-sm transition-colors',
mode === 'copy'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
Copy
</button>
</div>
<p className="mt-1 text-xs text-text-faint">
{mode === 'move'
? 'Files are moved on disk; original photos update their folder.'
: 'Files are copied on disk; new photo records are created.'}
</p>
</div>
{/* Delete heap toggle */}
<div className="mb-4 flex items-center gap-2">
<input
id="delete-heap"
type="checkbox"
checked={deleteHeap}
onChange={(e) => setDeleteHeap(e.target.checked)}
className="h-4 w-4 rounded border-border bg-bg text-primary focus:ring-2 focus:ring-primary focus:ring-offset-0"
/>
<label htmlFor="delete-heap" className="text-sm text-text">
Delete heap after conversion
</label>
</div>
{convertMutation.isError && (
<div className="mb-3 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>{(convertMutation.error as any)?.message || 'Conversion failed'}</span>
</div>
)}
<div className="flex justify-end gap-2">
<button
<DialogFooter>
<Button
variant="outline"
onClick={onClose}
disabled={convertMutation.isPending}
className="rounded bg-surface-2 px-4 py-2 text-sm text-text hover:bg-surface-offset disabled:opacity-50"
>
Cancel
</button>
<button
</Button>
<Button
onClick={() => convertMutation.mutate()}
disabled={!targetId || convertMutation.isPending}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
>
{convertMutation.isPending ? 'Converting…' : 'Convert'}
</button>
</div>
</div>
</div>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useState } from 'react'
import {
ShoppingBasket,
Plus,
@@ -23,6 +23,15 @@ import { toast } from '../ToastContainer'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
import { HeapConvertDialog } from './HeapConvertDialog'
import { ShareDialog } from '../sharing/ShareDialog'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
/**
* Heaps panel for the left sidebar. Renders the list of heaps with the
@@ -53,28 +62,10 @@ export function HeapsPanel() {
// the draft name. Mirrors the folder rename pattern in LeftSidebar.
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameDraft, setRenameDraft] = useState('')
// Which heap's burger menu is currently open. null when no menu is open.
// The popover closes on outside click and Escape via the effect below.
// Which heap's burger menu is currently open. Drives the trigger's
// hover-visible state via DropdownMenu's open prop — Radix handles
// outside-click + Escape dismissal internally.
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!openMenuId) return
const onDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpenMenuId(null)
}
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpenMenuId(null)
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [openMenuId])
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
@@ -217,7 +208,7 @@ export function HeapsPanel() {
className="flex items-center gap-1 px-2 py-0.5"
style={{ paddingLeft: '20px' }}
>
<input
<Input
autoFocus
type="text"
value={newName}
@@ -230,15 +221,16 @@ export function HeapsPanel() {
}
}}
placeholder="Heap name"
className="flex-1 rounded border border-border bg-bg px-2 py-0.5 text-[11px] text-text focus:border-primary focus:outline-none"
className="h-6 flex-1 bg-bg px-2 text-[11px]"
/>
<button
<Button
onClick={handleCreate}
disabled={!newName.trim() || createMutation.isPending}
className="rounded bg-primary px-2 py-0.5 text-[11px] text-white hover:bg-primary/80 disabled:opacity-50"
size="sm"
className="h-6 px-2 text-[11px]"
>
Add
</button>
</Button>
</div>
)}
@@ -273,7 +265,7 @@ export function HeapsPanel() {
'group relative flex h-[24px] cursor-pointer items-center gap-1 rounded px-2 text-[12px] leading-none',
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
// Active-heap row gets a soft primary wash so the
// user always knows where Pick / T will land, even
// user always knows where Select / T will land, even
// when viewing a different section.
isActive && !isFiltered && 'bg-primary/8 text-text',
isDropTarget && 'ring-2 ring-primary bg-primary/10'
@@ -323,7 +315,7 @@ export function HeapsPanel() {
/>
{isRenaming ? (
<input
<Input
autoFocus
type="text"
value={renameDraft}
@@ -337,7 +329,7 @@ export function HeapsPanel() {
setRenamingId(null)
}
}}
className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none"
className="h-6 flex-1 bg-bg px-1 text-[13px]"
/>
) : (
<>
@@ -350,7 +342,7 @@ export function HeapsPanel() {
{isActive && (
<span
className="ml-1 flex-shrink-0 rounded-full bg-primary/25 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-primary"
title="Active heap — Pick (P) and the basket badge on photos point here"
title="Active heap — Select (S) and the basket badge on photos point here"
>
Active
</span>
@@ -389,93 +381,81 @@ export function HeapsPanel() {
{/* Kebab menu — collects rename / duplicate / convert /
* delete so the row stays compact. */}
<div
className={clsx(
'relative flex-shrink-0',
isMenuOpen ? 'block' : 'hidden group-hover:block'
)}
<DropdownMenu
open={isMenuOpen}
onOpenChange={(o) => setOpenMenuId(o ? heap.id : null)}
>
<button
onClick={(e) => {
e.stopPropagation()
setOpenMenuId(isMenuOpen ? null : heap.id)
}}
className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
title="More actions"
aria-label="More heap actions"
aria-haspopup="menu"
aria-expanded={isMenuOpen}
<div
className={clsx(
'relative flex-shrink-0',
isMenuOpen ? 'block' : 'hidden group-hover:block'
)}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
{isMenuOpen && (
<div
ref={menuRef}
role="menu"
className="absolute right-0 top-full z-30 mt-1 min-w-[160px] overflow-hidden rounded-lg border border-border bg-surface py-1 text-sm shadow-xl"
onClick={(e) => e.stopPropagation()}
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
title="More actions"
aria-label="More heap actions"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent
align="end"
className="min-w-[160px]"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem
onClick={() => {
setRenamingId(heap.id)
setRenameDraft(heap.name)
}}
>
<MenuItem
icon={<Pencil className="h-3.5 w-3.5" />}
label="Rename"
onClick={() => {
setOpenMenuId(null)
setRenamingId(heap.id)
setRenameDraft(heap.name)
}}
/>
<MenuItem
icon={<Copy className="h-3.5 w-3.5" />}
label="Duplicate"
onClick={() => {
setOpenMenuId(null)
duplicateMutation.mutate(heap.id)
}}
/>
<MenuItem
icon={<FolderOutput className="h-3.5 w-3.5" />}
label="Move to folder…"
onClick={() => {
setOpenMenuId(null)
setConvertingHeap(heap)
}}
/>
<MenuItem
icon={<Users className="h-3.5 w-3.5" />}
label="Share…"
onClick={() => {
setOpenMenuId(null)
setSharingHeap(heap)
}}
/>
<MenuItem
icon={<DownloadIcon className="h-3.5 w-3.5" />}
label="Download as zip"
onClick={() => {
setOpenMenuId(null)
downloads.trigger(downloads.heapUrl(heap.id))
}}
/>
<div className="my-1 h-px bg-border" />
<MenuItem
icon={<Trash2 className="h-3.5 w-3.5" />}
label="Delete"
destructive
onClick={() => {
setOpenMenuId(null)
if (
confirm(
`Delete heap "${heap.name}"? Photos are not affected.`
)
) {
deleteMutation.mutate(heap.id)
}
}}
/>
</div>
)}
</div>
<Pencil className="h-3.5 w-3.5 text-text-muted" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => duplicateMutation.mutate(heap.id)}
>
<Copy className="h-3.5 w-3.5 text-text-muted" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setConvertingHeap(heap)}>
<FolderOutput className="h-3.5 w-3.5 text-text-muted" />
Move to folder
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSharingHeap(heap)}>
<Users className="h-3.5 w-3.5 text-text-muted" />
Share
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
downloads.trigger(downloads.heapUrl(heap.id))
}
>
<DownloadIcon className="h-3.5 w-3.5 text-text-muted" />
Download as zip
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
if (
confirm(
`Delete heap "${heap.name}"? Photos are not affected.`
)
) {
deleteMutation.mutate(heap.id)
}
}}
className="text-reject focus:bg-reject/10 focus:text-reject"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
})}
@@ -543,30 +523,3 @@ export function HeapsPanel() {
)
}
function MenuItem({
icon,
label,
onClick,
destructive = false,
}: {
icon: React.ReactNode
label: string
onClick: () => void
destructive?: boolean
}) {
return (
<button
role="menuitem"
onClick={onClick}
className={clsx(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors',
destructive
? 'text-reject hover:bg-reject/10'
: 'text-text hover:bg-surface-2'
)}
>
<span className="text-text-muted">{icon}</span>
{label}
</button>
)
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useState } from 'react'
import {
ChevronRight,
ChevronDown,
@@ -15,7 +15,6 @@ import {
Layers2,
MoreHorizontal,
Pencil,
PanelLeftClose,
Settings,
Users,
Eye,
@@ -51,6 +50,15 @@ import { UploadModal } from '../upload/UploadModal'
import { useSharedFoldersQuery } from '../../hooks/useSharingQueries'
import { useAuth } from '../../contexts/AuthContext'
import { useFeaturesQuery } from '../../hooks/useFeaturesQuery'
import { useScanActivity } from '../../hooks/useScanActivity'
import { Input } from '@/components/ui/input'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
interface TreeItem {
id: string
@@ -62,14 +70,15 @@ interface TreeItem {
/** For folder rows only: the user-set "hide from views" flag. Drives
* the muted styling + eye-off badge + menu item label. */
isHidden?: boolean
/** For folder rows only: filesystem path. Used to match against the
* scan-status `current_folder` so we can show an inline spinner on
* the row that's actively being scanned. */
path?: string
}
interface LeftSidebarProps {
onCollapse: () => void
}
export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
export function LeftSidebar() {
const { user, isAdmin, logout } = useAuth()
const scanActivity = useScanActivity()
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
// Inline rename state for source-root rows. Stores the id being edited
// and the draft name. Double-click a folder row to start.
@@ -80,40 +89,18 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
const navigateToSection = useFilterStore((s) => s.navigateToSection)
const currentSection = useFilterStore((s) => s.currentSection)
const { data: allTags = [] } = useTagsQuery()
const { data: faceClusters = [] } = useTagsQuery('face_cluster')
const { data: stats } = useLibraryStatsQuery()
const { data: featuresMap } = useFeaturesQuery()
const visionOn = featuresMap ? featuresMap['vision.enabled'] !== false : true
const facesOn = visionOn && (featuresMap ? featuresMap['vision.faces.enabled'] !== false : true)
const tagsOn =
visionOn &&
(featuresMap
? featuresMap['vision.detector.enabled'] !== false ||
featuresMap['vision.classifier.enabled'] !== false
: true)
const tagsOn = true
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
// Per-folder kebab menu open state. Stores the tree-item id ("folder-..."
// or "folders" for the section header). Outside-click + Escape close.
// or "folders" for the section header). Radix DropdownMenu handles
// outside-click + Escape dismissal for us — we only track which row's
// menu is open so the trigger stays visible while it's open (the
// trigger is hover-hidden by default on non-hovered rows).
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!openMenuId) return
const onDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpenMenuId(null)
}
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpenMenuId(null)
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [openMenuId])
// "Create new folder under {parent}" inline state. parentId is the
// Folder.id (no "folder-" prefix).
@@ -295,8 +282,8 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
case 'tags':
navigateToSection('tags', { groupBy: 'tag' })
break
case 'people':
navigateToSection('people', { groupBy: 'tag' })
case 'needs-review':
navigateToSection('needs-review', { needsReview: true })
break
case 'colors':
navigateToSection('colors', { groupBy: 'color' })
@@ -417,6 +404,7 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
count: node.photo_count,
type: 'folder',
isHidden: node.is_hidden,
path: node.path,
children: node.children.length > 0
? node.children.map(folderNodeToTreeItem)
: undefined,
@@ -425,7 +413,6 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
// Total tag count for the badge on the Tags entry (user tags only).
const userTags = allTags.filter((t) => t.kind === 'user')
const tagsTotalCount = userTags.reduce((sum, t) => sum + (t.photo_count || 0), 0)
const peopleTotalCount = faceClusters.reduce((sum, t) => sum + (t.photo_count || 0), 0)
const libraryTree: TreeItem[] = [
{
@@ -436,7 +423,7 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: stats?.all_photos ?? 0 },
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: stats?.rated ?? 0 },
...(tagsOn ? [{ id: 'tags', label: 'Tags', icon: <TagIcon className="h-4 w-4" />, count: tagsTotalCount }] : []),
...(facesOn ? [{ id: 'people', label: 'People', icon: <Users className="h-4 w-4" />, count: peopleTotalCount }] : []),
...(visionOn ? [{ id: 'needs-review', label: 'Needs Review', icon: <Users className="h-4 w-4" />, count: stats?.needs_review ?? 0 }] : []),
{ id: 'colors', label: 'Colors', icon: <Palette className="h-4 w-4" />, count: stats?.colored ?? 0 },
{ id: 'map', label: 'Map', icon: <MapPin className="h-4 w-4" />, count: stats?.with_gps ?? 0 },
{ id: 'memories', label: 'Memories', icon: <Clock className="h-4 w-4" /> },
@@ -516,7 +503,7 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
style={
isSectionHeader
? undefined
: { paddingLeft: `${8 + (depth - 1) * 12}px` }
: { paddingLeft: `${depth * 20}px` }
}
onClick={() => {
if (renamingId === item.id) return
@@ -580,7 +567,7 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
)}
</button>
) : (
!isSectionHeader && <div className="w-3" />
!isSectionHeader && <div className="h-4 w-4 flex-shrink-0" />
)}
{/* Item Icon — section headers drop their icon in favor of the
@@ -606,7 +593,7 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
{/* Label (or inline rename input for folder rows) */}
{renamingId === item.id ? (
<input
<Input
autoFocus
type="text"
value={renameDraft}
@@ -627,7 +614,7 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
setRenamingId(null)
}
}}
className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none"
className="h-6 flex-1 bg-bg px-1 text-[13px]"
/>
) : (
<span
@@ -641,6 +628,26 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
</span>
)}
{/* Activity spinner — shown on the FOLDERS section header
* whenever any background scan/processing is happening, and
* on a folder row whose path is the one currently being
* scanned. Replaces the old bottom-right ScanProgress popup. */}
{(() => {
const showOnFolders =
isSectionHeader && item.id === 'folders' && scanActivity.active
const showOnFolderRow =
!!item.path &&
!!scanActivity.currentFolder &&
scanActivity.currentFolder.startsWith(item.path)
if (!showOnFolders && !showOnFolderRow) return null
return (
<span
className="ml-1 inline-block h-2.5 w-2.5 flex-shrink-0 animate-spin rounded-full border border-primary/30 border-t-primary"
aria-label="Background activity in progress"
/>
)
})()}
{/* Count Badge — fixed-width slot so counts line up in a column
* across rows regardless of digit count. Section headers skip
* the badge entirely (they're labels, not navigable rows). */}
@@ -664,114 +671,108 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
const folderId = item.id.slice('folder-'.length)
const isMenuOpen = openMenuId === item.id
return (
<div
className={clsx(
'relative flex-shrink-0',
isMenuOpen ? 'block' : 'hidden group-hover:block'
)}
<DropdownMenu
open={isMenuOpen}
onOpenChange={(o) => setOpenMenuId(o ? item.id : null)}
>
<button
onClick={(e) => {
e.stopPropagation()
setOpenMenuId(isMenuOpen ? null : item.id)
}}
className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
title="More actions"
aria-label="More folder actions"
aria-haspopup="menu"
aria-expanded={isMenuOpen}
<div
className={clsx(
'relative flex-shrink-0',
isMenuOpen ? 'block' : 'hidden group-hover:block'
)}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
{isMenuOpen && (
<div
ref={menuRef}
role="menu"
onClick={(e) => e.stopPropagation()}
className="absolute right-0 top-full z-30 mt-1 min-w-[180px] overflow-hidden rounded-lg border border-border bg-surface py-1 text-sm shadow-xl"
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
title="More actions"
aria-label="More folder actions"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent
align="end"
className="min-w-[180px]"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem
onClick={() =>
setUploadTarget({ open: true, folderId })
}
>
<FolderMenuItem
icon={<UploadIcon className="h-3.5 w-3.5" />}
label="Upload here…"
onClick={() => {
setOpenMenuId(null)
setUploadTarget({ open: true, folderId })
}}
/>
<FolderMenuItem
icon={<FolderPlus className="h-3.5 w-3.5" />}
label="New sub-folder"
onClick={() => {
setOpenMenuId(null)
setCreatingUnder(folderId)
setCreateDraft('')
// Make sure the parent is expanded so the new
// input is visible.
if (!expandedItems.has(item.id)) {
toggleExpanded(item.id)
}
}}
/>
<FolderMenuItem
icon={<Pencil className="h-3.5 w-3.5" />}
label="Rename"
onClick={() => {
setOpenMenuId(null)
setRenamingId(item.id)
setRenameDraft(item.label)
}}
/>
<FolderMenuItem
icon={
item.isHidden ? (
<Eye className="h-3.5 w-3.5" />
) : (
<EyeOff className="h-3.5 w-3.5" />
)
<UploadIcon className="h-3.5 w-3.5 text-text-muted" />
Upload here
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setCreatingUnder(folderId)
setCreateDraft('')
if (!expandedItems.has(item.id)) {
toggleExpanded(item.id)
}
label={item.isHidden ? 'Show in views' : 'Hide from views'}
onClick={() => {
setOpenMenuId(null)
toggleHiddenMutation.mutate({
id: folderId,
hidden: !item.isHidden,
})
}}
/>
<FolderMenuItem
icon={<Users className="h-3.5 w-3.5" />}
label="Share…"
onClick={() => {
setOpenMenuId(null)
setSharingFolder({ id: folderId, name: item.label })
}}
/>
<FolderMenuItem
icon={<DownloadIcon className="h-3.5 w-3.5" />}
label="Download as zip"
onClick={() => {
setOpenMenuId(null)
downloads.trigger(downloads.folderUrl(folderId))
}}
/>
<div className="my-1 h-px bg-border" />
<FolderMenuItem
icon={<Trash2 className="h-3.5 w-3.5" />}
label="Delete folder…"
destructive
onClick={() => {
setOpenMenuId(null)
setDeletingFolder({
id: folderId,
name: item.label,
photoCount: item.count,
})
}}
/>
</div>
)}
</div>
}}
>
<FolderPlus className="h-3.5 w-3.5 text-text-muted" />
New sub-folder
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setRenamingId(item.id)
setRenameDraft(item.label)
}}
>
<Pencil className="h-3.5 w-3.5 text-text-muted" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
toggleHiddenMutation.mutate({
id: folderId,
hidden: !item.isHidden,
})
}
>
{item.isHidden ? (
<Eye className="h-3.5 w-3.5 text-text-muted" />
) : (
<EyeOff className="h-3.5 w-3.5 text-text-muted" />
)}
{item.isHidden ? 'Show in views' : 'Hide from views'}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
setSharingFolder({ id: folderId, name: item.label })
}
>
<Users className="h-3.5 w-3.5 text-text-muted" />
Share
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
downloads.trigger(downloads.folderUrl(folderId))
}
>
<DownloadIcon className="h-3.5 w-3.5 text-text-muted" />
Download as zip
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
setDeletingFolder({
id: folderId,
name: item.label,
photoCount: item.count,
})
}
className="text-reject focus:bg-reject/10 focus:text-reject"
>
<Trash2 className="h-3.5 w-3.5" />
Delete folder
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
})()}
@@ -783,7 +784,7 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
creatingUnder === item.id.slice('folder-'.length) && (
<div
className="flex items-center gap-1 px-2 py-1"
style={{ paddingLeft: `${8 + (depth + 1) * 16 + 4}px` }}
style={{ paddingLeft: `${(depth + 1) * 20}px` }}
>
<FolderPlus className="h-3 w-3 flex-shrink-0 text-text-muted" />
<input
@@ -847,14 +848,6 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
>
<UploadIcon className="h-3.5 w-3.5" />
</button>
<button
onClick={onCollapse}
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Collapse panel (Tab)"
aria-label="Collapse panel"
>
<PanelLeftClose className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* Active heap card — pinned just below the Library header so
@@ -978,30 +971,3 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
)
}
function FolderMenuItem({
icon,
label,
onClick,
destructive = false,
}: {
icon: React.ReactNode
label: string
onClick: () => void
destructive?: boolean
}) {
return (
<button
role="menuitem"
onClick={onClick}
className={clsx(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors',
destructive
? 'text-reject hover:bg-reject/10'
: 'text-text hover:bg-surface-2'
)}
>
<span className="text-text-muted">{icon}</span>
{label}
</button>
)
}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { X, Star, ShoppingBasket, Trash2, Plus, PanelRightClose } from 'lucide-react'
import { X, Star, ShoppingBasket, Trash2, Plus } from 'lucide-react'
import clsx from 'clsx'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { format } from 'date-fns'
@@ -22,6 +22,9 @@ import { stripPhotosFromCache } from '../../hooks/usePhotosQuery'
import { toast } from '../ToastContainer'
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
/**
* Right-hand details panel.
@@ -29,11 +32,7 @@ import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
* - 2+ photos selected → renders a slim bulk-action panel that fans out
* rating / color / discard / pick across the entire selection.
*/
interface RightSidebarProps {
onCollapse: () => void
}
export function RightSidebar({ onCollapse }: RightSidebarProps) {
export function RightSidebar() {
const { selectedPhotos, activePhotoId, clearSelection } = usePhotoStore()
const queryClient = useQueryClient()
@@ -159,7 +158,7 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
const { data: allTags = [] } = useTagsQuery()
const [tagInput, setTagInput] = useState('')
// Active heap membership for the bulk Pick toggle.
// Active heap membership for the bulk Select toggle.
const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
const heapMutation = useMutation({
@@ -212,23 +211,17 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
</h2>
<div className="flex items-center gap-0.5">
{selectedPhotos.length > 0 && (
<button
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-text-muted"
onClick={clearSelection}
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear selection (Esc)"
aria-label="Clear selection"
>
<X className="h-3.5 w-3.5" />
</button>
</Button>
)}
<button
onClick={onCollapse}
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Collapse panel (I)"
aria-label="Collapse panel"
>
<PanelRightClose className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
@@ -317,7 +310,7 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
{/* Bulk rating */}
<div>
<label className="mb-1 block text-xs text-text-muted">Rating</label>
<Label className="mb-1 block">Rating</Label>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((value) => (
<button
@@ -345,7 +338,7 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
{/* Bulk color */}
<div>
<label className="mb-1 block text-xs text-text-muted">Color label</label>
<Label className="mb-1 block">Color label</Label>
<div className="flex items-center gap-1.5">
{COLOR_LABEL_OPTIONS.map(({ value, className }) => (
<button
@@ -374,18 +367,18 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
{/* Bulk flag */}
<div>
<label className="mb-1 block text-xs text-text-muted">Flag</label>
<Label className="mb-1 block">Flag</Label>
<div className="flex gap-2">
<button
<Button
size="sm"
onClick={() => {
if (!activeHeap) return
heapMutation.mutate({ ids: selectedPhotos, remove: allMembers })
}}
disabled={!activeHeap || heapMutation.isPending}
className={clsx(
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
allMembers
? 'bg-pick/20 text-pick'
? 'bg-pick/20 text-pick hover:bg-pick/30'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
)}
title={
@@ -396,16 +389,18 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
: 'Set an active heap first'
}
>
<ShoppingBasket className="h-3 w-3" />
{allMembers ? 'Picked' : 'Pick'}
</button>
<button
<ShoppingBasket className="mr-1 h-3 w-3" />
{allMembers ? 'Selected' : 'Select'}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => bulkDiscardMutation.mutate(selectedPhotos)}
className="flex items-center gap-1 rounded bg-surface-2 px-2 py-1 text-sm text-text-muted transition-colors hover:bg-surface-offset"
className="text-text-muted"
>
<Trash2 className="h-3 w-3" />
<Trash2 className="mr-1 h-3 w-3" />
Discard
</button>
</Button>
</div>
</div>
@@ -414,7 +409,7 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
* input adds an existing tag if it matches a name, or creates
* a new tag and applies it. */}
<div>
<label className="mb-1 block text-xs text-text-muted">Tags</label>
<Label className="mb-1 block">Tags</Label>
<BulkTagsEditor
allTags={allTags}
tagInput={tagInput}
@@ -447,7 +442,7 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
* clock (1970 epoch) and for legacy libraries where the folder
* structure is the only trustworthy date signal. */}
<div>
<label className="mb-1 block text-xs text-text-muted">Date Taken</label>
<Label className="mb-1 block">Date Taken</Label>
<BulkTakenAtEditor
disabled={
bulkTakenAtMutation.isPending || bulkTakenAtMapMutation.isPending
@@ -530,33 +525,36 @@ function BulkTakenAtEditor({
<div className="space-y-2">
{/* Apply-one row */}
<div className="flex items-center gap-1.5">
<input
<Input
type="datetime-local"
value={uniformDraft}
onChange={(e) => setUniformDraft(e.target.value)}
disabled={disabled}
className="flex-1 rounded border border-border bg-bg px-2 py-1 text-xs text-text focus:border-primary focus:outline-none disabled:opacity-50"
className="h-7 flex-1 text-xs"
/>
<button
<Button
size="sm"
onClick={handleApplyUniform}
disabled={disabled || !uniformDraft}
className="rounded bg-primary/20 px-2 py-1 text-xs text-primary hover:bg-primary/30 disabled:cursor-not-allowed disabled:opacity-40"
className="bg-primary/20 text-primary hover:bg-primary/30"
title={`Apply this date to all ${selectedCount} selected`}
>
Apply
</button>
</Button>
</div>
{/* Guess-from-path preview */}
{preview === null ? (
<button
<Button
variant="outline"
size="sm"
onClick={handleGuess}
disabled={disabled}
className="flex w-full items-center justify-center gap-1 rounded border border-dashed border-primary/50 px-2 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
className="w-full border-dashed border-primary/50 bg-transparent text-primary hover:bg-primary/10"
title="Scan each photo's folder + filename for a date pattern"
>
Guess from folder paths
</button>
</Button>
) : (
<div className="rounded border border-border bg-bg p-2 text-[11px]">
<div className="mb-1.5 text-text-muted">
@@ -582,20 +580,23 @@ function BulkTakenAtEditor({
</ul>
)}
<div className="flex gap-1.5">
<button
<Button
size="sm"
onClick={handleApplyPreview}
disabled={disabled || preview.hits.length === 0}
className="flex-1 rounded bg-primary/20 px-2 py-1 text-xs text-primary hover:bg-primary/30 disabled:cursor-not-allowed disabled:opacity-40"
className="flex-1 bg-primary/20 text-primary hover:bg-primary/30"
>
Apply {preview.hits.length}
</button>
<button
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setPreview(null)}
disabled={disabled}
className="rounded bg-surface-2 px-2 py-1 text-xs text-text-muted hover:bg-surface-offset disabled:opacity-50"
className="text-text-muted"
>
Cancel
</button>
</Button>
</div>
</div>
)}
@@ -652,7 +653,7 @@ function BulkTagsEditor({
return (
<div className="space-y-2">
<input
<Input
type="text"
value={tagInput}
onChange={(e) => onTagInputChange(e.target.value)}
@@ -666,18 +667,20 @@ function BulkTagsEditor({
}}
placeholder="Filter or create…"
disabled={disabled}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text placeholder-text-faint focus:border-primary focus:outline-none disabled:opacity-50"
className="h-7 text-xs"
/>
{trimmed && !exactMatch && (
<button
<Button
variant="outline"
size="sm"
onClick={handleSubmit}
disabled={disabled}
className="flex w-full items-center justify-center gap-1 rounded border border-dashed border-primary/50 px-2 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
className="w-full border-dashed border-primary/50 bg-transparent text-primary hover:bg-primary/10"
>
<Plus className="h-3 w-3" />
<Plus className="mr-1 h-3 w-3" />
Create "{trimmed}" and apply
</button>
</Button>
)}
{filtered.length > 0 ? (

View File

@@ -1,14 +1,6 @@
import { PanelLeftOpen, PanelRightOpen } from 'lucide-react'
import desertBg from '../../assets/desert.png'
import muleSprites from '../../assets/mule-sprites.png'
interface TopBarProps {
leftSidebarOpen: boolean
rightSidebarOpen: boolean
onExpandLeft: () => void
onExpandRight: () => void
}
// Block-character ASCII rendering of "Mulimago" — sits on a black plate
// in place of the old text title.
const MULIMAGO_ASCII = `▖ ▖ ▜ ▘
@@ -34,22 +26,9 @@ function toRoman(n: number): string {
/**
* Slim top bar — animated walking mule on the left over a tiled desert
* backdrop, settings gear on the right. The active heap badge moved into
* the Heaps panel in the left sidebar (where it actually relates to the
* heap rows the user navigates to).
*
* Also hosts the "expand sidebar" affordances: when a side panel is
* collapsed, a small panel-open icon appears on the corresponding edge
* so the user has a way to bring it back without hunting for the
* keyboard shortcut. When the panel is open, the button hides — its
* collapse twin lives in the panel's own header.
* backdrop. Sidebar toggle buttons live on the FilterBar.
*/
export function TopBar({
leftSidebarOpen,
rightSidebarOpen,
onExpandLeft,
onExpandRight,
}: TopBarProps) {
export function TopBar() {
return (
<header
className="relative flex h-16 items-center justify-between overflow-hidden border-b border-border px-4"
@@ -67,16 +46,6 @@ export function TopBar({
}}
>
<div className="relative flex items-center gap-3">
{!leftSidebarOpen && (
<button
onClick={onExpandLeft}
className="rounded bg-black/30 p-1.5 text-text-muted backdrop-blur-sm transition-colors hover:bg-black/50 hover:text-text"
title="Expand panel (Tab)"
aria-label="Expand left panel"
>
<PanelLeftOpen className="h-4 w-4" />
</button>
)}
<div
aria-label="Mulimago"
className="h-12 w-14"
@@ -100,16 +69,6 @@ export function TopBar({
<span className="text-[10px] font-serif text-black/80">
Built with hubris {toRoman(new Date().getFullYear())}
</span>
{!rightSidebarOpen && (
<button
onClick={onExpandRight}
className="rounded bg-black/30 p-1.5 text-text-muted backdrop-blur-sm transition-colors hover:bg-black/50 hover:text-text"
title="Expand panel (I)"
aria-label="Expand right panel"
>
<PanelRightOpen className="h-4 w-4" />
</button>
)}
</div>
</header>
)

View File

@@ -1,241 +0,0 @@
import { useState, useCallback, useMemo } from 'react'
import { Users, Pencil, Check, X, Loader2, ArrowLeft } from 'lucide-react'
import clsx from 'clsx'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useTagsQuery } from '../../hooks/useTagsQuery'
import {
tags as tagsApi,
photos as photosApi,
type Tag,
} from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
import { useCardGridNav } from '../../hooks/useCardGridNav'
import { Timeline } from '../timeline/Timeline'
import { toast } from '../ToastContainer'
/**
* People view — two states:
* 1. Grid of face cluster cards (default) — arrow keys + Enter to browse
* 2. Detail view showing a person's photos in the full Timeline — Esc to go back
*/
export function PeopleView() {
const { data: rawClusters = [], isLoading } = useTagsQuery('face_cluster')
const clusters = useMemo(
() => [...rawClusters].sort((a, b) => b.photo_count - a.photo_count),
[rawClusters]
)
const queryClient = useQueryClient()
const setTagIds = useFilterStore((s) => s.setTagIds)
const [selectedPerson, setSelectedPerson] = useState<Tag | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const [editName, setEditName] = useState('')
const renameMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) =>
tagsApi.update(id, { name }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tags'] })
setEditingId(null)
if (selectedPerson && editingId === selectedPerson.id) {
setSelectedPerson({ ...selectedPerson, name: editName.trim() })
}
toast.success('Renamed')
},
onError: (e: any) =>
toast.error('Rename failed', e?.response?.data?.detail || e.message),
})
const startEditing = (tag: Tag) => {
setEditingId(tag.id)
setEditName(tag.name)
}
const submitRename = () => {
if (!editingId || !editName.trim()) return
renameMutation.mutate({ id: editingId, name: editName.trim() })
}
const enterDetail = useCallback(
(person: Tag) => {
setTagIds([person.id])
setSelectedPerson(person)
},
[setTagIds]
)
const exitDetail = useCallback(() => {
setTagIds([])
setSelectedPerson(null)
}, [setTagIds])
const { activeIndex, gridRef } = useCardGridNav({
items: clusters,
inDetail: selectedPerson !== null,
onEnter: enterDetail,
onExit: exitDetail,
})
// ── Detail view: a person's photos ─────────────────────────────────
if (selectedPerson) {
const isEditing = editingId === selectedPerson.id
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<button
onClick={exitDetail}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
title="Back to people"
>
<ArrowLeft className="h-4 w-4" />
</button>
{isEditing ? (
<div className="flex items-center gap-1.5">
<input
autoFocus
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') submitRename()
if (e.key === 'Escape') setEditingId(null)
}}
className="rounded border border-border bg-bg px-2 py-1 text-sm text-text focus:border-primary focus:outline-none"
/>
<button onClick={submitRename} className="rounded p-1 text-green-500 hover:bg-green-500/10">
<Check className="h-4 w-4" />
</button>
<button onClick={() => setEditingId(null)} className="rounded p-1 text-text-muted hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
) : (
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-text">{selectedPerson.name}</h2>
<button
onClick={() => startEditing(selectedPerson)}
className="rounded p-0.5 text-text-muted transition-colors hover:text-text"
title="Rename"
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
<div className="flex-1 overflow-hidden">
<Timeline />
</div>
</div>
)
}
// ── Card grid ──────────────────────────────────────────────────────
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-text-muted">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Loading people...
</div>
)
}
if (clusters.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
<Users className="h-12 w-12 opacity-40" />
<p className="text-sm">No people identified yet</p>
<p className="max-w-xs text-center text-xs opacity-70">
Face detection runs automatically when photos are scanned.
People will appear here once faces are found and clustered.
</p>
</div>
)
}
return (
<div className="h-full overflow-auto p-4 pb-20">
<div className="mb-4 flex items-center gap-2 text-text-muted">
<Users className="h-4 w-4" />
<span className="text-sm font-medium">
{clusters.length} {clusters.length === 1 ? 'person' : 'people'} identified
</span>
</div>
<div
ref={gridRef}
className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3"
>
{clusters.map((tag, i) => (
<div
key={tag.id}
className={clsx(
'group cursor-pointer overflow-hidden rounded-lg border bg-surface transition-all hover:border-primary/50 hover:shadow-md',
i === activeIndex
? 'border-primary ring-1 ring-primary/30'
: editingId === tag.id
? 'border-primary ring-1 ring-primary/30'
: 'border-border'
)}
onClick={() => {
if (editingId !== tag.id) enterDetail(tag)
}}
>
<div className="relative aspect-square overflow-hidden bg-surface-2">
{tag.representative_photo_id ? (
<img
src={photosApi.getThumbnailUrl(tag.representative_photo_id, 'small')}
alt={tag.name}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<Users className="h-10 w-10 text-text-muted/30" />
</div>
)}
<span className="absolute bottom-1.5 right-1.5 rounded-full bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white backdrop-blur-sm">
{tag.photo_count}
</span>
<button
className="absolute right-1.5 top-1.5 rounded-full bg-black/50 p-1 text-white opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation()
startEditing(tag)
}}
title="Rename"
>
<Pencil className="h-3 w-3" />
</button>
</div>
<div className="px-2 py-1.5">
{editingId === tag.id ? (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<input
autoFocus
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') submitRename()
if (e.key === 'Escape') setEditingId(null)
}}
className="min-w-0 flex-1 rounded border border-border bg-bg px-1.5 py-0.5 text-xs text-text focus:border-primary focus:outline-none"
/>
<button onClick={submitRename} className="rounded p-0.5 text-green-500 hover:bg-green-500/10">
<Check className="h-3 w-3" />
</button>
<button onClick={() => setEditingId(null)} className="rounded p-0.5 text-text-muted hover:bg-surface-2">
<X className="h-3 w-3" />
</button>
</div>
) : (
<p className="truncate text-xs font-medium text-text">{tag.name}</p>
)}
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import type { Photo } from '../../types/photo'
import { photos as photosApi } from '../../services/api'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
interface PreviewFilmstripProps {
photos: Photo[]
@@ -13,6 +14,7 @@ const CELL_SIZE = 72
export function PreviewFilmstrip({ photos, currentIndex, onSelect }: PreviewFilmstripProps) {
const activeRef = useRef<HTMLButtonElement>(null)
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
useEffect(() => {
activeRef.current?.scrollIntoView({
@@ -26,22 +28,28 @@ export function PreviewFilmstrip({ photos, currentIndex, onSelect }: PreviewFilm
<div className="flex h-24 shrink-0 items-center gap-1 overflow-x-auto border-t border-border bg-surface px-2 py-2">
{photos.map((photo, index) => {
const isActive = index === currentIndex
const isInActiveHeap = activeHeapMembers.has(photo.id)
return (
<button
key={photo.id}
ref={isActive ? activeRef : null}
onClick={() => onSelect(photo.id)}
className={clsx(
'relative shrink-0 overflow-hidden rounded-sm transition-all',
'hover:opacity-100',
isActive
? 'ring-2 ring-primary opacity-100'
: 'opacity-60'
'relative shrink-0 overflow-hidden rounded-sm will-change-transform transition-[transform,box-shadow,opacity] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)]',
isActive && 'scale-90 opacity-100 ring-2 ring-blue-500 ring-offset-2 ring-offset-surface',
!isActive && isInActiveHeap && 'opacity-100',
!isActive && !isInActiveHeap && 'opacity-60 hover:opacity-100 hover:ring-1 hover:ring-text-muted/70'
)}
style={{ width: CELL_SIZE, height: CELL_SIZE }}
title={photo.filename}
>
<FilmstripThumb photo={photo} />
{isInActiveHeap && (
<div className="pointer-events-none absolute inset-0 bg-emerald-500/40" />
)}
{isActive && (
<div className="pointer-events-none absolute inset-0 bg-blue-500/50" />
)}
</button>
)
})}
@@ -77,7 +85,10 @@ function FilmstripThumb({ photo }: { photo: Photo }) {
onError={() => setErrored(true)}
className={clsx(
'h-full w-full object-cover transition-opacity duration-200',
loaded ? 'opacity-100' : 'opacity-0'
loaded ? 'opacity-100' : 'opacity-0',
// Match the grid's discarded styling so the filmstrip mirrors
// what the user sees behind the preview.
photo.is_discarded && 'opacity-50 grayscale'
)}
/>
</>

View File

@@ -24,15 +24,19 @@ export function PreviewImage({ photo }: PreviewImageProps) {
}
function PreviewVideo({ photo }: { photo: Photo }) {
// min-h-0 + overflow-hidden are needed so a portrait/vertical video
// doesn't push past the column's allotted height and shove the
// filmstrip off-screen — flex items default to min-height:auto, which
// makes intrinsically-tall content overflow.
return (
<div className="flex flex-1 items-center justify-center bg-black">
<div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black">
<video
key={photo.id}
src={getVideoSrc(photo)}
controls
autoPlay
muted
className="max-h-full max-w-full"
className="max-h-full max-w-full object-contain"
/>
</div>
)

View File

@@ -10,6 +10,9 @@ import { PreviewImage } from './PreviewImage'
import { PreviewFilmstrip } from './PreviewFilmstrip'
import { getPreviewImageSrc, isVideo } from './previewSrc'
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
import { KeyboardHints } from '../KeyboardHints'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
export function PreviewView() {
const activePhotoId = usePhotoStore((s) => s.activePhotoId)
@@ -18,25 +21,17 @@ export function PreviewView() {
const containerRef = useRef<HTMLDivElement>(null)
const previouslyFocusedRef = useRef<HTMLElement | null>(null)
const [infoPanelOpen, setInfoPanelOpen] = useState(false)
const [infoPanelOpen, setInfoPanelOpen] = useState(true)
// Snapshot the photo we opened on, captured once at mount via the
// store's getState (which is guaranteed to reflect the value the
// openPreview action just wrote, even if the React subscription
// hasn't been delivered to this component's first render yet). This
// is the id we'll restore on close, no matter how many neighbours
// the user arrows through inside the preview.
// Track the photo we OPENED preview on as a defensive fallback only.
// On close we restore the LAST viewed photo so the grid focus matches
// what the user just saw (filmstrip/arrow nav can land them anywhere).
const openOriginRef = useRef<string | null>(
activePhotoId ?? usePhotoStore.getState().activePhotoId
)
const closePreview = useCallback(() => {
// Bypass the store action and write the restoration directly so
// the snapshot ref is the single source of truth. Falls back to
// the live activePhotoId if the ref was somehow never populated
// (defensive — openPreview always sets activePhotoId before
// PreviewView mounts).
const id =
openOriginRef.current ?? usePhotoStore.getState().activePhotoId
usePhotoStore.getState().activePhotoId ?? openOriginRef.current
usePhotoStore.setState({
viewMode: 'grid',
activePhotoId: id,
@@ -202,12 +197,9 @@ export function PreviewView() {
className="fixed inset-0 z-[1000] flex flex-col items-center justify-center bg-black text-text-muted outline-none"
>
<div>No photo to display</div>
<button
onClick={closePreview}
className="mt-4 rounded border border-border px-3 py-1 text-sm hover:bg-surface"
>
<Button variant="outline" size="sm" className="mt-4" onClick={closePreview}>
Close
</button>
</Button>
</div>
)
}
@@ -234,30 +226,39 @@ export function PreviewView() {
{/* Top-right action buttons */}
<div className="absolute right-3 top-3 z-10 flex items-center gap-2">
<button
<Button
size="icon"
onClick={() => setInfoPanelOpen((v) => !v)}
className={
'flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white transition hover:bg-black/80 ' +
(infoPanelOpen ? 'ring-2 ring-primary' : '')
}
className={cn(
'h-9 w-9 rounded-full bg-black/60 text-white hover:bg-black/80',
infoPanelOpen && 'ring-2 ring-primary'
)}
title="Toggle info panel (I)"
aria-label="Toggle info panel"
aria-pressed={infoPanelOpen}
>
<Info className="h-5 w-5" />
</button>
<button
</Button>
<Button
size="icon"
onClick={closePreview}
className="flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white transition hover:bg-black/80"
className="h-9 w-9 rounded-full bg-black/60 text-white hover:bg-black/80"
title="Close (Esc)"
aria-label="Close preview"
>
<X className="h-5 w-5" />
</button>
</Button>
</div>
<PreviewImage photo={currentPhoto} />
{/* Shortcut hints — sits above the filmstrip and centers to the
* image column (not the viewport), so the optional info panel
* on the right doesn't push it off-axis. */}
<div className="pointer-events-none absolute inset-x-0 bottom-24 z-10">
<KeyboardHints />
</div>
<PreviewFilmstrip
photos={photos}
currentIndex={safeIndex}

View File

@@ -1,6 +1,7 @@
import { useState, useMemo, useCallback } from 'react'
import { Star, ArrowLeft, Loader2 } from 'lucide-react'
import clsx from 'clsx'
import { Button } from '@/components/ui/button'
import { photos as photosApi } from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
@@ -80,13 +81,15 @@ export function RatedView() {
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<button
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-text-muted"
onClick={exitDetail}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
title="Back to ratings"
>
<ArrowLeft className="h-4 w-4" />
</button>
</Button>
<h2 className="text-sm font-semibold text-amber-400">{selectedGroup.label}</h2>
</div>
<div className="flex-1 overflow-hidden">

View File

@@ -1,9 +1,27 @@
import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Users, Trash2, X } from 'lucide-react'
import clsx from 'clsx'
import { Users, Trash2 } from 'lucide-react'
import { sharing, type ShareInfo } from '../../services/api'
import { SHARED_HEAPS_KEY, SHARED_FOLDERS_KEY } from '../../hooks/useSharingQueries'
import {
SHARED_HEAPS_KEY,
SHARED_FOLDERS_KEY,
} from '../../hooks/useSharingQueries'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Alert, AlertDescription } from '@/components/ui/alert'
interface ShareDialogProps {
isOpen: boolean
@@ -13,7 +31,13 @@ interface ShareDialogProps {
onClose: () => void
}
export function ShareDialog({ isOpen, type, targetId, targetName, onClose }: ShareDialogProps) {
export function ShareDialog({
isOpen,
type,
targetId,
targetName,
onClose,
}: ShareDialogProps) {
const [username, setUsername] = useState('')
const [permission, setPermission] = useState<'read' | 'write'>('read')
const [error, setError] = useState<string | null>(null)
@@ -40,7 +64,9 @@ export function ShareDialog({ isOpen, type, targetId, targetName, onClose }: Sha
setPermission('read')
setError(null)
queryClient.invalidateQueries({ queryKey: sharesQueryKey })
queryClient.invalidateQueries({ queryKey: type === 'heap' ? SHARED_HEAPS_KEY : SHARED_FOLDERS_KEY })
queryClient.invalidateQueries({
queryKey: type === 'heap' ? SHARED_HEAPS_KEY : SHARED_FOLDERS_KEY,
})
},
onError: (err: any) => {
setError(err?.response?.data?.detail || 'Failed to share')
@@ -54,19 +80,12 @@ export function ShareDialog({ isOpen, type, targetId, targetName, onClose }: Sha
: sharing.revokeFolderShare(targetId, shareId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: sharesQueryKey })
queryClient.invalidateQueries({ queryKey: type === 'heap' ? SHARED_HEAPS_KEY : SHARED_FOLDERS_KEY })
queryClient.invalidateQueries({
queryKey: type === 'heap' ? SHARED_HEAPS_KEY : SHARED_FOLDERS_KEY,
})
},
})
useEffect(() => {
if (!isOpen) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [isOpen, onClose])
useEffect(() => {
if (isOpen) {
setUsername('')
@@ -75,103 +94,102 @@ export function ShareDialog({ isOpen, type, targetId, targetName, onClose }: Sha
}
}, [isOpen])
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="relative z-10 w-[420px] rounded-lg border border-border bg-surface p-5 shadow-2xl">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-text-muted" />
<h2 className="text-base font-semibold text-text">
Share {type === 'heap' ? 'heap' : 'folder'}
</h2>
</div>
<button onClick={onClose} className="text-text-muted hover:text-text">
<X className="h-4 w-4" />
</button>
</div>
<Dialog open={isOpen} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-[420px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Users className="h-4 w-4 text-text-muted" />
Share {type === 'heap' ? 'heap' : 'folder'}
</DialogTitle>
</DialogHeader>
<div className="mb-3 text-sm text-text-muted">
Sharing <span className="font-medium text-text">{targetName}</span>
</div>
{/* Existing shares */}
{shares.length > 0 && (
<div className="mb-4 space-y-1.5">
{shares.map((share) => (
<div
key={share.id}
className="flex items-center justify-between rounded border border-border bg-surface-2 px-3 py-1.5 text-sm"
>
<div className="flex items-center gap-2">
<span className="font-medium text-text">{share.shared_with_username}</span>
<span className="rounded bg-surface px-1.5 py-0.5 text-[10px] font-medium uppercase text-text-muted">
{share.permission}
</span>
</div>
<button
onClick={() => revokeMutation.mutate(share.id)}
className="text-text-muted hover:text-reject"
title="Revoke access"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
{isLoading && <div className="mb-4 text-xs text-text-muted">Loading shares...</div>}
{/* Add new share */}
<form
onSubmit={(e) => {
e.preventDefault()
if (username.trim()) addMutation.mutate()
}}
className="space-y-3"
>
<div className="flex gap-2">
<input
type="text"
value={username}
onChange={(e) => {
setUsername(e.target.value)
setError(null)
}}
placeholder="Username"
className="flex-1 rounded border border-border bg-surface-2 px-3 py-1.5 text-sm text-text placeholder:text-text-muted focus:border-primary focus:outline-none"
autoFocus
/>
<select
value={permission}
onChange={(e) => setPermission(e.target.value as 'read' | 'write')}
className="rounded border border-border bg-surface-2 px-2 py-1.5 text-sm text-text"
>
<option value="read">Read</option>
<option value="write">Read + Write</option>
</select>
</div>
{error && <div className="text-xs text-reject">{error}</div>}
<div className="flex justify-end">
<button
type="submit"
disabled={!username.trim() || addMutation.isPending}
className={clsx(
'rounded px-3 py-1.5 text-sm font-medium text-white',
'bg-primary hover:bg-primary/80 disabled:opacity-50'
)}
>
{addMutation.isPending ? 'Sharing...' : 'Share'}
</button>
</div>
</form>
<div className="text-sm text-text-muted">
Sharing <span className="font-medium text-text">{targetName}</span>
</div>
</div>
</div>
{/* Existing shares */}
{shares.length > 0 && (
<div className="space-y-1.5">
{shares.map((share) => (
<div
key={share.id}
className="flex items-center justify-between rounded border border-border bg-surface-2 px-3 py-1.5 text-sm"
>
<div className="flex items-center gap-2">
<span className="font-medium text-text">
{share.shared_with_username}
</span>
<span className="rounded bg-surface px-1.5 py-0.5 text-[10px] font-medium uppercase text-text-muted">
{share.permission}
</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => revokeMutation.mutate(share.id)}
className="h-6 w-6 text-text-muted hover:text-reject"
title="Revoke access"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
{isLoading && (
<div className="text-xs text-text-muted">Loading shares...</div>
)}
<form
onSubmit={(e) => {
e.preventDefault()
if (username.trim()) addMutation.mutate()
}}
className="space-y-3"
>
<div className="flex gap-2">
<Input
type="text"
value={username}
onChange={(e) => {
setUsername(e.target.value)
setError(null)
}}
placeholder="Username"
className="flex-1"
autoFocus
/>
<Select
value={permission}
onValueChange={(v) => setPermission(v as 'read' | 'write')}
>
<SelectTrigger className="w-auto">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="read">Read</SelectItem>
<SelectItem value="write">Read + Write</SelectItem>
</SelectContent>
</Select>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex justify-end">
<Button
type="submit"
disabled={!username.trim() || addMutation.isPending}
>
{addMutation.isPending ? 'Sharing...' : 'Share'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -29,6 +29,13 @@ import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
import { toast } from '../ToastContainer'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
COLOR_LABEL_OPTIONS,
type ColorLabel,
@@ -386,7 +393,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
<div className="space-y-2.5 border-b border-border p-3">
<div>
<label className="mb-1 block text-xs text-text-muted">Filename</label>
<input
<Input
type="text"
value={filenameDraft}
onChange={(e) => setFilenameDraft(e.target.value)}
@@ -405,7 +412,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
<div>
<label className="mb-1 block text-xs text-text-muted">Title</label>
<input
<Input
type="text"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
@@ -425,7 +432,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
<div>
<label className="mb-1 block text-xs text-text-muted">Notes</label>
<textarea
<Textarea
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
onBlur={commitNotes}
@@ -494,7 +501,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
</div>
</div>
{/* Flag — Pick + Discard */}
{/* Flag — Select + Discard */}
<div>
<label className="mb-1 block text-xs text-text-muted">Flag</label>
<div className="flex gap-2">
@@ -519,7 +526,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
}
>
<ShoppingBasket className="h-3 w-3" />
{isInActiveHeap ? 'Picked' : 'Pick'}
{isInActiveHeap ? 'Selected' : 'Select'}
</button>
<button
onClick={() => updateMutation.mutate({ is_discarded: !isDiscarded })}
@@ -680,20 +687,23 @@ function Section({
children: React.ReactNode
}) {
return (
<div className="border-b border-border">
<button
onClick={onToggle}
className="flex w-full items-center justify-between px-3 py-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:bg-surface-2 hover:text-text"
>
<Collapsible
open={expanded}
onOpenChange={onToggle}
className="border-b border-border"
>
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:bg-surface-2 hover:text-text">
<span>{title}</span>
{expanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</button>
{expanded && <div className="px-3 pb-2.5">{children}</div>}
</div>
</CollapsibleTrigger>
<CollapsibleContent className="px-3 pb-2.5">
{children}
</CollapsibleContent>
</Collapsible>
)
}
@@ -776,7 +786,7 @@ function TagsEditor({
)}
<div className="relative">
<input
<Input
type="text"
value={tagInput}
onChange={(e) => onTagInputChange(e.target.value)}
@@ -789,7 +799,7 @@ function TagsEditor({
}
}}
placeholder="Add tag…"
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text placeholder-text-faint focus:border-primary focus:outline-none"
className="h-7 bg-bg text-xs"
/>
{suggestions.length > 0 && (
<div className="mt-1 rounded border border-border bg-bg shadow-md">

View File

@@ -1,6 +1,7 @@
import { useState, useMemo, useCallback } from 'react'
import { Tag as TagIcon, ArrowLeft, Loader2 } from 'lucide-react'
import clsx from 'clsx'
import { Button } from '@/components/ui/button'
import { useTagsQuery } from '../../hooks/useTagsQuery'
import { photos as photosApi, type Tag } from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
@@ -17,11 +18,10 @@ export function TagsView() {
const setTagIds = useFilterStore((s) => s.setTagIds)
const [selectedTag, setSelectedTag] = useState<Tag | null>(null)
// Exclude face_cluster tags (those live in PeopleView)
const tags = useMemo(
() =>
allTags
.filter((t) => t.kind !== 'face_cluster')
.filter((t) => t.kind === 'user')
.sort((a, b) => b.photo_count - a.photo_count),
[allTags]
)
@@ -50,13 +50,15 @@ export function TagsView() {
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<button
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-text-muted"
onClick={exitDetail}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
title="Back to tags"
>
<ArrowLeft className="h-4 w-4" />
</button>
</Button>
<h2 className="text-sm font-semibold text-text">{selectedTag.name}</h2>
</div>
<div className="flex-1 overflow-hidden">

View File

@@ -1,10 +1,8 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import {
Star,
ShoppingBasket,
Trash2,
RefreshCw,
Check,
Copy,
AlertTriangle,
Users,
@@ -73,11 +71,9 @@ interface PhotoThumbnailProps {
* thumbnails stay at the explicit `size`. */
fill?: boolean
isSelected: boolean
/** True when the photo belongs to the currently active heap. */
/** True when the photo belongs to the currently active heap — drives
* the green tint overlay. */
isInActiveHeap?: boolean
/** Name of the active heap. When set + isInActiveHeap, the basket
* badge expands into a name chip so the user knows which heap. */
activeHeapName?: string | null
onClick: (e: React.MouseEvent) => void
onDoubleClick?: (e: React.MouseEvent) => void
}
@@ -88,7 +84,6 @@ export function PhotoThumbnail({
fill = false,
isSelected,
isInActiveHeap = false,
activeHeapName = null,
onClick,
onDoubleClick,
}: PhotoThumbnailProps) {
@@ -203,12 +198,27 @@ export function PhotoThumbnail({
return (
<div
className={clsx(
'group relative cursor-pointer overflow-hidden rounded-sm transition-all duration-200',
// Two-tone hover ring: bright primary inner + dark offset so it
// pops on light AND dark photos.
'hover:ring-2 hover:ring-primary/60 hover:ring-offset-1 hover:ring-offset-bg',
'group relative cursor-pointer overflow-hidden rounded-sm will-change-transform',
// Animate INTO selection (springy ease-in over 300ms); snap
// back instantly when deselected by dropping the transition
// entirely. Heap membership doesn't animate — it just paints
// the green tint overlay.
isSelected &&
'ring-2 ring-primary ring-offset-2 ring-offset-bg shadow-lg',
'transition-[transform,box-shadow] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)]',
// Hover is a subtle preview; selection/heap-membership are the
// prominent ring + tint states. They must read differently
// because keyboard nav drives selection while the mouse drives
// hover — the two can land on different photos at the same
// time, and the user needs to tell which one is actually
// selected.
!isSelected && !isInActiveHeap &&
'hover:ring-1 hover:ring-text-muted/70 hover:ring-offset-1 hover:ring-offset-bg',
isSelected &&
'scale-90 ring-2 ring-blue-500 ring-offset-2 ring-offset-bg',
// Active-heap membership signals via the green tint overlay
// only — no ring, no scale, no ornament. The grid layout stays
// intact and the photo just gets a green wash. When BOTH apply,
// selection's blue ring takes over.
!imageLoaded && 'bg-surface animate-pulse'
)}
style={
@@ -271,6 +281,18 @@ export function PhotoThumbnail({
</div>
)}
{/* Tint overlays — heap membership (green) and selection (blue)
* composite on top of the image. They can stack: a photo that's
* both selected AND in the active heap shows both tints. Never
* tinted on hover so the keyboard-driven selection stays
* distinguishable from the mouse-driven hover. */}
{isInActiveHeap && (
<div className="pointer-events-none absolute inset-0 bg-emerald-500/40" />
)}
{isSelected && (
<div className="pointer-events-none absolute inset-0 bg-blue-500/50" />
)}
{/* ── Ornaments ────────────────────────────────────────────────────
* All overlays compose the THUMB_BADGE_* classes so they share one
* shape/size/ring family. Colour signals semantics:
@@ -315,27 +337,12 @@ export function PhotoThumbnail({
</div>
)}
{/* TL — selection */}
{isSelected && (
<div
className={clsx(
'absolute left-1 top-1',
THUMB_BADGE_BASE,
THUMB_BADGE_SQUARE,
THUMB_BADGE_PRIMARY
)}
>
<Check className={THUMB_BADGE_ICON} strokeWidth={3} />
</div>
)}
{/* TL offset — owner badge for shared photos. Sits below the
* selection check so both can show simultaneously. */}
{/* TL — owner badge for shared photos. Selection itself is
* conveyed by the ring/outline on the wrapper, no badge needed. */}
{photo.owner_username && (
<div
className={clsx(
'absolute left-1',
isSelected ? 'top-7' : 'top-1',
'absolute left-1 top-1',
THUMB_BADGE_BASE,
THUMB_BADGE_NEUTRAL,
'max-w-[90px]'
@@ -374,22 +381,9 @@ export function PhotoThumbnail({
</div>
)}
{/* BR — flags stack: heap (primary) · duplicate / discard (neutral).
* Heap is the only user-state flag here so it gets primary; the
* rest are metadata about the file, so they're neutral-dark. */}
{/* BR — duplicate / discard (neutral). Heap membership is shown
* via the green tint overlay above, no badge here. */}
<div className="absolute bottom-1 right-1 flex items-center gap-1">
{isInActiveHeap && (
<div
className={clsx(THUMB_BADGE_BASE, THUMB_BADGE_PRIMARY, 'max-w-[120px]')}
title={activeHeapName ? `In heap: ${activeHeapName}` : 'In active heap'}
>
<ShoppingBasket
className={clsx(THUMB_BADGE_ICON, 'flex-shrink-0')}
strokeWidth={2.5}
/>
{activeHeapName && <span className="truncate">{activeHeapName}</span>}
</div>
)}
{photo.is_duplicate && (
<div
className={clsx(THUMB_BADGE_BASE, THUMB_BADGE_SQUARE, THUMB_BADGE_NEUTRAL)}

View File

@@ -182,21 +182,26 @@ export function Timeline() {
const prevViewModeRef = useRef(viewMode)
// Auto-focus the first photo on initial grid load so arrow-key nav
// works immediately without a pre-click. Only fires when there's no
// current active photo — we never clobber the user's selection or
// the one they restored by navigating back from preview.
// works immediately without a pre-click. One-shot — after the user
// explicitly clears the selection (Escape), we don't re-focus, so
// the metadata sidebar can collapse and stay collapsed.
const didAutoFocusRef = useRef(false)
useEffect(() => {
if (didAutoFocusRef.current) return
if (viewMode !== 'grid') return
if (activePhotoId) return
if (activePhotoId) {
didAutoFocusRef.current = true
return
}
if (photos.length === 0) return
didAutoFocusRef.current = true
selectPhoto(photos[0].id)
}, [viewMode, activePhotoId, photos, selectPhoto])
// Membership in the active heap (for the basket affordance). Subscribed
// once at this level so we don't have hundreds of thumbnails each
// subscribing to the same query.
const { memberIds: activeHeapMembers, activeHeap } = useActiveHeapMembers()
const activeHeapName = activeHeap?.name ?? null
// Membership in the active heap (drives the green tint on each
// thumbnail). Subscribed once at this level so we don't have hundreds
// of thumbnails each subscribing to the same query.
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
// Build the flat virtualizer items: a mix of group headers and rows of
// photos. Date headers appear only in the main timeline (groupBy='date').
@@ -783,7 +788,6 @@ export function Timeline() {
fill
isSelected={selectedPhotos.includes(photo.id)}
isInActiveHeap={activeHeapMembers.has(photo.id)}
activeHeapName={activeHeapName}
onClick={(e) => {
if (e.shiftKey) {
selectRange(photo.id)

View File

@@ -0,0 +1,59 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const alertVariants = cva(
'relative w-full rounded-md border border-border bg-surface p-3 text-sm text-text [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-3 [&>svg]:top-3 [&>svg]:text-text',
{
variants: {
variant: {
default: '',
destructive:
'border-reject/40 bg-reject/10 text-reject [&>svg]:text-reject',
},
},
defaultVariants: {
variant: 'default',
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = 'Alert'
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props}
/>
))
AlertTitle.displayName = 'AlertTitle'
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-xs [&_p]:leading-relaxed', className)}
{...props}
/>
))
AlertDescription.displayName = 'AlertDescription'
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,56 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
// Palette-rewritten shadcn Button. Variants map to the existing
// desert-dusk tokens; add more variants here if a new visual emerges.
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-bg hover:bg-primary/90',
destructive: 'bg-reject text-text hover:bg-reject/90',
outline:
'border border-border bg-surface hover:bg-surface-2 text-text',
secondary: 'bg-surface-2 text-text hover:bg-surface-offset',
ghost: 'text-text hover:bg-surface-2',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-8 px-3',
sm: 'h-7 rounded-md px-2 text-xs',
lg: 'h-9 rounded-md px-4',
icon: 'h-8 w-8',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@@ -0,0 +1,84 @@
import * as React from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { DayPicker } from 'react-day-picker'
import { cn } from '@/lib/utils'
import { buttonVariants } from './button'
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
classNames={{
months: 'flex flex-col sm:flex-row gap-4',
month: 'flex flex-col gap-3',
// Row layout: prev-nav · dropdowns (Month / Year) · next-nav.
// Works for both button and dropdown caption layouts.
caption: 'flex items-center justify-between px-1 pt-1',
caption_label: 'text-sm font-medium text-text',
// Dropdown caption — react-day-picker v8 renders each dropdown
// as an invisible <select> layered over a visible caption_label
// span. We position the <select> absolutely + transparent so
// the label is what's actually drawn, while clicks still open
// the native picker. The top-level CaptionLabel ("January 2026"
// in full) sits inside a .vhidden wrapper — absolutely clipped
// so it stays screen-reader only.
caption_dropdowns: 'flex items-center justify-center gap-2',
dropdown_month: 'relative inline-flex items-center rounded-md px-1 text-sm font-medium text-text hover:bg-surface-2',
dropdown_year: 'relative inline-flex items-center rounded-md px-1 text-sm font-medium text-text hover:bg-surface-2',
dropdown:
'absolute inset-0 z-10 cursor-pointer appearance-none bg-transparent opacity-0',
dropdown_icon: 'ml-1 h-3 w-3 opacity-60',
vhidden:
'!absolute !-m-px !h-px !w-px !overflow-hidden !whitespace-nowrap !border-0 !p-0 ![clip:rect(0,0,0,0)]',
nav: 'flex items-center gap-1',
nav_button: cn(
buttonVariants({ variant: 'ghost' }),
'h-6 w-6 p-0 text-text-muted hover:text-text'
),
nav_button_previous: '',
nav_button_next: '',
table: 'w-full border-collapse',
head_row: 'flex',
head_cell:
'text-text-muted rounded-md w-7 font-normal text-[0.7rem]',
row: 'flex w-full mt-1',
cell: cn(
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20',
'[&:has([aria-selected])]:bg-surface-2 [&:has([aria-selected].day-outside)]:bg-surface-2/50'
),
day: cn(
buttonVariants({ variant: 'ghost' }),
'h-7 w-7 p-0 font-normal aria-selected:opacity-100'
),
day_range_end: 'day-range-end',
day_selected:
'bg-primary text-bg hover:bg-primary hover:text-bg focus:bg-primary focus:text-bg',
day_today: 'text-star',
day_outside:
'day-outside text-text-faint aria-selected:bg-surface-2/50 aria-selected:text-text-faint',
day_disabled: 'text-text-faint opacity-50',
day_range_middle:
'aria-selected:bg-surface-2 aria-selected:text-text',
day_hidden: 'invisible',
...classNames,
}}
components={{
IconLeft: () => <ChevronLeft className="h-4 w-4" />,
IconRight: () => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
)
}
Calendar.displayName = 'Calendar'
export { Calendar }

View File

@@ -0,0 +1,28 @@
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { Check } from 'lucide-react'
import { cn } from '@/lib/utils'
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-border bg-surface-offset focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-bg',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn('flex items-center justify-center text-current')}
>
<Check className="h-3 w-3" strokeWidth={3} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,7 @@
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,145 @@
import * as React from 'react'
import { type DialogProps } from '@radix-ui/react-dialog'
import { Command as CommandPrimitive } from 'cmdk'
import { Search } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Dialog, DialogContent } from './dialog'
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-surface text-text',
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
type CommandDialogProps = DialogProps
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-text-muted [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b border-border px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 text-text-muted" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-9 w-full rounded-md bg-transparent py-2 text-sm text-text outline-none placeholder:text-text-faint disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm text-text-muted"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-text [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-text-muted',
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-text outline-none data-[selected=true]:bg-surface-2 data-[selected=true]:text-text data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-xs tracking-widest text-text-muted', className)}
{...props}
/>
)
}
CommandShortcut.displayName = 'CommandShortcut'
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,108 @@
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-bg/70 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-surface p-5 text-text shadow-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-3 top-3 rounded-sm p-0.5 text-text-muted opacity-70 transition-opacity hover:bg-surface-2 hover:opacity-100 focus:outline-none focus:ring-1 focus:ring-primary disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col gap-1 text-left', className)}
{...props}
/>
)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('mt-4 flex flex-row justify-end gap-2', className)}
{...props}
/>
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-sm font-semibold leading-none text-text', className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-xs text-text-muted', className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,192 @@
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { Check, ChevronRight, Circle } from 'lucide-react'
import { cn } from '@/lib/utils'
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none text-text focus:bg-surface-2 data-[state=open]:bg-surface-2',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-surface p-1 text-text shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-surface p-1 text-text shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-text outline-none transition-colors focus:bg-surface-2 data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-text outline-none transition-colors focus:bg-surface-2 data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-text outline-none transition-colors focus:bg-surface-2 data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-xs font-semibold text-text-muted',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName =
DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => (
<span
className={cn('ml-auto text-xs tracking-widest text-text-muted', className)}
{...props}
/>
)
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,23 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Input = React.forwardRef<
HTMLInputElement,
React.InputHTMLAttributes<HTMLInputElement>
>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
ref={ref}
className={cn(
'flex h-8 w-full rounded-md border border-border bg-surface-offset px-2.5 text-sm text-text placeholder:text-text-faint focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50 file:border-0 file:bg-transparent file:text-sm file:font-medium',
className
)}
{...props}
/>
)
})
Input.displayName = 'Input'
export { Input }

View File

@@ -0,0 +1,21 @@
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cn } from '@/lib/utils'
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
'text-xs font-medium leading-none text-text-muted peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className
)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,141 @@
import * as React from 'react'
import { Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from './command'
export interface MultiSelectOption {
value: string
label: string
/** Optional right-hand meta (count, badge, etc.). */
meta?: React.ReactNode
/** Optional keywords added to the cmdk search index. */
keywords?: string[]
}
interface MultiSelectProps {
options: MultiSelectOption[]
values: string[]
onChange: (next: string[]) => void
/** Show the search input. Defaults to true when options.length > 5. */
searchable?: boolean
searchPlaceholder?: string
emptyMessage?: string
/** When true, selected items pin to the top with a separator. */
pinSelected?: boolean
className?: string
}
/**
* Multi-select list built on cmdk/Command. Meant to live inside an existing
* popover/pill/dialog container — it does NOT render its own trigger.
* Clicking a row toggles it; selected rows show a checkmark.
*/
export function MultiSelect({
options,
values,
onChange,
searchable,
searchPlaceholder = 'Search…',
emptyMessage = 'No results.',
pinSelected = true,
className,
}: MultiSelectProps) {
const showSearch = searchable ?? options.length > 5
const selectedSet = React.useMemo(() => new Set(values), [values])
const { selected, unselected } = React.useMemo(() => {
if (!pinSelected) {
return { selected: [] as MultiSelectOption[], unselected: options }
}
const sel: MultiSelectOption[] = []
const unsel: MultiSelectOption[] = []
for (const o of options) {
;(selectedSet.has(o.value) ? sel : unsel).push(o)
}
return { selected: sel, unselected: unsel }
}, [options, selectedSet, pinSelected])
const toggle = (v: string) => {
const next = selectedSet.has(v)
? values.filter((x) => x !== v)
: [...values, v]
onChange(next)
}
return (
<Command className={cn('bg-transparent', className)}>
{showSearch && (
<CommandInput placeholder={searchPlaceholder} className="h-8 text-xs" />
)}
<CommandList className="max-h-64">
<CommandEmpty>{emptyMessage}</CommandEmpty>
{pinSelected && selected.length > 0 && (
<>
<CommandGroup className="p-0">
{selected.map((opt) => (
<Row key={opt.value} option={opt} selected onToggle={toggle} />
))}
</CommandGroup>
{unselected.length > 0 && <CommandSeparator />}
</>
)}
{unselected.length > 0 && (
<CommandGroup className="p-0">
{unselected.map((opt) => (
<Row
key={opt.value}
option={opt}
selected={selectedSet.has(opt.value)}
onToggle={toggle}
/>
))}
</CommandGroup>
)}
</CommandList>
</Command>
)
}
function Row({
option,
selected,
onToggle,
}: {
option: MultiSelectOption
selected: boolean
onToggle: (v: string) => void
}) {
return (
<CommandItem
value={option.value}
keywords={[option.label, ...(option.keywords ?? [])]}
onSelect={() => onToggle(option.value)}
className={cn(
'cursor-pointer gap-1.5 px-2 py-1 text-xs',
selected && 'bg-primary/10 data-[selected=true]:bg-primary/20'
)}
>
<span className="min-w-0 flex-1 truncate">{option.label}</span>
{option.meta != null && (
<span className="shrink-0 text-[10px] tabular-nums text-text-muted">
{option.meta}
</span>
)}
<Check
className={cn(
'h-3 w-3 shrink-0 text-primary',
selected ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem>
)
}

View File

@@ -0,0 +1,29 @@
import * as React from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { cn } from '@/lib/utils'
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-md border border-border bg-surface p-3 text-text shadow-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,42 @@
import * as React from 'react'
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
import { Circle } from 'lucide-react'
import { cn } from '@/lib/utils'
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn('grid gap-2', className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
'aspect-square h-4 w-4 rounded-full border border-border text-primary focus:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2 w-2 fill-current text-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,155 @@
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-8 w-full items-center justify-between rounded-md border border-border bg-surface-offset px-2.5 text-sm text-text placeholder:text-text-faint focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 text-text-muted" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1 text-text-muted',
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1 text-text-muted',
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-surface text-text shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pl-8 pr-2 text-xs font-semibold text-text-muted', className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-text outline-none focus:bg-surface-2 data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,29 @@
import * as React from 'react'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '@/lib/utils'
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = 'horizontal', decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -0,0 +1,33 @@
import { Toaster as Sonner } from 'sonner'
type ToasterProps = React.ComponentProps<typeof Sonner>
// Palette-mapped sonner toaster. Mount once near the App root; the
// `toast.*` API comes from `@/components/ToastContainer` which wraps
// sonner's `toast` with our legacy (title, message?, action?) shape so
// existing call sites don't need edits.
const Toaster = ({ ...props }: ToasterProps) => (
<Sonner
position="bottom-left"
theme="dark"
toastOptions={{
classNames: {
toast:
'group toast bg-surface/90 border border-border backdrop-blur-md text-text shadow-lg',
title: 'text-text text-sm font-semibold',
description: 'text-text-muted text-xs',
actionButton:
'bg-primary text-bg hover:bg-primary/90 rounded-md px-2 py-1 text-xs font-medium',
cancelButton:
'bg-surface-2 text-text-muted hover:bg-surface-offset rounded-md px-2 py-1 text-xs',
success: 'border-l-2 border-l-pick',
error: 'border-l-2 border-l-reject',
info: 'border-l-2 border-l-primary',
warning: 'border-l-2 border-l-star',
},
}}
{...props}
/>
)
export { Toaster }

View File

@@ -0,0 +1,27 @@
import * as React from 'react'
import * as SwitchPrimitives from '@radix-ui/react-switch'
import { cn } from '@/lib/utils'
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-bg disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-surface-offset',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-4 w-4 rounded-full bg-text shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,53 @@
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '@/lib/utils'
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-9 items-center justify-center gap-1 rounded-md bg-surface-2 p-1 text-text-muted',
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-surface data-[state=active]:text-text data-[state=active]:shadow-sm',
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-3 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary',
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,22 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => {
return (
<textarea
ref={ref}
className={cn(
'flex min-h-[60px] w-full resize-y rounded-md border border-border bg-surface-offset px-2.5 py-1.5 text-sm text-text placeholder:text-text-faint focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
)
})
Textarea.displayName = 'Textarea'
export { Textarea }

View File

@@ -0,0 +1,56 @@
import * as React from 'react'
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group'
import { type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { toggleVariants } from './toggle'
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: 'default',
variant: 'default',
})
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn('flex items-center gap-1', className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }

View File

@@ -0,0 +1,42 @@
import * as React from 'react'
import * as TogglePrimitive from '@radix-ui/react-toggle'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const toggleVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors hover:bg-surface-2 hover:text-text focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-surface-offset data-[state=on]:text-text',
{
variants: {
variant: {
default: 'bg-transparent text-text-muted',
outline:
'border border-border bg-transparent text-text-muted hover:bg-surface-2',
},
size: {
default: 'h-8 px-2',
sm: 'h-7 px-2 text-xs',
lg: 'h-9 px-2.5',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }

View File

@@ -0,0 +1,28 @@
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@/lib/utils'
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border border-border bg-surface-2 px-2 py-1 text-xs text-text shadow-md data-[state=delayed-open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=delayed-open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -17,6 +17,12 @@ import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { FOLDER_TREE_QUERY_KEY } from '../../hooks/useFolderTreeQuery'
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
import { toast } from '../ToastContainer'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
interface UploadModalProps {
isOpen: boolean
@@ -123,15 +129,8 @@ export function UploadModal({ isOpen, onClose, initialFolderId }: UploadModalPro
}
}, [isOpen, initialFolderId, folderTree, destFolderId])
// Esc closes (unless mid-upload — don't orphan in-flight requests).
useEffect(() => {
if (!isOpen) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isUploading) onClose()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [isOpen, isUploading, onClose])
// Esc / overlay-click dismissal lives on the Dialog primitive below.
// We only need to guard against closing while an upload is in flight.
// Reset transient state on open so a previous session's queue doesn't
// bleed into a fresh one.
@@ -324,31 +323,20 @@ export function UploadModal({ isOpen, onClose, initialFolderId }: UploadModalPro
)
const overallPct = totalBytes > 0 ? Math.round((uploadedBytes / totalBytes) * 100) : 0
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={!isUploading ? onClose : undefined}
/>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="relative z-10 flex max-h-[85vh] w-[760px] flex-col rounded-lg border border-border bg-surface shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-5 py-3">
<div className="flex items-center gap-2">
<UploadIcon className="h-4 w-4 text-text-muted" />
<h2 className="text-base font-semibold text-text">Upload photos</h2>
</div>
<button
onClick={onClose}
disabled={isUploading}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text disabled:opacity-40"
aria-label="Close"
>
<X className="h-4 w-4" />
</button>
</div>
<Dialog
open={isOpen}
onOpenChange={(o) => {
if (!o && !isUploading) onClose()
}}
>
<DialogContent className="flex max-h-[85vh] w-[760px] max-w-[760px] flex-col p-0">
<DialogHeader className="border-b border-border px-5 py-3">
<DialogTitle className="flex items-center gap-2">
<UploadIcon className="h-4 w-4 text-text-muted" />
Upload photos
</DialogTitle>
</DialogHeader>
{/* Body */}
<div className="flex min-h-0 flex-1 gap-4 overflow-hidden p-5">
@@ -535,9 +523,8 @@ export function UploadModal({ isOpen, onClose, initialFolderId }: UploadModalPro
{isUploading ? 'Uploading…' : `Upload ${queue.length || ''}`.trim()}
</button>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -18,14 +18,7 @@ export function useFeaturesQuery() {
})
}
export function useIsFeatureEnabled(
name:
| 'vision.enabled'
| 'vision.ocr.enabled'
| 'vision.detector.enabled'
| 'vision.faces.enabled'
| 'vision.classifier.enabled',
): boolean {
export function useIsFeatureEnabled(name: 'vision.enabled'): boolean {
const { data } = useFeaturesQuery()
// Default to enabled while loading so we don't flash "feature off"
// during a first-paint fetch. The backend is the source of truth;

View File

@@ -89,6 +89,7 @@ function parseUrl(): HydratePayload {
}
if (sp.get('duplicates') === 'true') out.duplicates = true
if (sp.get('needs_review') === 'true') out.needsReview = true
const groupBy = sp.get('group')
if (groupBy === 'date' || groupBy === 'tag') out.groupBy = groupBy
@@ -123,6 +124,7 @@ function writeUrl(f: FilterState & { currentSection?: string }) {
if (f.folderId) sp.set('folder_id', f.folderId)
if (f.tagIds.length > 0) sp.set('tag_ids', f.tagIds.join(','))
if (f.duplicates) sp.set('duplicates', 'true')
if (f.needsReview) sp.set('needs_review', 'true')
if (f.groupBy !== 'date') sp.set('group', f.groupBy)
if (f.currentSection && f.currentSection !== 'all-photos')
sp.set('section', f.currentSection)

View File

@@ -6,7 +6,7 @@ import { HEAPS_QUERY_KEY } from './useHeapsQuery'
import { toast } from '../components/ToastContainer'
import { registerUndoable, useUndoStore } from '../store/undoStore'
import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery'
import { stripPhotosFromCache } from './usePhotosQuery'
import type { Photo } from '../types/photo'
interface KeyboardShortcutsProps {
onToggleLeftSidebar: () => void
@@ -77,16 +77,61 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
onError: (e: any) => toast.error('Bulk color failed', e.message || 'Unknown error'),
})
const bulkDiscardMutation = useMutation({
mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids),
onSuccess: invalidatePhotoQueries,
onError: (e: any) => toast.error('Bulk discard failed', e.message || 'Unknown error'),
})
/** Flip the cached photos to is_discarded=value in every list query
* without removing them. Lets the grid grey them in place instead of
* reflowing — they stay until a hard reload, which gives the user
* visual context for the undo. */
const markCachedDiscarded = (ids: string[], discarded: boolean) => {
const set = new Set(ids)
queryClient.setQueriesData<Photo[]>({ queryKey: ['photos'] }, (prev) =>
prev
? prev.map((p) => (set.has(p.id) ? { ...p, is_discarded: discarded } : p))
: prev
)
}
const bulkRestoreMutation = useMutation({
mutationFn: (ids: string[]) => photosApi.bulkRestore(ids),
onSuccess: invalidatePhotoQueries,
onError: (e: any) => toast.error('Bulk restore failed', e.message || 'Unknown error'),
/** Look up a photo's CURRENT cached state (is_discarded etc) without
* triggering a refetch. Walks every ['photos', _] entry first
* (timeline lists), then falls back to the per-photo cache. */
const findCachedPhoto = (id: string): Photo | undefined => {
const entries = queryClient.getQueriesData<Photo[]>({ queryKey: ['photos'] })
for (const [, list] of entries) {
if (!list) continue
const p = list.find((x) => x.id === id)
if (p) return p
}
return queryClient.getQueryData<Photo>(['photo', id])
}
// Discard / restore mutation — the only path that doesn't auto-
// invalidate ['photos']. Invalidating would refetch with the active
// filter (which excludes discarded photos in every section except
// Discarded, and vice-versa) and yank the just-changed photos off
// the screen. We want the opposite: the photos stay where they are,
// re-tinted via PhotoThumbnail's `is_discarded` styling, until the
// user reloads. Same goes for the preview filmstrip.
const discardMutation = useMutation({
mutationFn: ({ ids, discarded }: { ids: string[]; discarded: boolean }) =>
ids.length === 1
? photosApi.update(ids[0], { is_discarded: discarded })
: discarded
? photosApi.bulkDiscard(ids)
: photosApi.bulkRestore(ids),
onMutate: ({ ids, discarded }) => markCachedDiscarded(ids, discarded),
onError: (e: any, { ids, discarded }) => {
// Roll back the optimistic flip.
markCachedDiscarded(ids, !discarded)
toast.error(
discarded ? 'Discard failed' : 'Restore failed',
e?.message || 'Unknown error'
)
},
onSuccess: (_data, { ids }) => {
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
ids.forEach((id) =>
queryClient.invalidateQueries({ queryKey: ['photo', id] })
)
},
})
/** The set of photo ids the next culling action should apply to.
@@ -109,44 +154,43 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
const ids = cullTargets()
if (ids.length === 0) return
// Discard is a removal from the timeline view: yank it from the
// selection / cache before the network round-trip so the grid
// reflows immediately and the next photo takes over the active
// cursor. Restore goes through the same removal path because it
// only fires from views (discard pile) where restored photos no
// longer match the filter.
// Discard / restore: leave the photos in place and just flip the
// tint via the dedicated mutation (no cache strip, no timeline
// removal). They disappear on hard reload because the section
// filter excludes them in the wrong direction.
if (data.is_discarded === true || data.is_discarded === false) {
usePhotoStore.getState().removePhotosFromTimeline(ids)
stripPhotosFromCache(queryClient, ids)
}
if (ids.length === 1) {
const id = ids[0]
updateMutation.mutate(
{ id, data },
const discarded = data.is_discarded
discardMutation.mutate(
{ ids, discarded },
{
onSuccess: () => {
// Only the discard/restore subset of single-photo updates is
// undoable today — rating and color round-trip cleanly enough
// that the manual fix is faster than maintaining per-photo
// previous-value snapshots.
if (data.is_discarded === true) {
registerUndoable('Discarded 1 photo', async () => {
await photosApi.bulkRestore([id])
invalidatePhotoQueries()
})
} else if (data.is_discarded === false) {
registerUndoable('Restored 1 photo', async () => {
await photosApi.bulkDiscard([id])
invalidatePhotoQueries()
})
}
const verb = discarded ? 'Discarded' : 'Restored'
registerUndoable(
`${verb} ${ids.length} photo${ids.length === 1 ? '' : 's'}`,
async () => {
markCachedDiscarded(ids, !discarded)
await (discarded
? photosApi.bulkRestore(ids)
: photosApi.bulkDiscard(ids))
queryClient.invalidateQueries({
queryKey: LIBRARY_STATS_QUERY_KEY,
})
ids.forEach((id) =>
queryClient.invalidateQueries({ queryKey: ['photo', id] })
)
}
)
},
}
)
return
}
if (ids.length === 1) {
updateMutation.mutate({ id: ids[0], data })
return
}
// Multi-selection — fan out to the right bulk endpoint per field.
if (data.rating !== undefined) {
bulkRatingMutation.mutate({ ids, rating: data.rating })
@@ -154,34 +198,20 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
if (data.color_label !== undefined) {
bulkColorMutation.mutate({ ids, color: data.color_label })
}
if (data.is_discarded === true) {
bulkDiscardMutation.mutate(ids, {
onSuccess: () => {
registerUndoable(
`Discarded ${ids.length} photo${ids.length === 1 ? '' : 's'}`,
async () => {
await photosApi.bulkRestore(ids)
invalidatePhotoQueries()
}
)
},
})
} else if (data.is_discarded === false) {
bulkRestoreMutation.mutate(ids, {
onSuccess: () => {
registerUndoable(
`Restored ${ids.length} photo${ids.length === 1 ? '' : 's'}`,
async () => {
await photosApi.bulkDiscard(ids)
invalidatePhotoQueries()
}
)
},
})
}
}
// P key (Pick): toggle the current selection's membership in the active
/** X toggles the discard flag based on the FIRST target's current
* state — so pressing X on a photo that's already discarded restores
* it. Mirrors the way Lightroom's flag-toggle works for selections. */
const toggleDiscardOnTargets = () => {
const ids = cullTargets()
if (ids.length === 0) return
const first = findCachedPhoto(ids[0])
const willDiscard = !(first?.is_discarded ?? false)
updateActive({ is_discarded: willDiscard })
}
// S key (Select): toggle the current selection's membership in the active
// heap. If every selected photo is already a member, remove them; otherwise
// add the missing ones. No active heap → toast hint.
const heapMutation = useMutation({
@@ -251,7 +281,7 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
? [state.activePhotoId]
: []
if (ids.length === 0) {
toast.info('Nothing selected', 'Select photos first, then press P')
toast.info('Nothing selected', 'Select photos first, then press S')
return
}
const heapsList = queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []
@@ -342,12 +372,12 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
useHotkeys('0', () => updateActive({ rating: 0 }), HK_OPTS)
// P (Pick) is unified with "add to active heap" — Pick a photo and you're
// adding it to the heap you set as active. Toggling on already-picked
// photos removes them from the heap.
useHotkeys('p', togglePickOnSelection, HK_OPTS)
// S (Select) is unified with "add to active heap" — Select a photo and
// you're adding it to the heap you set as active. Toggling on already-
// selected photos removes them from the heap.
useHotkeys('s', togglePickOnSelection, HK_OPTS)
useHotkeys('x', () => updateActive({ is_discarded: true }), HK_OPTS)
useHotkeys('x', toggleDiscardOnTargets, HK_OPTS)
useHotkeys('u', () => updateActive({ is_discarded: false }), HK_OPTS)

View File

@@ -52,6 +52,7 @@ export function usePhotosQuery() {
const folderId = useFilterStore((s) => s.folderId)
const tagIds = useFilterStore((s) => s.tagIds)
const duplicates = useFilterStore((s) => s.duplicates)
const needsReview = useFilterStore((s) => s.needsReview)
const groupBy = useFilterStore((s) => s.groupBy)
const sortBy = useFilterStore((s) => s.sortBy)
const sortOrder = useFilterStore((s) => s.sortOrder)
@@ -71,11 +72,12 @@ export function usePhotosQuery() {
folderId,
tagIds,
duplicates,
needsReview,
groupBy,
sortBy,
sortOrder,
}),
[q, dateFrom, dateTo, mediaTypes, ratingMin, ratingMax, colorLabel, flag, heapId, folderId, tagIds, duplicates, groupBy, sortBy, sortOrder]
[q, dateFrom, dateTo, mediaTypes, ratingMin, ratingMax, colorLabel, flag, heapId, folderId, tagIds, duplicates, needsReview, groupBy, sortBy, sortOrder]
)
const queryClient = useQueryClient()

View File

@@ -0,0 +1,64 @@
import { useQuery } from '@tanstack/react-query'
import { library, type WorkerStatus } from '../services/api'
interface ScanStatus {
is_scanning: boolean
current_folder?: string
processed_files: number
total_files: number
errors: string[]
}
/** Pending tasks waiting in a queue. We deliberately ignore worker
* `active`/`reserved` counts here because they can stay non-zero
* briefly after a queue drains (workers hold prefetched tasks) and
* would otherwise leave the spinner running with nothing to do. */
function pendingTasks(ws: WorkerStatus | undefined): number {
if (!ws) return 0
return Object.values(ws.queues ?? {}).reduce((a, b) => a + b, 0)
}
/** Tasks actively being executed by workers right now. Distinct from
* pendingTasks so we can drop the spinner the moment no real work is
* in flight, even if reserved tasks linger. */
function activeTasks(ws: WorkerStatus | undefined): number {
if (!ws) return 0
return (
ws.workers?.reduce((sum, w) => sum + (w.active ?? 0), 0) ?? 0
)
}
/**
* Read the current background-activity status (filesystem scan +
* processing queues) so consumers can render a small inline spinner
* instead of the old bottom-right popover. Shares query keys with
* ScanProgress so polling stays deduplicated.
*/
export function useScanActivity() {
const { data: scanStatus } = useQuery<ScanStatus>({
queryKey: ['scan-status'],
queryFn: () => library.scanStatus(),
refetchInterval: (query) =>
query.state.data?.is_scanning ? 2000 : 10000,
})
const { data: workerStatus } = useQuery<WorkerStatus>({
queryKey: ['worker-status-progress'],
queryFn: () => library.maintenance.workerStatus(),
refetchInterval: (query) => {
const data = query.state.data
return pendingTasks(data) + activeTasks(data) > 0 ? 3000 : 15000
},
})
const isScanning = scanStatus?.is_scanning ?? false
const isProcessing =
pendingTasks(workerStatus) + activeTasks(workerStatus) > 0
return {
active: isScanning || isProcessing,
isScanning,
isProcessing,
/** Path of the folder currently being scanned, when known. */
currentFolder: scanStatus?.current_folder ?? null,
}
}

View File

@@ -0,0 +1,9 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
/** Tailwind-aware className combiner. Used by every shadcn ui/*.tsx
* component so repeated Tailwind utilities collapse to the last-wins
* value instead of fighting each other in the class attribute. */
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs))
}

Some files were not shown because too many files have changed in this diff Show More