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, Tag,
Heap, Heap,
HeapPhoto, HeapPhoto,
Embedding,
) )
config = context.config 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_max_overflow: int = 10
db_pool_recycle: int = 3600 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): class ClassifierSettings(BaseModel):
"""CLIP zero-shot content classification settings""" """Binary content classifier (photography vs other)."""
enabled: bool = True
min_confidence: float = 0.3 min_confidence: float = 0.3
class VisionSettings(BaseModel): class VisionSettings(BaseModel):
"""AI vision pipeline settings. Disabled when running on SQLite """Vision pipeline — one binary classifier (photography vs other)."""
(pgvector is required for embedding storage)."""
enabled: bool = True enabled: bool = True
backend: str = "onnx" # "onnx" | "rocm" (future) backend: str = "onnx"
models_dir: str = "/data/models" 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"] execution_providers: list[str] = ["CPUExecutionProvider"]
embedder: EmbedderSettings = EmbedderSettings()
ocr: OCRSettings = OCRSettings()
detector: DetectorSettings = DetectorSettings()
faces: FacesSettings = FacesSettings()
classifier: ClassifierSettings = ClassifierSettings() classifier: ClassifierSettings = ClassifierSettings()
worker_concurrency: int = 2 worker_concurrency: int = 2

View File

@@ -111,13 +111,7 @@ async def init_db():
"""Initialize database, create tables if they don't exist""" """Initialize database, create tables if they don't exist"""
async with engine.begin() as conn: async with engine.begin() as conn:
# Import all models to register them with Base # Import all models to register them with Base
from app.models import User, Photo, Folder, SourceRoot, Tag, PhotoTag, Heap, HeapPhoto, Embedding from app.models import User, Photo, Folder, SourceRoot, Tag, PhotoTag, Heap, HeapPhoto # noqa: F401
# 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"))
# Create all tables. Note: create_all only creates *missing* tables — # Create all tables. Note: create_all only creates *missing* tables —
# it does NOT add new columns to existing tables when the model gains # 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.folders import Folder, SourceRoot
from app.models.tags import Tag, PhotoTag from app.models.tags import Tag, PhotoTag
from app.models.heaps import Heap, HeapPhoto 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 from app.models.sharing import HeapShare, FolderShare
__all__ = [ __all__ = [
@@ -20,9 +17,6 @@ __all__ = [
'PhotoTag', 'PhotoTag',
'Heap', 'Heap',
'HeapPhoto', 'HeapPhoto',
'Embedding',
'OCRText',
'FaceEmbedding',
'HeapShare', 'HeapShare',
'FolderShare', '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. # rows, and POST /folders/{id}/hide recomputes it on toggle.
is_hidden = Column(Boolean, nullable=False, default=False, server_default='false', index=True) 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 # "Capture date is probably wrong" — denormalized from the folder/filename
# date-guesser. Set at scan time and recomputed on every taken_at edit so # 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 # 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_media_type', 'media_type'),
Index('ix_photos_processing_status', 'processing_status'), Index('ix_photos_processing_status', 'processing_status'),
Index('ix_photos_lat_lon', 'latitude', 'longitude'), 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, association carries per-photo ML metadata (confidence, bounding box,
source model). 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.orm import relationship
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
import uuid import uuid
@@ -22,7 +22,7 @@ photo_tags = Table(
# ML metadata — null for user-applied tags # ML metadata — null for user-applied tags
Column('confidence', Float, nullable=True), Column('confidence', Float, nullable=True),
Column('bbox', JSONB, nullable=True), # [x1, y1, x2, y2] normalized 0-1 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_photo_id', 'photo_id'),
Index('ix_photo_tags_tag_id', 'tag_id'), Index('ix_photo_tags_tag_id', 'tag_id'),
) )
@@ -42,16 +42,11 @@ class Tag(Base):
# Tag classification # Tag classification
kind = Column(String, nullable=False, default='user', index=True) 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) # Which model produced this tag (null for user-created)
source = Column(String, nullable=True) source = Column(String, nullable=True)
# e.g. "vision:yolov8n", "vision:sface", null # e.g. "vision:clip_classifier", 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
)
# Relationships # Relationships
photos = relationship("Photo", secondary=photo_tags, backref="tags") photos = relationship("Photo", secondary=photo_tags, backref="tags")

View File

@@ -320,13 +320,8 @@ async def update_feature_flag(
class BackfillVisionBody(BaseModel): class BackfillVisionBody(BaseModel):
"""POST body for triggering a vision backfill. ``task`` picks a """POST body for triggering a classifier backfill. ``limit`` caps how
specific stage (``embed`` / ``ocr`` / ``detect`` / ``faces`` / many photos are queued."""
``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
limit: Optional[int] = None limit: Optional[int] = None
@@ -335,61 +330,29 @@ async def trigger_ai_backfill(
body: BackfillVisionBody, body: BackfillVisionBody,
admin: User = Depends(require_admin), admin: User = Depends(require_admin),
): ):
"""Queue a vision backfill pass. Identical code path as the automatic """Queue a classifier backfill pass."""
post-scan backfill — just triggered manually from the UI."""
if not is_enabled(FLAG_VISION_ENABLED): if not is_enabled(FLAG_VISION_ENABLED):
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="Vision is currently disabled; enable it before running a backfill.", 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: if body.limit is not None and body.limit <= 0:
raise HTTPException(status_code=400, detail="limit must be positive") 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 from app.tasks.vision import backfill_vision
result = backfill_vision.apply_async( result = backfill_vision.apply_async(kwargs={'limit': body.limit})
kwargs={'task': body.task, 'limit': body.limit}
)
logger.info( logger.info(
f"Admin '{admin.username}' queued vision backfill " 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 { return {
"status": "queued", "status": "queued",
"task_id": result.id, "task_id": result.id,
"task": body.task,
"limit": body.limit, "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") @router.post("/ai/rescan")
async def trigger_full_rescan(admin: User = Depends(require_admin)): async def trigger_full_rescan(admin: User = Depends(require_admin)):
"""Dispatch the same scan_all_source_roots job the backend runs at """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 ).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). # Legacy split (kept for the existing /stats consumers).
photo_count = ( photo_count = (
await db.execute( await db.execute(
@@ -120,6 +126,7 @@ async def get_library_stats(
"with_gps": with_gps_count, "with_gps": with_gps_count,
"duplicates": duplicates_count, "duplicates": duplicates_count,
"discarded": discarded_count, "discarded": discarded_count,
"needs_review": needs_review_count,
"total_photos": photo_count, "total_photos": photo_count,
"total_videos": video_count, "total_videos": video_count,
"total_size": size, "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 = _redis.Redis.from_url(settings.redis_url, socket_timeout=1.0)
r.ping() r.ping()
broker_ok = True broker_ok = True
# `vision` is the big one — embed / classify / detect / ocr / # `vision` runs the content classifier — the only heavy queue.
# 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.
for q in ('default', 'high', 'low', 'vision'): for q in ('default', 'high', 'low', 'vision'):
try: try:
queue_depths[q] = int(r.llen(q) or 0) 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 Keep the shape flat + serialisable; the frontend turns it straight
into a list of rows without needing to know about the models. 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 from app.models.tags import photo_tags # association Table, not a model
owner = _owner_filter(current_user, scope) 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 # Classified: distinct photos with a content_type tag.
# configured embedder model. A photo can have multiple model rows classified_done = await scalar_count(
# (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(
select(func.count(func.distinct(photo_tags.c.photo_id))) select(func.count(func.distinct(photo_tags.c.photo_id)))
.select_from(photo_tags) .select_from(photo_tags)
.join(Photo, Photo.id == photo_tags.c.photo_id) .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( needs_review_count = await scalar_count(
select(func.count(func.distinct(OCRText.photo_id))) select(func.count(Photo.id)).where(not_discarded, Photo.needs_review.is_(True))
.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))
) )
duplicate_groups = await scalar_count( duplicate_groups = await scalar_count(
@@ -657,43 +629,11 @@ async def get_pipeline_stats(
"hint": "Feeds duplicate detection.", "hint": "Feeds duplicate detection.",
}, },
{ {
"key": "embeddings", "key": "classification",
"label": f"Embeddings ({embedder_model})", "label": "Content classification (photo vs other)",
"done": embeddings_done, "done": classified_done,
"total": total_images, "total": total_images,
"hint": "Semantic search + content classification.", "hint": f"{needs_review_count} photos flagged for review.",
},
{
"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,
}, },
{ {
"key": "duplicates", "key": "duplicates",
@@ -708,7 +648,6 @@ async def get_pipeline_stats(
return { return {
"total_photos": total_photos, "total_photos": total_photos,
"total_images": total_images, "total_images": total_images,
"embedder_model": embedder_model,
"stages": stages, "stages": stages,
} }

View File

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

View File

@@ -25,13 +25,7 @@ class SearchRequest(BaseModel):
@router.post("") @router.post("")
async def search_photos(body: SearchRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): 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) """FTS search over photo metadata with optional tag and date filters."""
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
"""
filters = body.filters or {} filters = body.filters or {}
results = await hybrid_search( results = await hybrid_search(

View File

@@ -1,10 +1,8 @@
""" """
Tags API router. Tags API router.
Unified across user tags, ML-detected objects, and face clusters via Unified across user tags and the binary content-type classifier
the `kind` query parameter. Default behaviour (no kind filter) returns ('photography' | 'other') via the `kind` column.
all tags — the frontend's "Hide auto-generated tags" toggle filters
client-side or passes `kind=user`.
""" """
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
@@ -34,15 +32,11 @@ class TagUpdate(BaseModel):
color: Optional[str] = None color: Optional[str] = None
class TagMerge(BaseModel):
target_id: str # tag to merge INTO
# ── Endpoints ───────────────────────────────────────────────────────────── # ── Endpoints ─────────────────────────────────────────────────────────────
@router.get("") @router.get("")
async def list_tags( 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), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@@ -90,7 +84,7 @@ async def list_tags(
"color": tag.color, "color": tag.color,
"kind": tag.kind, "kind": tag.kind,
"source": tag.source, "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), "photo_count": int(count or 0),
} }
for tag, count, first_photo_id in rows 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), tag_id: str, body: TagUpdate, db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), 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)) result = await db.execute(select(Tag).where(Tag.id == tag_id, Tag.user_id == current_user.id))
tag = result.scalar_one_or_none() tag = result.scalar_one_or_none()
if not tag: if not tag:
@@ -149,52 +143,6 @@ async def update_tag(
return {"id": tag.id, "name": tag.name, "color": tag.color, "kind": tag.kind} 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) @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)): 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.""" """Delete a tag. Photo associations cascade-delete via the FK."""

View File

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

View File

@@ -41,12 +41,10 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from sqlalchemy import select, update, text from sqlalchemy import select, update
from app.database import AsyncSessionLocal from app.database import AsyncSessionLocal
from app.models.photos import Photo from app.models.photos import Photo
from app.models.embeddings import Embedding
from app.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -54,11 +52,6 @@ logger = logging.getLogger(__name__)
# pHash Hamming distance threshold (6 out of 64 bits). # pHash Hamming distance threshold (6 out of 64 bits).
DEFAULT_PHASH_THRESHOLD = 6 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: def _hex_to_int(h: str) -> int:
"""Parse a 16-char hex pHash to a Python int. Returns -1 on bad input """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( async def regroup_duplicates(
phash_threshold: int = DEFAULT_PHASH_THRESHOLD, phash_threshold: int = DEFAULT_PHASH_THRESHOLD,
clip_threshold: float = DEFAULT_CLIP_THRESHOLD, **_ignored,
) -> dict: ) -> 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. Idempotent — safe to call as often as you like. Returns a summary dict.
""" """
embedder_model = settings.vision.embedder.name
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
# Pull all visible photos with a phash or embedding. # Pull all visible photos with a phash or embedding.
rows = ( rows = (
@@ -162,15 +153,6 @@ async def regroup_duplicates(
if _hamming(hi, hj) <= phash_threshold: if _hamming(hi, hj) <= phash_threshold:
uf.union_by_key(phash_ids[i], phash_ids[j]) 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 ── # ── Write results ──
await _clear_all_groups(session) await _clear_all_groups(session)
@@ -202,16 +184,9 @@ async def regroup_duplicates(
async def incremental_regroup( async def incremental_regroup(
since: Optional[datetime] = None, since: Optional[datetime] = None,
phash_threshold: int = DEFAULT_PHASH_THRESHOLD, phash_threshold: int = DEFAULT_PHASH_THRESHOLD,
clip_threshold: float = DEFAULT_CLIP_THRESHOLD, **_ignored,
) -> dict: ) -> dict:
"""Incremental duplicate detection for newly added photos. """Incremental duplicate detection for newly added photos using pHash."""
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
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
# If no watermark, fall back to full regroup. # If no watermark, fall back to full regroup.
if since is None: if since is None:
@@ -282,13 +257,6 @@ async def incremental_regroup(
if _hamming(nh, eh) <= phash_threshold: if _hamming(nh, eh) <= phash_threshold:
uf.union_by_key(new_id, existing_id) 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 ── # ── Write results ──
# Only update groups that contain at least one new photo. # Only update groups that contain at least one new photo.
# Clear all groups first, then rewrite. # 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: async def _clear_all_groups(session) -> None:
"""Reset duplicate_group_id / is_duplicate on every photo.""" """Reset duplicate_group_id / is_duplicate on every photo."""
await session.execute( 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 Only one flag now — the master vision switch. Runtime overrides live in
toggle these at runtime from the Settings → AI Features tab without Redis under ``mulita:flags:<name>``; an unset key falls back to the
rebuilding the image or editing the bind-mounted YAML; the overrides YAML default.
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.
""" """
from __future__ import annotations from __future__ import annotations
@@ -31,30 +17,16 @@ from app.config import settings
logger = logging.getLogger(__name__) 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_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 = ( ALL_FLAGS = (FLAG_VISION_ENABLED,)
FLAG_VISION_ENABLED,
FLAG_OCR_ENABLED,
FLAG_DETECTOR_ENABLED,
FLAG_FACES_ENABLED,
FLAG_CLASSIFIER_ENABLED,
)
_VISION_QUEUE = 'vision'
_REDIS: Optional[redis.Redis] = None _REDIS: Optional[redis.Redis] = None
def _redis() -> Optional[redis.Redis]: 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 global _REDIS
if _REDIS is None: if _REDIS is None:
try: try:
@@ -69,19 +41,8 @@ def _redis() -> Optional[redis.Redis]:
def _yaml_default(name: str) -> bool: 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: if name == FLAG_VISION_ENABLED:
return bool(v.enabled) return bool(settings.vision.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)
raise ValueError(f"Unknown feature flag: {name!r}") raise ValueError(f"Unknown feature flag: {name!r}")
@@ -90,16 +51,6 @@ def _redis_key(name: str) -> str:
def is_enabled(name: str) -> bool: 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() r = _redis()
if r is not None: if r is not None:
try: try:
@@ -112,9 +63,6 @@ def is_enabled(name: str) -> bool:
def set_flag(name: str, value: bool) -> None: 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: if name not in ALL_FLAGS:
raise ValueError(f"Unknown feature flag: {name!r}") raise ValueError(f"Unknown feature flag: {name!r}")
r = _redis() r = _redis()
@@ -125,9 +73,6 @@ def set_flag(name: str, value: bool) -> None:
def reset_flag(name: str) -> 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: if name not in ALL_FLAGS:
raise ValueError(f"Unknown feature flag: {name!r}") raise ValueError(f"Unknown feature flag: {name!r}")
r = _redis() r = _redis()
@@ -137,150 +82,41 @@ def reset_flag(name: str) -> None:
_apply_worker_side_effects(name) _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: def _apply_worker_side_effects(name: str) -> None:
"""Bring the live workers in line with the new flag value. """Attach or detach the vision consumer and purge queued work when
the master flag flips. Best-effort — state is already persisted."""
For the master ``vision.enabled`` flag we go beyond task gating and if name != FLAG_VISION_ENABLED:
actually stop consumption from the ``vision`` queue — flipping it return
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.
"""
try: 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 from app.tasks.celery import celery_app
except Exception as e: except Exception as e:
logger.warning(f"feature_flags: celery app unavailable for side effects ({e})") logger.warning(f"feature_flags: celery app unavailable for side effects ({e})")
return return
try: try:
if name == FLAG_VISION_ENABLED: if is_enabled(FLAG_VISION_ENABLED):
if is_enabled(FLAG_VISION_ENABLED): celery_app.control.add_consumer(_VISION_QUEUE, reply=False)
# Re-attach the vision consumer so workers pick up tasks logger.info("feature_flags: vision re-enabled; consumer added")
# again. broadcast=True ensures every running worker else:
# receives the command. celery_app.control.cancel_consumer(_VISION_QUEUE, reply=False)
celery_app.control.add_consumer(_VISION_QUEUE, reply=False) _purge_queue(_VISION_QUEUE)
logger.info("feature_flags: vision re-enabled; consumer added") logger.info("feature_flags: vision disabled; consumer cancelled and queue purged")
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}"
)
except Exception as e: 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: 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() r = _redis()
if r is None: if r is None:
return 0 return 0
try: try:
removed = r.delete(queue) return int(r.delete(queue) or 0)
return int(removed or 0)
except Exception as e: except Exception as e:
logger.warning(f"feature_flags: purge {queue} failed: {e}") logger.warning(f"feature_flags: purge {queue} failed: {e}")
return 0 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]]: 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() r = _redis()
out: dict[str, dict[str, object]] = {} out: dict[str, dict[str, object]] = {}
for name in ALL_FLAGS: for name in ALL_FLAGS:

View File

@@ -1,19 +1,13 @@
""" """
Unified search service — hybrid FTS + semantic (RRF) search. FTS search over photos.search_vector with optional tag/date filters.
Phase 1 (PR4): semantic-only via pgvector cosine similarity.
Phase 2 (PR5): adds FTS via tsvector, enables RRF fusion.
""" """
import logging import logging
from typing import Optional from typing import Optional
import numpy as np from sqlalchemy import select, text
from sqlalchemy import select, text, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Photo from app.models import Photo
from app.models.embeddings import Embedding
from app.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -27,135 +21,57 @@ async def hybrid_search(
limit: int = 50, limit: int = 50,
offset: int = 0, offset: int = 0,
) -> list[dict]: ) -> list[dict]:
"""Run hybrid search (FTS + semantic) with RRF fusion. """Full-text search using photos.search_vector. No embeddings, no OCR."""
Currently semantic-only; FTS leg added in PR5.
"""
model_name = settings.vision.embedder.name
results = {}
# ── Semantic search (CLIP text → pgvector cosine) ─────────────────
if q: if q:
try: 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(""" fts_stmt = text("""
SELECT id, ts_rank(search_vector, plainto_tsquery('english', :q)) AS rank SELECT id, ts_rank(search_vector, plainto_tsquery('english', :q)) AS rank
FROM photos FROM photos
WHERE search_vector @@ plainto_tsquery('english', :q) WHERE search_vector @@ plainto_tsquery('english', :q)
AND is_trashed = false AND is_trashed = false
AND is_hidden = 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 ORDER BY rank DESC
LIMIT 200 LIMIT 500
""") """)
fts_rows = (await db.execute(fts_stmt, {"q": q})).fetchall() 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
except Exception as e: except Exception as e:
logger.warning("FTS search failed: %s", e) logger.warning("FTS search failed: %s", e)
rows = []
# ── RRF fusion ──────────────────────────────────────────────────── scored = [(pid, float(rank)) for pid, rank in rows]
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.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: if tag_ids:
from app.models.tags import photo_tags from app.models.tags import photo_tags
# Subquery to get distinct photo_ids matching the tag filter photo_ids = [pid for pid, _ in scored]
sub = select(photo_tags.c.photo_id).where( if not photo_ids:
photo_tags.c.tag_id.in_(tag_ids) return []
).distinct().subquery() stmt = select(photo_tags.c.photo_id).where(
stmt = select(Photo.id).join(sub, Photo.id == sub.c.photo_id) photo_tags.c.photo_id.in_(photo_ids),
else: photo_tags.c.tag_id.in_(tag_ids),
stmt = select(Photo.id) ).distinct()
stmt = stmt.where( valid = {row[0] for row in (await db.execute(stmt)).fetchall()}
Photo.is_discarded.is_(False), scored = [(pid, s) for pid, s in scored if pid in valid]
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]
# Apply filters to scored results page = scored[offset : offset + limit]
photo_ids = [pid for pid, _ in scored] return [{"photo_id": pid, "score": s} for pid, s in page]
if not photo_ids:
return []
# Filter by tags if requested # No text query — recent photos with tag/date filters.
if tag_ids: if tag_ids:
from app.models.tags import photo_tags from app.models.tags import photo_tags
stmt = select(photo_tags.c.photo_id).where( sub = select(photo_tags.c.photo_id).where(
photo_tags.c.photo_id.in_(photo_ids), photo_tags.c.tag_id.in_(tag_ids)
photo_tags.c.tag_id.in_(tag_ids), ).distinct().subquery()
).distinct() stmt = select(Photo.id).join(sub, Photo.id == sub.c.photo_id)
valid_ids = {row[0] for row in (await db.execute(stmt)).fetchall()} else:
scored = [(pid, s) for pid, s in scored if pid in valid_ids] stmt = select(Photo.id)
stmt = stmt.where(
# Paginate Photo.is_discarded.is_(False),
page = scored[offset : offset + limit] Photo.is_hidden.is_(False),
return [{"photo_id": pid, "score": score} for pid, score in page] )
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 The pipeline is now a single binary classifier: photography vs other.
implementation is ONNXBackend (onnx_backend.py). A ROCm backend can be Feature extraction is an internal detail of the classifier and is not
added later by subclassing these ABCs and registering via exposed as a separate service.
settings.vision.backend.
""" """
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
import numpy as np 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 @dataclass
class ClassificationResult: class ClassificationResult:
"""A content-type classification."""
label: str label: str
confidence: float 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): class ContentClassifier(ABC):
"""Classifies images into content types (screenshot, document, etc.).""" """Classifies an image into 'photography' or 'other'."""
@abstractmethod @abstractmethod
def classify(self, image: np.ndarray) -> list[ClassificationResult]: def classify(self, image: np.ndarray) -> 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."""
... ...

View File

@@ -1,124 +1,46 @@
""" """
Download vision model weights on first worker boot. Ensure the OpenCLIP ViT-B/32 visual encoder is present on worker boot.
Exported via export_models.py if missing.
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.
""" """
import logging import logging
import os
from pathlib import Path from pathlib import Path
from urllib.request import urlretrieve
from app.config import settings from app.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# (relative_path, url, description) REQUIRED = [
# 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 = [
("embed/visual.onnx", "OpenCLIP ViT-B/32 visual encoder"), ("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): 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 = Path(models_dir or settings.vision.models_dir)
base.mkdir(parents=True, exist_ok=True) base.mkdir(parents=True, exist_ok=True)
# Download auto-downloadable models missing = [(rel, desc) for rel, desc in REQUIRED if not (base / rel).exists()]
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))
if missing: if missing:
logger.warning( logger.warning("Missing %d model file(s); attempting automatic export", len(missing))
"Missing %d model file(s); attempting automatic export:", try:
len(missing), from app.services.vision import export_models
) export_models.export_openclip_visual(base)
for rel_path, desc in missing: except Exception as e:
logger.warning(" %s%s", base / rel_path, desc) 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 still_missing = [(r, d) for r, d in REQUIRED if not (base / r).exists()]
if still_missing:
missing_paths = {rel for rel, _ in missing} for rel, desc in still_missing:
logger.error(" still missing: %s%s", base / rel, desc)
# 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)
else: else:
logger.info("All model files present in %s", base) 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: try:
import redis as _redis import redis as _redis
r = _redis.from_url(settings.redis_url) _redis.from_url(settings.redis_url).set("mulita:vision:ready", "1")
r.set("mulita:vision:ready", "1")
logger.info("Set mulita:vision:ready in Redis") logger.info("Set mulita:vision:ready in Redis")
except Exception as e: except Exception as e:
logger.warning("Could not set vision readiness flag in Redis: %s", 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 Uses OpenCLIP ViT-B/32 image features (ONNX) and two pre-computed text
embeddings (the ONNX text encoder has degraded quality due to the prompt centroids. Text centroids are computed once with the native
eot_indices workaround). Image embeddings use the ONNX visual encoder open_clip text encoder and cached to {models_dir}/classifier/vectors.npz
which works well. so steady-state worker startup doesn't pay the PyTorch cost.
""" """
from __future__ import annotations
import logging import logging
from pathlib import Path
import numpy as np import numpy as np
import torch
from app.config import VisionSettings 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__) logger = logging.getLogger(__name__)
CATEGORY_PROMPTS = {
"screenshot": [ PROMPTS = {
"a screenshot of a computer screen", "photography": [
"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": [
"a photograph taken with a camera", "a photograph taken with a camera",
"a real photo of a real scene or person", "a real photo of a real scene or person",
"a candid photograph", "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): 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): def __init__(self, settings: VisionSettings):
import open_clip
self._min_confidence = settings.classifier.min_confidence self._min_confidence = settings.classifier.min_confidence
self._encoder = CLIPVisualEncoder(settings)
# Load native model for text encoding only. cache_dir = Path(settings.models_dir) / "classifier"
# Use whichever model family the embedder is configured for so cache_dir.mkdir(parents=True, exist_ok=True)
# the classification text vectors live in the same space as the cache_path = cache_dir / "vectors.npz"
# image embeddings.
embedder_name = settings.embedder.name if cache_path.exists():
if embedder_name.startswith("siglip2"): logger.info("Loading cached text centroids from %s", cache_path)
model_arch = "ViT-B-16-SigLIP-384" data = np.load(cache_path)
pretrained = "webli" self._photo = data["photography"].astype(np.float32)
self._other = data["other"].astype(np.float32)
else: else:
model_arch = "ViT-B-32" centroids = _compute_text_centroids()
pretrained = "laion2b_s34b_b79k" 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) def classify(self, image: np.ndarray) -> ClassificationResult:
model, _, _ = open_clip.create_model_and_transforms( vec = self._encoder.encode(image)
model_arch, pretrained=pretrained s_photo = float(np.dot(vec, self._photo))
) s_other = float(np.dot(vec, self._other))
model.eval()
self._model = model
self._tokenizer = open_clip.get_tokenizer(model_arch)
# Get the ONNX image embedder from the registry if s_photo >= s_other:
from app.services.vision.registry import registry label = "photography"
self._embedder = registry.get_embedder() margin = s_photo - s_other
else:
label = "other"
margin = s_other - s_photo
# Pre-compute text embeddings for each category # 0.01 margin → ~0.3 conf, 0.03+ → ~1.0
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
confidence = min(1.0, margin * 30) confidence = min(1.0, margin * 30)
return ClassificationResult(label=label, confidence=confidence)
if confidence >= self._min_confidence:
return [ClassificationResult(label=best_cat, confidence=confidence)]
return []

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. OpenCLIP ViT-B/32 visual encoder (ONNX). Produces 512-d image features
consumed by the content classifier. Not exposed as a standalone service;
Supports two model families: the classifier owns the lifecycle.
- 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.
""" """
import logging import logging
from pathlib import Path from pathlib import Path
import numpy as np 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.config import VisionSettings
from app.services.vision.base import Embedder
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── Model-specific constants ────────────────────────────────────────── _MEAN = np.array([0.48145466, 0.4578275, 0.40821073], dtype=np.float32)
_STD = np.array([0.26862954, 0.26130258, 0.27577711], dtype=np.float32)
# OpenCLIP ViT-B/32 (ImageNet norm, 224px) _SIZE = 224
_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
def _preprocess_image( def _preprocess(image: np.ndarray) -> np.ndarray:
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."""
from PIL import Image from PIL import Image
img = Image.fromarray(image).convert("RGB") img = Image.fromarray(image).convert("RGB")
w, h = img.size 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) img = img.resize((int(w * scale), int(h * scale)), Image.BICUBIC)
w, h = img.size w, h = img.size
left = (w - input_size) // 2 left = (w - _SIZE) // 2
top = (h - input_size) // 2 top = (h - _SIZE) // 2
img = img.crop((left, top, left + input_size, top + input_size)) img = img.crop((left, top, left + _SIZE, top + _SIZE))
arr = np.array(img, dtype=np.float32) / 255.0 arr = np.array(img, dtype=np.float32) / 255.0
arr = (arr - mean) / std arr = (arr - _MEAN) / _STD
arr = arr.transpose(2, 0, 1) # HWC → CHW arr = arr.transpose(2, 0, 1)
return arr[np.newaxis] # NCHW return arr[np.newaxis]
class OpenCLIPEmbedder(Embedder): class CLIPVisualEncoder:
"""Legacy OpenCLIP ViT-B/32 embedder (512-d).""" """OpenCLIP ViT-B/32 image encoder, 512-d normalized output."""
def __init__(self, settings: VisionSettings): def __init__(self, settings: VisionSettings):
model_dir = Path(settings.models_dir) / "embed" model_path = Path(settings.models_dir) / "embed" / "visual.onnx"
visual_path = model_dir / "visual.onnx"
textual_path = model_dir / "textual.onnx"
from app.services.vision.providers import create_session from app.services.vision.providers import create_session
from app.config import settings as app_settings from app.config import settings as app_settings
providers = app_settings.vision.execution_providers
logger.info("Loading OpenCLIP visual encoder from %s", visual_path) logger.info("Loading CLIP visual encoder from %s", model_path)
self._visual = create_session(str(visual_path), configured_providers=providers) self._session = create_session(
str(model_path),
configured_providers=app_settings.vision.execution_providers,
)
logger.info("Loading OpenCLIP textual encoder from %s", textual_path) def encode(self, image: np.ndarray) -> np.ndarray:
self._textual = create_session(str(textual_path), configured_providers=providers) inp = _preprocess(image)
name = self._session.get_inputs()[0].name
def embed_image(self, image: np.ndarray) -> np.ndarray: out = self._session.run(None, {name: inp})[0][0]
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]
out = out / np.linalg.norm(out) out = out / np.linalg.norm(out)
return out.astype(np.float32) 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] python -m app.services.vision.export_models [--models-dir /data/models]
This produces: Produces:
embed/visual.onnx (~350 MB) 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.
""" """
import argparse import argparse
import logging import logging
import sys
from pathlib import Path from pathlib import Path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def export_openclip(models_dir: Path): def export_openclip_visual(models_dir: Path):
"""Export OpenCLIP ViT-B/32 to two ONNX files (visual + textual)."""
import torch import torch
import open_clip import open_clip
out_dir = models_dir / "embed" out_dir = models_dir / "embed"
out_dir.mkdir(parents=True, exist_ok=True) out_dir.mkdir(parents=True, exist_ok=True)
visual_path = out_dir / "visual.onnx" visual_path = out_dir / "visual.onnx"
textual_path = out_dir / "textual.onnx" if visual_path.exists():
logger.info("OpenCLIP visual.onnx already exists, skipping export")
if visual_path.exists() and textual_path.exists():
logger.info("OpenCLIP ONNX files already exist, skipping export")
return return
logger.info("Loading OpenCLIP ViT-B-32 laion2b_s34b_b79k...") 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" "ViT-B-32", pretrained="laion2b_s34b_b79k"
) )
model.eval() model.eval()
# Use dynamo=False to get the legacy TorchScript exporter which logger.info("Exporting visual encoder → %s", visual_path)
# produces IR version 9 (compatible with onnxruntime 1.17.x). dummy = torch.randn(1, 3, 224, 224)
# The new torch.onnx.export default (dynamo=True) emits IR 10. torch.onnx.export(
export_kwargs = dict(opset_version=14, dynamo=False) model.visual,
dummy,
# ── Visual encoder ──────────────────────────────────────────────── str(visual_path),
if not visual_path.exists(): input_names=["image"],
logger.info("Exporting visual encoder → %s", visual_path) output_names=["embedding"],
dummy_image = torch.randn(1, 3, 224, 224) dynamic_axes={"image": {0: "batch"}},
torch.onnx.export( opset_version=14,
model.visual, dynamo=False,
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"
) )
model.eval() logger.info("Visual encoder exported (%.1f MB)", visual_path.stat().st_size / 1e6)
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)
def main(): def main():
parser = argparse.ArgumentParser(description="Export vision model weights to ONNX") parser = argparse.ArgumentParser()
parser.add_argument( parser.add_argument("--models-dir", type=Path, default=Path("/data/models"))
"--models-dir",
type=Path,
default=Path("/data/models"),
help="Directory to write model files (default: /data/models)",
)
args = parser.parse_args() args = parser.parse_args()
logging.basicConfig( logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
level=logging.INFO, args.models_dir.mkdir(parents=True, exist_ok=True)
format="%(asctime)s %(levelname)s %(message)s", export_openclip_visual(args.models_dir)
)
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.")
if __name__ == "__main__": 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. ModelRegistry — lazy-loads the single content classifier per worker.
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.
""" """
import logging import logging
from functools import lru_cache from functools import lru_cache
from app.config import settings 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__) logger = logging.getLogger(__name__)
class ModelRegistry: class ModelRegistry:
"""Central access point for all vision models."""
def __init__(self): def __init__(self):
self._vision = settings.vision 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) @lru_cache(maxsize=1)
def get_classifier(self) -> ContentClassifier: def get_classifier(self) -> ContentClassifier:
logger.info("Loading content classifier (backend=%s)", self._vision.backend) logger.info("Loading content classifier (backend=%s)", self._vision.backend)
return self._load_backend().create_classifier() from app.services.vision.classify import CLIPContentClassifier
return CLIPContentClassifier(self._vision)
@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}")
def warmup(self): def warmup(self):
"""Pre-load all enabled models. Called from Celery worker_process_init logger.info("Warming up vision classifier...")
on the vision queue to avoid cold-start latency on the first task.""" self.get_classifier()
logger.info("Warming up vision models...") logger.info("Vision warmup complete")
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")
# Module-level singleton. Import this from tasks.
registry = ModelRegistry() 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 # Explicit routes for every task name. Wildcard patterns don't match
# short names produced by @shared_task(name='...'). # short names produced by @shared_task(name='...').
task_routes={ task_routes={
# Vision queue — GPU/CPU-bound inference # Vision queue — CPU-bound binary classification
'embed_photo': {'queue': 'vision'},
'ocr_photo': {'queue': 'vision'},
'detect_objects': {'queue': 'vision'},
'extract_faces': {'queue': 'vision'},
'classify_content': {'queue': 'vision'}, 'classify_content': {'queue': 'vision'},
'vision_fanout': {'queue': 'vision'}, 'vision_fanout': {'queue': 'vision'},
'recluster_faces': {'queue': 'vision'},
# High-priority queue — thumbnails & duplicates # High-priority queue — thumbnails & duplicates
'generate_thumbnails': {'queue': 'high'}, 'generate_thumbnails': {'queue': 'high'},
'regenerate_all_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. still hit Settings → Re-detect duplicates to force a fresh pass.
""" """
from app.tasks.thumbs import incremental_regroup_duplicates_task 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: async with AsyncSessionLocal() as session:
result = await session.execute( result = await session.execute(
@@ -461,14 +461,6 @@ async def _scan_all_source_roots_async():
except Exception as e: except Exception as e:
logger.warning(f"Could not queue post-scan vision backfill: {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. # Re-extract metadata for photos missing GPS coordinates.
# Runs on every startup so photos scanned before the GPS fix # Runs on every startup so photos scanned before the GPS fix
# eventually get their coordinates populated. # eventually get their coordinates populated.

View File

@@ -1,33 +1,21 @@
""" """
Celery tasks for the vision pipeline — embedding, OCR, object detection, Celery tasks for the vision pipeline.
face recognition.
All tasks run on the dedicated `vision` queue with limited concurrency A single binary classifier decides whether a photo is 'photography' or
(memory-bound CPU inference). They read thumbnails generated by 'other'. Photos classified as 'other' get needs_review=true so the user
generate_thumbnails, so they MUST run after thumbs complete. can triage screenshots / documents / memes in the UI.
DB access uses sync psycopg2 sessions (not asyncpg) because Celery
forks workers and asyncpg connections can't be shared across forks.
""" """
import logging import logging
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
from celery import shared_task 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 sqlalchemy.orm import Session, sessionmaker
from PIL import Image from PIL import Image
from app.models.embeddings import Embedding
from app.config import settings from app.config import settings
from app.services.feature_flags import ( from app.services.feature_flags import is_enabled, FLAG_VISION_ENABLED
is_enabled,
FLAG_VISION_ENABLED,
FLAG_OCR_ENABLED,
FLAG_DETECTOR_ENABLED,
FLAG_FACES_ENABLED,
FLAG_CLASSIFIER_ENABLED,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -35,7 +23,6 @@ VISION_READY_KEY = "mulita:vision:ready"
def _vision_worker_ready() -> bool: def _vision_worker_ready() -> bool:
"""Check whether the vision worker has finished model bootstrap."""
try: try:
import redis as _redis import redis as _redis
return bool(_redis.from_url(settings.redis_url).exists(VISION_READY_KEY)) return bool(_redis.from_url(settings.redis_url).exists(VISION_READY_KEY))
@@ -47,7 +34,6 @@ _sync_engine = None
def _get_sync_engine(): def _get_sync_engine():
"""Return a module-level singleton engine (one per worker process)."""
global _sync_engine global _sync_engine
if _sync_engine is None: if _sync_engine is None:
sync_url = settings.database_url.replace("+asyncpg", "+psycopg2").replace("+aiosqlite", "") sync_url = settings.database_url.replace("+asyncpg", "+psycopg2").replace("+aiosqlite", "")
@@ -56,21 +42,13 @@ def _get_sync_engine():
def _get_sync_session() -> Session: def _get_sync_session() -> Session:
"""Create a sync DB session backed by the shared engine."""
return sessionmaker(bind=_get_sync_engine())() return sessionmaker(bind=_get_sync_engine())()
def _load_thumb(photo_id: str, size: str = "medium") -> np.ndarray | None: 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") thumb_base = Path("/data/thumbs")
# Try legacy flat path first.
thumb_path = thumb_base / photo_id / f"{size}.webp" thumb_path = thumb_base / photo_id / f"{size}.webp"
if not thumb_path.exists(): 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")) matches = list(thumb_base.glob(f"*/{photo_id}/{size}.webp"))
if matches: if matches:
thumb_path = matches[0] thumb_path = matches[0]
@@ -79,7 +57,7 @@ def _load_thumb(photo_id: str, size: str = "medium") -> np.ndarray | None:
return None return None
try: try:
img = Image.open(thumb_path).convert("RGB") img = Image.open(thumb_path).convert("RGB")
img.load() # force decode to catch corruption early img.load()
arr = np.array(img) arr = np.array(img)
img.close() img.close()
return arr return arr
@@ -88,207 +66,25 @@ def _load_thumb(photo_id: str, size: str = "medium") -> np.ndarray | None:
return 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') @shared_task(name='vision_fanout', queue='vision')
def vision_fanout(photo_id: str): 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): if not is_enabled(FLAG_VISION_ENABLED):
return {'status': 'skipped', 'reason': 'vision disabled'} return {'status': 'skipped', 'reason': 'vision disabled'}
classify_content.delay(photo_id)
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)
return {'status': 'dispatched', 'photo_id': 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) @shared_task(name='classify_content', queue='vision', bind=True, max_retries=3)
def classify_content(self, photo_id: str): def classify_content(self, photo_id: str):
"""Classify image content type (screenshot, document, artwork, etc.) """Run the binary classifier and write:
using CLIP zero-shot classification. Writes Tag(kind=content_type).""" - a Tag(kind='content_type', name IN ('photography','other'))
if not is_enabled(FLAG_VISION_ENABLED) or not is_enabled(FLAG_CLASSIFIER_ENABLED): - Photo.needs_review = (label == 'other')
return {'status': 'skipped', 'reason': 'classifier disabled'} """
if not is_enabled(FLAG_VISION_ENABLED):
return {'status': 'skipped', 'reason': 'vision disabled'}
image = _load_thumb(photo_id, "medium") image = _load_thumb(photo_id, "medium")
if image is None: if image is None:
@@ -297,30 +93,28 @@ def classify_content(self, photo_id: str):
try: try:
from app.services.vision.registry import registry from app.services.vision.registry import registry
classifier = registry.get_classifier() classifier = registry.get_classifier()
results = classifier.classify(image) result = classifier.classify(image)
except Exception as exc: except Exception as exc:
logger.exception("classify_content failed for %s", photo_id) logger.exception("classify_content failed for %s", photo_id)
raise self.retry(exc=exc, countdown=60) 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 import Photo
from app.models.tags import Tag, photo_tags from app.models.tags import Tag, photo_tags
source_name = "vision:clip_classifier" source_name = "vision:clip_classifier"
best = results[0] label = result.label
confidence = result.confidence
session = _get_sync_session() session = _get_sync_session()
try: try:
# Get the photo's user_id so tags inherit ownership.
photo = session.execute( photo = session.execute(
select(Photo).where(Photo.id == photo_id) select(Photo).where(Photo.id == photo_id)
).scalar_one_or_none() ).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( session.execute(
delete(photo_tags).where( delete(photo_tags).where(
photo_tags.c.photo_id == photo_id, 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( 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() ).scalar_one_or_none()
if not tag: 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.add(tag)
session.flush() session.flush()
@@ -342,232 +136,16 @@ def classify_content(self, photo_id: str):
photo_tags.insert().values( photo_tags.insert().values(
photo_id=photo_id, photo_id=photo_id,
tag_id=tag.id, tag_id=tag.id,
confidence=best.confidence, confidence=confidence,
source=source_name, 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) session.execute(
return {'status': 'success', 'photo_id': photo_id, 'content_type': best.label} update(Photo)
.where(Photo.id == photo_id)
.values(needs_review=(label == 'other'))
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.commit() session.commit()
except Exception: except Exception:
@@ -576,103 +154,41 @@ def recluster_faces(self):
finally: finally:
session.close() session.close()
n_clusters = len(cluster_tag_map) logger.info("[%s] Classified %s as %s (%.2f)", self.request.id, photo_id, label, confidence)
logger.info("Face clustering: %d clusters from %d faces", n_clusters, len(face_rows)) return {'status': 'success', 'photo_id': photo_id, 'label': label}
return {'status': 'success', 'clusters': n_clusters, 'faces': len(face_rows)}
@shared_task(name='backfill_vision', bind=True, max_retries=10) @shared_task(name='backfill_vision', bind=True, max_retries=10)
def backfill_vision(self, task: str | None = None, limit: int | None = None): def backfill_vision(self, limit: int | None = None, **_ignored):
"""Queue vision tasks for photos that haven't been processed yet. """Queue classify_content for photos without a content_type tag."""
Uses a sync DB connection to avoid asyncpg conflicts in Celery."""
if not _vision_worker_ready(): if not _vision_worker_ready():
logger.info("Vision worker not ready yet — retrying in 30s") logger.info("Vision worker not ready yet — retrying in 30s")
raise self.retry(countdown=30) 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" ordering = "ORDER BY p.taken_at DESC NULLS LAST, p.added_at DESC NULLS LAST"
limit_clause = " LIMIT :lim" if limit else "" limit_clause = " LIMIT :lim" if limit else ""
params: dict = {"model": model_name} params: dict = {}
if limit: if limit:
params["lim"] = int(limit) params["lim"] = int(limit)
session = _get_sync_session() session = _get_sync_session()
try: try:
# Each query finds photos missing a specific pipeline output so sql = f"""
# enabling a new processor after import still back-fills. SELECT p.id FROM photos p
embed_ids = [] WHERE p.processing_status = 'completed'
if task in ('embed', None): AND NOT EXISTS (
sql = f""" SELECT 1 FROM photo_tags pt
SELECT p.id FROM photos p WHERE pt.photo_id = p.id
LEFT JOIN embeddings e ON e.photo_id = p.id AND e.model = :model AND pt.source = 'vision:clip_classifier'
WHERE e.photo_id IS NULL AND p.processing_status = 'completed' )
{ordering}{limit_clause} {ordering}{limit_clause}
""" """
embed_ids = [r[0] for r in session.execute(sa_text(sql), params).fetchall()] 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
finally: finally:
session.close() session.close()
# Dispatch — deduplicate across query results. for pid in ids:
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:
classify_content.delay(pid) classify_content.delay(pid)
logger.info("Backfill queued %d photos for vision processing", len(all_ids)) logger.info("Backfill queued %d photos for classification", len(ids))
return {'status': 'queued', 'count': len(all_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) aiosqlite==0.19.0 # SQLite escape hatch (docker-compose.sqlite.yml override)
asyncpg==0.29.0 # async Postgres driver (default) asyncpg==0.29.0 # async Postgres driver (default)
psycopg2-binary==2.9.9 # sync Postgres driver, used by Alembic CLI psycopg2-binary==2.9.9 # sync Postgres driver, used by Alembic CLI
pgvector==0.2.5 # pgvector SQLAlchemy types
alembic==1.13.1 alembic==1.13.1
# Redis and Celery # Redis and Celery
@@ -42,11 +41,6 @@ watchfiles==0.21.0
# Vision pipeline (ONNX Runtime CPU inference) # Vision pipeline (ONNX Runtime CPU inference)
onnxruntime==1.18.1 onnxruntime==1.18.1
open-clip-torch==2.24.0 # tokenizer + export helper; inference via ONNX 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 numpy>=1.26.0,<2.0
# Utilities # 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-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-context-menu": "^2.1.5",
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7", "@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-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0", "@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-query": "^5.17.0", "@tanstack/react-query": "^5.17.0",
"@tanstack/react-virtual": "^3.0.1", "@tanstack/react-virtual": "^3.0.1",
"axios": "^1.6.5", "axios": "^1.6.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"cmdk": "^1.1.1",
"date-fns": "^3.2.0", "date-fns": "^3.2.0",
"framer-motion": "^10.18.0", "framer-motion": "^10.18.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.303.0", "lucide-react": "^0.303.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-day-picker": "^8.10.1",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.4.3", "react-hotkeys-hook": "^4.4.3",
"react-intersection-observer": "^9.5.3", "react-intersection-observer": "^9.5.3",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
"react-leaflet-cluster": "^2.1.0", "react-leaflet-cluster": "^2.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^2.2.0", "tailwind-merge": "^2.2.0",
"tailwindcss-animate": "^1.0.7",
"zustand": "^4.4.7" "zustand": "^4.4.7"
}, },
"devDependencies": { "devDependencies": {
@@ -63,7 +73,6 @@
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
@@ -973,7 +982,6 @@
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
@@ -995,7 +1003,6 @@
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
@@ -1005,14 +1012,12 @@
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@jridgewell/trace-mapping": { "node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31", "version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/resolve-uri": "^3.1.0",
@@ -1023,7 +1028,6 @@
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@nodelib/fs.stat": "2.0.5", "@nodelib/fs.stat": "2.0.5",
@@ -1037,7 +1041,6 @@
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 8" "node": ">= 8"
@@ -1047,7 +1050,6 @@
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@nodelib/fs.scandir": "2.1.5", "@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": { "node_modules/@radix-ui/react-arrow": {
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", "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": { "node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", "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": { "node_modules/@radix-ui/react-direction": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", "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": { "node_modules/@radix-ui/react-menu": {
"version": "2.1.16", "version": "2.1.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", "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": { "node_modules/@radix-ui/react-popover": {
"version": "1.1.15", "version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", "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": { "node_modules/@radix-ui/react-popper": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", "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": { "node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.11", "version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", "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": { "node_modules/@radix-ui/react-separator": {
"version": "1.1.8", "version": "1.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", "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": { "node_modules/@radix-ui/react-slider": {
"version": "1.3.6", "version": "1.3.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", "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": { "node_modules/@radix-ui/react-slot": {
"version": "1.2.3", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-compose-refs": "1.1.2" "@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": { "node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", "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": { "node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", "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", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/anymatch": { "node_modules/anymatch": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"normalize-path": "^3.0.0", "normalize-path": "^3.0.0",
@@ -3094,7 +3288,6 @@
"version": "5.0.2", "version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/argparse": { "node_modules/argparse": {
@@ -3204,7 +3397,6 @@
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -3227,7 +3419,6 @@
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fill-range": "^7.1.1" "fill-range": "^7.1.1"
@@ -3297,7 +3488,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -3345,7 +3535,6 @@
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"anymatch": "~3.1.2", "anymatch": "~3.1.2",
@@ -3370,7 +3559,6 @@
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"is-glob": "^4.0.1" "is-glob": "^4.0.1"
@@ -3379,6 +3567,18 @@
"node": ">= 6" "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": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -3388,6 +3588,22 @@
"node": ">=6" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3424,7 +3640,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -3463,7 +3678,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"cssesc": "bin/cssesc" "cssesc": "bin/cssesc"
@@ -3533,7 +3747,6 @@
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/dir-glob": { "node_modules/dir-glob": {
@@ -3553,7 +3766,6 @@
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/doctrine": { "node_modules/doctrine": {
@@ -3906,7 +4118,6 @@
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.stat": "^2.0.2",
@@ -3923,7 +4134,6 @@
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"is-glob": "^4.0.1" "is-glob": "^4.0.1"
@@ -3950,7 +4160,6 @@
"version": "1.20.1", "version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"reusify": "^1.0.4" "reusify": "^1.0.4"
@@ -3973,7 +4182,6 @@
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"to-regex-range": "^5.0.1" "to-regex-range": "^5.0.1"
@@ -4106,7 +4314,6 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -4208,7 +4415,6 @@
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"is-glob": "^4.0.3" "is-glob": "^4.0.3"
@@ -4406,7 +4612,6 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"binary-extensions": "^2.0.0" "binary-extensions": "^2.0.0"
@@ -4419,7 +4624,6 @@
"version": "2.16.1", "version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"hasown": "^2.0.2" "hasown": "^2.0.2"
@@ -4435,7 +4639,6 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -4445,7 +4648,6 @@
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-extglob": "^2.1.1" "is-extglob": "^2.1.1"
@@ -4458,7 +4660,6 @@
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.12.0" "node": ">=0.12.0"
@@ -4485,7 +4686,6 @@
"version": "1.21.7", "version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
@@ -4600,7 +4800,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=14" "node": ">=14"
@@ -4613,7 +4812,6 @@
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/locate-path": { "node_modules/locate-path": {
@@ -4683,7 +4881,6 @@
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 8" "node": ">= 8"
@@ -4693,7 +4890,6 @@
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"braces": "^3.0.3", "braces": "^3.0.3",
@@ -4751,7 +4947,6 @@
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"any-promise": "^1.0.0", "any-promise": "^1.0.0",
@@ -4763,7 +4958,6 @@
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -4796,7 +4990,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -4806,7 +4999,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -4816,7 +5008,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -4929,7 +5120,6 @@
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-type": { "node_modules/path-type": {
@@ -4946,14 +5136,12 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
@@ -4966,7 +5154,6 @@
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -4976,7 +5163,6 @@
"version": "4.0.7", "version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -4986,7 +5172,6 @@
"version": "8.5.8", "version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -5015,7 +5200,6 @@
"version": "15.1.0", "version": "15.1.0",
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"postcss-value-parser": "^4.0.0", "postcss-value-parser": "^4.0.0",
@@ -5033,7 +5217,6 @@
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
"integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -5059,7 +5242,6 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -5102,7 +5284,6 @@
"version": "6.2.0", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -5128,7 +5309,6 @@
"version": "6.1.2", "version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cssesc": "^3.0.0", "cssesc": "^3.0.0",
@@ -5142,7 +5322,6 @@
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
@@ -5178,7 +5357,6 @@
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -5207,6 +5385,20 @@
"node": ">=0.10.0" "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": { "node_modules/react-dom": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@@ -5357,7 +5549,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"pify": "^2.3.0" "pify": "^2.3.0"
@@ -5367,7 +5558,6 @@
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"picomatch": "^2.2.1" "picomatch": "^2.2.1"
@@ -5380,7 +5570,6 @@
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-core-module": "^2.16.1", "is-core-module": "^2.16.1",
@@ -5411,7 +5600,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"iojs": ">=1.0.0", "iojs": ">=1.0.0",
@@ -5484,7 +5672,6 @@
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -5559,11 +5746,20 @@
"node": ">=8" "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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -5599,7 +5795,6 @@
"version": "3.35.1", "version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/gen-mapping": "^0.3.2", "@jridgewell/gen-mapping": "^0.3.2",
@@ -5635,7 +5830,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -5658,7 +5852,6 @@
"version": "3.4.19", "version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0", "@alloc/quick-lru": "^5.2.0",
@@ -5692,6 +5885,15 @@
"node": ">=14.0.0" "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": { "node_modules/text-table": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -5703,7 +5905,6 @@
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"any-promise": "^1.0.0" "any-promise": "^1.0.0"
@@ -5713,7 +5914,6 @@
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"thenify": ">= 3.1.0 < 4" "thenify": ">= 3.1.0 < 4"
@@ -5726,7 +5926,6 @@
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -5743,7 +5942,6 @@
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
@@ -5761,7 +5959,6 @@
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -5774,7 +5971,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-number": "^7.0.0" "is-number": "^7.0.0"
@@ -5800,7 +5996,6 @@
"version": "0.1.13", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/tslib": { "node_modules/tslib": {
@@ -5946,7 +6141,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/vite": { "node_modules/vite": {

View File

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

View File

@@ -1,9 +1,8 @@
import { useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Timeline } from './components/timeline/Timeline' import { Timeline } from './components/timeline/Timeline'
import { DuplicatesView } from './components/duplicates/DuplicatesView' import { DuplicatesView } from './components/duplicates/DuplicatesView'
import { MapView } from './components/map/MapView' import { MapView } from './components/map/MapView'
import { MemoriesView } from './components/memories/MemoriesView' import { MemoriesView } from './components/memories/MemoriesView'
import { PeopleView } from './components/people/PeopleView'
import { TagsView } from './components/tags/TagsView' import { TagsView } from './components/tags/TagsView'
import { ColorsView } from './components/colors/ColorsView' import { ColorsView } from './components/colors/ColorsView'
import { RatedView } from './components/rated/RatedView' import { RatedView } from './components/rated/RatedView'
@@ -25,13 +24,30 @@ import { usePhotosQuery } from './hooks/usePhotosQuery'
import { AuthProvider, useAuth } from './contexts/AuthContext' import { AuthProvider, useAuth } from './contexts/AuthContext'
import { LoginPage } from './components/auth/LoginPage' import { LoginPage } from './components/auth/LoginPage'
import { SetupPage } from './components/auth/SetupPage' import { SetupPage } from './components/auth/SetupPage'
import { TooltipProvider } from '@/components/ui/tooltip'
function MainApp() { function MainApp() {
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true) const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
const [rightSidebarOpen, setRightSidebarOpen] = useState(true) const [rightSidebarOpen, setRightSidebarOpen] = useState(true)
const viewMode = usePhotoStore((state) => state.viewMode) const viewMode = usePhotoStore((state) => state.viewMode)
const activePhotoId = usePhotoStore((state) => state.activePhotoId)
const currentSection = useFilterStore((s) => s.currentSection) 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. // Bidirectional sync of filter store with URL query params.
useFilterUrlSync() useFilterUrlSync()
@@ -56,13 +72,9 @@ function MainApp() {
const showRightSidebar = rightSidebarOpen && !isSettings const showRightSidebar = rightSidebarOpen && !isSettings
return ( return (
<TooltipProvider delayDuration={300}>
<div className="flex flex-col h-screen bg-bg text-text"> <div className="flex flex-col h-screen bg-bg text-text">
<TopBar <TopBar />
leftSidebarOpen={leftSidebarOpen}
rightSidebarOpen={showRightSidebar}
onExpandLeft={() => setLeftSidebarOpen(true)}
onExpandRight={() => setRightSidebarOpen(true)}
/>
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Left Sidebar */} {/* Left Sidebar */}
@@ -71,9 +83,7 @@ function MainApp() {
leftSidebarOpen ? 'w-60' : 'w-0' leftSidebarOpen ? 'w-60' : 'w-0'
} overflow-hidden border-r border-border bg-surface`} } overflow-hidden border-r border-border bg-surface`}
> >
<LeftSidebar <LeftSidebar />
onCollapse={() => setLeftSidebarOpen(false)}
/>
</div> </div>
{/* Main column — filter bar, discard bar, timeline. Lives to the {/* Main column — filter bar, discard bar, timeline. Lives to the
@@ -81,7 +91,14 @@ function MainApp() {
* across the sidebar. relative so the KeyboardHints overlay * across the sidebar. relative so the KeyboardHints overlay
* centers against this column, not the viewport. */} * centers against this column, not the viewport. */}
<div className="relative flex min-w-0 flex-1 flex-col"> <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 />} {!isSettings && <DiscardActionBar />}
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{currentSection === 'settings' ? ( {currentSection === 'settings' ? (
@@ -92,8 +109,6 @@ function MainApp() {
<MemoriesView /> <MemoriesView />
) : currentSection === 'duplicates' ? ( ) : currentSection === 'duplicates' ? (
<DuplicatesView /> <DuplicatesView />
) : currentSection === 'people' ? (
<PeopleView />
) : currentSection === 'tags' ? ( ) : currentSection === 'tags' ? (
<TagsView /> <TagsView />
) : currentSection === 'colors' ? ( ) : currentSection === 'colors' ? (
@@ -104,7 +119,7 @@ function MainApp() {
<Timeline /> <Timeline />
)} )}
</div> </div>
{!isSettings && <KeyboardHints />} {!isSettings && viewMode !== 'preview' && <KeyboardHints />}
</div> </div>
{/* Right Sidebar */} {/* Right Sidebar */}
@@ -113,7 +128,7 @@ function MainApp() {
showRightSidebar ? 'w-72' : 'w-0' showRightSidebar ? 'w-72' : 'w-0'
} overflow-hidden border-l border-border bg-surface`} } overflow-hidden border-l border-border bg-surface`}
> >
<RightSidebar onCollapse={() => setRightSidebarOpen(false)} /> <RightSidebar />
</div> </div>
</div> </div>
@@ -127,6 +142,7 @@ function MainApp() {
{viewMode === 'preview' && <PreviewView />} {viewMode === 'preview' && <PreviewView />}
</div> </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 { 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() { export function KeyboardHints() {
const selectedCount = usePhotoStore((state) => state.selectedPhotos.length) const selectedCount = usePhotoStore((s) => s.selectedPhotos.length)
const viewMode = usePhotoStore((state) => state.viewMode) 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 const [collapsed, setCollapsed] = useState(
// would just be confusing. Hide them. () => typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1'
if (viewMode === 'preview') return null )
useEffect(() => {
localStorage.setItem(STORAGE_KEY, collapsed ? '1' : '0')
}, [collapsed])
const hints = selectedCount > 0 // `H` toggles the panel. `?` (shift+/) collides with the global `/`
? [ // search shortcut, so we use a plain letter instead.
{ key: '1-5', action: 'Rate' }, useHotkeys('h', () => setCollapsed((c) => !c), { preventDefault: true })
{ key: 'P', action: 'Pick → heap' },
{ key: 'X', action: 'Discard' }, const hints = getHints({ selectedCount, currentSection, viewMode })
{ key: 'Space', action: 'Preview' },
{ key: 'I', action: 'Info panel' }, // Nothing relevant to show — hide entirely.
{ key: 'Esc', action: 'Deselect' }, if (hints.length === 0) return null
]
: [
{ key: '↑↓←→', action: 'Navigate' },
{ key: 'Space', action: 'Preview' },
{ key: 'Tab', action: 'Library panel' },
{ key: 'I', action: 'Info panel' },
{ key: '/', action: 'Search' },
]
return ( return (
// Absolute (not fixed) so the parent's flex/position context can <div className="pointer-events-none absolute bottom-0 left-1/2 z-30 -translate-x-1/2 pb-4">
// center it relative to the timeline area, not the viewport. Mount {collapsed ? (
// inside the main column in App.tsx so it isn't offset by the // Collapsed handle: a small pill peeking from the bottom so the
// sidebar widths. // user can re-open the panel without remembering the shortcut.
<div className="pointer-events-none absolute bottom-4 left-1/2 z-30 -translate-x-1/2"> <button
{/* Near-opaque dark pill so the hints stay legible against busy type="button"
* thumbnails. The previous bg-surface/40 + 5% white ring left onClick={() => setCollapsed(false)}
* text washed out when a bright photo sat directly behind it. */} 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"
<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"> title="Show shortcuts (H)"
{hints.map((hint, i) => ( >
<div key={i} className="flex items-center gap-1.5"> <ChevronUp className="h-3 w-3" />
<kbd className="rounded bg-white/15 px-1.5 py-0.5 text-[11px] font-medium text-white shadow-sm"> Shortcuts
{hint.key} <kbd className="rounded bg-white/15 px-1 py-0.5 text-[10px] font-medium text-white">
</kbd> H
<span className="whitespace-nowrap text-xs text-white/85"> </kbd>
{hint.action} </button>
</span> ) : (
{i < hints.length - 1 && ( <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> <span className="ml-1 text-white/30"></span>
)} </div>
</div> ))}
))} <button
{selectedCount > 0 && ( type="button"
<> onClick={() => setCollapsed(true)}
<span className="text-white/30"></span> 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"
<span className="whitespace-nowrap text-xs font-semibold text-primary"> title="Hide shortcuts (H)"
{selectedCount} selected >
</span> <kbd className="rounded bg-white/15 px-1 py-0.5 text-[10px] font-medium text-white">
</> H
)} </kbd>
</div> <ChevronDown className="h-3 w-3" />
</button>
</div>
)}
</div> </div>
) )
} }

View File

@@ -1,8 +1,6 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef } from 'react'
import { FolderOpen, Loader2, Check, AlertCircle, X, Brain, Sparkles } from 'lucide-react'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { library, WorkerStatus } from '../services/api' import { library, WorkerStatus } from '../services/api'
import clsx from 'clsx'
interface ScanStatus { interface ScanStatus {
is_scanning: boolean is_scanning: boolean
@@ -14,9 +12,14 @@ interface ScanStatus {
type Phase = 'idle' | 'scanning' | 'processing' | 'done' 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() { export function ScanProgress() {
const [isVisible, setIsVisible] = useState(false)
const [isMinimized, setIsMinimized] = useState(false)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const wasScanningRef = useRef(false) const wasScanningRef = useRef(false)
const wasProcessingRef = useRef(false) const wasProcessingRef = useRef(false)
@@ -43,7 +46,6 @@ export function ScanProgress() {
enabled: true, enabled: true,
}) })
const visionActive = visionQueued(workerStatus)
const totalActive = totalQueued(workerStatus) const totalActive = totalQueued(workerStatus)
const phase: Phase = isScanning const phase: Phase = isScanning
@@ -54,18 +56,11 @@ export function ScanProgress() {
useEffect(() => { useEffect(() => {
if (phase === 'scanning') { if (phase === 'scanning') {
setIsVisible(true)
setIsMinimized(false)
wasScanningRef.current = true wasScanningRef.current = true
wasProcessingRef.current = false wasProcessingRef.current = false
} else if (phase === 'processing') { } 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 wasProcessingRef.current = true
if (wasScanningRef.current) { if (wasScanningRef.current) {
// Scan just finished — invalidate data caches.
wasScanningRef.current = false wasScanningRef.current = false
queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: ['folders'] }) queryClient.invalidateQueries({ queryKey: ['folders'] })
@@ -76,7 +71,6 @@ export function ScanProgress() {
} }
} else if (phase === 'idle') { } else if (phase === 'idle') {
if (wasScanningRef.current) { if (wasScanningRef.current) {
// Scan finished with no queued processing (small import).
wasScanningRef.current = false wasScanningRef.current = false
queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: ['folders'] }) queryClient.invalidateQueries({ queryKey: ['folders'] })
@@ -86,172 +80,15 @@ export function ScanProgress() {
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] }) queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
} }
if (wasProcessingRef.current) { if (wasProcessingRef.current) {
// Processing just drained — refresh tags (new clusters/objects).
wasProcessingRef.current = false wasProcessingRef.current = false
queryClient.invalidateQueries({ queryKey: ['tags'] }) queryClient.invalidateQueries({ queryKey: ['tags'] })
queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] }) queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
} }
if (isVisible) {
setTimeout(() => setIsVisible(false), 3000)
}
} }
}, [phase, isVisible, queryClient]) }, [phase, queryClient])
if (!isVisible) return null 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
} }
function totalQueued(ws: WorkerStatus | undefined): number { function totalQueued(ws: WorkerStatus | undefined): number {

View File

@@ -1,132 +1,34 @@
import { useEffect, useState } from 'react' import { toast as sonnerToast } from 'sonner'
import { CheckCircle, XCircle, Info, AlertCircle, X } from 'lucide-react' import { Toaster } from '@/components/ui/sonner'
import clsx from 'clsx'
export interface ToastAction { export interface ToastAction {
label: string label: string
onClick: () => void onClick: () => void
} }
export interface Toast { /** Shim that preserves the legacy `(title, message?, action?)` call
id: string * shape used throughout the codebase while delegating to sonner for
type: 'success' | 'error' | 'info' | 'warning' * actual rendering. Call sites don't need to change. Actions auto-
title: string * extend the toast duration to 8s so users have time to hit Undo. */
message?: string const build = (message?: string, action?: ToastAction, duration = 5000) => ({
duration?: number description: message,
action?: ToastAction action: action && { label: action.label, onClick: action.onClick },
} duration: action ? Math.max(duration, 8000) : duration,
})
// Global toast state (in production, use Zustand or Context)
let toastListeners: ((toasts: Toast[]) => void)[] = []
let toastList: Toast[] = []
export const toast = { export const toast = {
success: (title: string, message?: string, action?: ToastAction) => 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) => 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) => 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) => warning: (title: string, message?: string, action?: ToastAction) =>
addToast('warning', title, message, 5000, action), sonnerToast.warning(title, build(message, 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))
} }
/** Mounted once near the App root. Delegates to sonner's `<Toaster />`
* with palette-matched class overrides (see `@/components/ui/sonner`). */
export function ToastContainer() { export function ToastContainer() {
const [toasts, setToasts] = useState<Toast[]>([]) return <Toaster />
}
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>
)
}

View File

@@ -1,12 +1,32 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Plus, Pencil, UserX, Shield, User as UserIcon } from 'lucide-react' import { Plus, Pencil, UserX, Shield, User as UserIcon } from 'lucide-react'
import { admin, type AdminUser } from '../../services/api' 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() { export function UserManagement() {
const [users, setUsers] = useState<AdminUser[]>([]) const [users, setUsers] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
const [editingUser, setEditingUser] = useState<AdminUser | null>(null) const [editingUser, setEditingUser] = useState<AdminUser | null>(null)
const [deactivatingUser, setDeactivatingUser] = useState<AdminUser | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const fetchUsers = useCallback(async () => { const fetchUsers = useCallback(async () => {
@@ -24,6 +44,18 @@ export function UserManagement() {
fetchUsers() fetchUsers()
}, [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) { if (loading) {
return <div className="p-4 text-sm text-text-muted">Loading users&hellip;</div> 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="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-text">Users</h3> <h3 className="text-sm font-semibold text-text">Users</h3>
<button <Button size="sm" onClick={() => setShowCreate(true)}>
onClick={() => setShowCreate(true)} <Plus className="mr-1 h-3 w-3" />
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" />
Add User Add User
</button> </Button>
</div> </div>
{error && ( {error && (
<div className="rounded bg-red-900/30 px-3 py-2 text-xs text-red-300"> <Alert variant="destructive">
{error} <AlertDescription>{error}</AlertDescription>
</div> </Alert>
)} )}
<table className="w-full text-xs"> <table className="w-full text-xs">
@@ -87,29 +116,25 @@ export function UserManagement() {
</td> </td>
<td className="py-1.5"> <td className="py-1.5">
<div className="flex gap-1"> <div className="flex gap-1">
<button <Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setEditingUser(u)} onClick={() => setEditingUser(u)}
className="rounded p-1 text-text-muted hover:bg-bg hover:text-text"
title="Edit user" title="Edit user"
> >
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3" />
</button> </Button>
{u.is_active && ( {u.is_active && (
<button <Button
onClick={async () => { variant="ghost"
if (!confirm(`Deactivate user "${u.username}"? Their photos will be preserved.`)) return size="icon"
try { className="h-6 w-6 hover:text-red-400"
await admin.deleteUser(u.id) onClick={() => setDeactivatingUser(u)}
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"
title="Deactivate user" title="Deactivate user"
> >
<UserX className="h-3 w-3" /> <UserX className="h-3 w-3" />
</button> </Button>
)} )}
</div> </div>
</td> </td>
@@ -118,26 +143,33 @@ export function UserManagement() {
</tbody> </tbody>
</table> </table>
{showCreate && ( <CreateUserModal
<CreateUserModal open={showCreate}
onClose={() => setShowCreate(false)} onClose={() => setShowCreate(false)}
onCreated={() => { onCreated={() => {
setShowCreate(false) setShowCreate(false)
fetchUsers() fetchUsers()
}} }}
/> />
)}
{editingUser && ( <EditUserModal
<EditUserModal user={editingUser}
user={editingUser} onClose={() => setEditingUser(null)}
onClose={() => setEditingUser(null)} onSaved={() => {
onSaved={() => { setEditingUser(null)
setEditingUser(null) fetchUsers()
fetchUsers() }}
}} />
/>
)} <ConfirmDialog
isOpen={!!deactivatingUser}
title={`Deactivate "${deactivatingUser?.username}"?`}
message="Their photos will be preserved."
confirmLabel="Deactivate"
destructive
onConfirm={handleDeactivate}
onClose={() => setDeactivatingUser(null)}
/>
</div> </div>
) )
} }
@@ -145,9 +177,11 @@ export function UserManagement() {
// ── Create User Modal ────────────────────────────────────────────────── // ── Create User Modal ──────────────────────────────────────────────────
function CreateUserModal({ function CreateUserModal({
open,
onClose, onClose,
onCreated, onCreated,
}: { }: {
open: boolean
onClose: () => void onClose: () => void
onCreated: () => void onCreated: () => void
}) { }) {
@@ -157,6 +191,16 @@ function CreateUserModal({
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
useEffect(() => {
if (open) {
setUsername('')
setPassword('')
setRole('user')
setError(null)
setLoading(false)
}
}, [open])
const handleSubmit = async () => { const handleSubmit = async () => {
setError(null) setError(null)
setLoading(true) setLoading(true)
@@ -171,56 +215,61 @@ function CreateUserModal({
} }
return ( return (
<ModalOverlay onClose={onClose} title="Add User"> <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
{error && ( <DialogContent className="max-w-sm">
<div className="rounded bg-red-900/30 px-3 py-2 text-xs text-red-300"> <DialogHeader>
{error} <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>
)} <DialogFooter>
<div className="space-y-3"> <Button variant="outline" onClick={onClose}>
<Field label="Username"> Cancel
<input </Button>
value={username} <Button onClick={handleSubmit} disabled={loading}>
onChange={(e) => setUsername(e.target.value)} {loading ? 'Creating\u2026' : 'Create'}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent" </Button>
autoFocus </DialogFooter>
/> </DialogContent>
</Field> </Dialog>
<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>
) )
} }
@@ -231,16 +280,26 @@ function EditUserModal({
onClose, onClose,
onSaved, onSaved,
}: { }: {
user: AdminUser user: AdminUser | null
onClose: () => void onClose: () => void
onSaved: () => void onSaved: () => void
}) { }) {
const [role, setRole] = useState(user.role) const [role, setRole] = useState<'user' | 'admin'>('user')
const [newPassword, setNewPassword] = useState('') const [newPassword, setNewPassword] = useState('')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
useEffect(() => {
if (user) {
setRole(user.role as 'user' | 'admin')
setNewPassword('')
setError(null)
setLoading(false)
}
}, [user])
const handleSubmit = async () => { const handleSubmit = async () => {
if (!user) return
setError(null) setError(null)
setLoading(true) setLoading(true)
try { try {
@@ -259,78 +318,54 @@ function EditUserModal({
} }
return ( return (
<ModalOverlay onClose={onClose} title={`Edit: ${user.username}`}> <Dialog open={!!user} onOpenChange={(o) => !o && onClose()}>
{error && ( <DialogContent className="max-w-sm">
<div className="rounded bg-red-900/30 px-3 py-2 text-xs text-red-300"> <DialogHeader>
{error} <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>
)} <DialogFooter>
<div className="space-y-3"> <Button variant="outline" onClick={onClose}>
<Field label="Role"> Cancel
<select </Button>
value={role} <Button onClick={handleSubmit} disabled={loading}>
onChange={(e) => setRole(e.target.value as 'user' | 'admin')} {loading ? 'Saving\u2026' : 'Save'}
className="rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent" </Button>
> </DialogFooter>
<option value="user">User</option> </DialogContent>
<option value="admin">Admin</option> </Dialog>
</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>
) )
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,10 +16,6 @@ import {
FolderSearch, FolderSearch,
Shield, Shield,
Brain, Brain,
ScanText,
UserSquare2,
Boxes,
Tags as TagsIcon,
RotateCcw, RotateCcw,
} from 'lucide-react' } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
@@ -36,6 +32,9 @@ import {
import { toast } from '../ToastContainer' import { toast } from '../ToastContainer'
import { useAuth } from '../../contexts/AuthContext' import { useAuth } from '../../contexts/AuthContext'
import { UserManagement } from '../admin/UserManagement' 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 // 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 // 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) const visibleTabs = TABS.filter((t) => !t.adminOnly || isAdmin)
return ( 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 */} {/* Tab bar */}
<div className="flex items-center gap-1 border-b border-border bg-surface px-4 py-1.5"> <div className="flex items-center border-b border-border bg-surface px-4 py-1.5">
{visibleTabs.map((tab) => ( <TabsList className="h-7 bg-transparent">
<button {visibleTabs.map((tab) => (
key={tab.id} <TabsTrigger
onClick={() => setActiveTab(tab.id)} key={tab.id}
className={clsx( value={tab.id}
'rounded px-3 py-1 text-xs font-medium transition-colors', className="h-6 px-3 text-xs data-[state=active]:bg-primary/20 data-[state=active]:text-primary"
activeTab === tab.id >
? 'bg-primary/20 text-primary' {tab.label}
: 'text-text-muted hover:bg-surface-2 hover:text-text', </TabsTrigger>
)} ))}
> </TabsList>
{tab.label}
</button>
))}
</div> </div>
{/* Tab content */} {/* Tab content */}
@@ -871,7 +871,7 @@ export function SettingsPage() {
</div> </div>
</div> </div>
</div> </Tabs>
) )
} }
@@ -1107,56 +1107,20 @@ interface AiFeaturesTabProps {
) => Promise<void> ) => 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<{ const FLAG_META: Array<{
id: string id: string
label: string label: string
description: string description: string
icon: React.ReactNode 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', id: 'vision.enabled',
label: 'Vision pipeline (master switch)', label: 'Vision classifier',
description: description:
'When off, every AI stage below is skipped — including newly uploaded photos. ' + 'Binary photo-vs-other classifier. Flags screenshots, documents, memes and ' +
'Existing results stay intact.', 'scans with "needs review" so they can be triaged.',
icon: <Sparkles className="h-3.5 w-3.5" />, 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) { function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {
@@ -1183,12 +1147,11 @@ function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {
queryClient.invalidateQueries({ queryKey: ['features'] }) queryClient.invalidateQueries({ queryKey: ['features'] })
} }
type BackfillTask = 'embed' | 'ocr' | 'detect' | 'faces' | 'classify' | null const runBackfill = () =>
const runBackfill = (task: BackfillTask) =>
runAction( runAction(
`ai-backfill:${task ?? 'all'}`, `ai-backfill:all`,
() => adminApi.triggerAiBackfill({ task }), () => adminApi.triggerAiBackfill({}),
task ? `Backfill queued for ${task}` : 'Full backfill queued', 'Classifier backfill queued',
(r) => `Celery task ${r.task_id}`, (r) => `Celery task ${r.task_id}`,
) )
@@ -1252,51 +1215,28 @@ function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {
</p> </p>
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<button <Switch
role="switch" checked={state.effective}
aria-checked={state.effective} onCheckedChange={(v) => applyFlag(meta.id, v)}
onClick={() => applyFlag(meta.id, !state.effective)}
disabled={isBusy || (dimmed && !isMaster)} 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'} 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 && ( {state.overridden && (
<button <Button
variant="outline"
size="icon"
onClick={() => applyFlag(meta.id, null)} onClick={() => applyFlag(meta.id, null)}
disabled={isBusy} 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" title="Reset to YAML default"
aria-label="Reset override" aria-label="Reset override"
> >
<RotateCcw className="h-3 w-3" /> <RotateCcw className="h-3 w-3" />
</button> </Button>
)} )}
</div> </div>
</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> </div>
) )
})} })}
@@ -1306,35 +1246,17 @@ function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {
<Section icon={<Cpu className="h-4 w-4" />} title="Manual pipeline triggers"> <Section icon={<Cpu className="h-4 w-4" />} title="Manual pipeline triggers">
<p className="text-xs text-text-muted"> <p className="text-xs text-text-muted">
Run a full pass across the enabled stages, recompute face Run the classifier over any photos that haven't been classified yet,
clusters, or force a fresh filesystem scan. All three are safe or force a fresh filesystem scan. Both are safe to run repeatedly.
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.
</p> </p>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<ActionButton <ActionButton
loading={!!busy['ai-backfill:all']} loading={!!busy['ai-backfill:all']}
onClick={() => runBackfill(null)} onClick={() => runBackfill()}
disabled={masterOff} disabled={masterOff}
> >
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
Run full vision backfill Run classifier 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
</ActionButton> </ActionButton>
<ActionButton <ActionButton
loading={!!busy['rescan-full']} 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 { discard as discardApi, photos as photosApi } from '../../services/api'
import { toast } from '../ToastContainer' import { toast } from '../ToastContainer'
import { ConfirmDialog } from '../dialogs/ConfirmDialog' import { ConfirmDialog } from '../dialogs/ConfirmDialog'
import { Button } from '@/components/ui/button'
import { registerUndoable } from '../../store/undoStore' import { registerUndoable } from '../../store/undoStore'
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery' import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
@@ -120,35 +121,38 @@ export function DiscardActionBar() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{selected > 0 && ( {selected > 0 && (
<> <>
<button <Button
variant="secondary"
size="sm"
onClick={() => restoreMutation.mutate(selectedPhotos)} onClick={() => restoreMutation.mutate(selectedPhotos)}
disabled={restoreMutation.isPending} 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)" 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} Restore {selected}
</button> </Button>
<button <Button
size="sm"
onClick={() => setDeleteSelectedOpen(true)} onClick={() => setDeleteSelectedOpen(true)}
disabled={deleteSelectedMutation.isPending} 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" 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} Delete {selected}
</button> </Button>
</> </>
)} )}
<button <Button
size="sm"
onClick={() => setConfirmOpen(true)} onClick={() => setConfirmOpen(true)}
disabled={total === 0 || emptyMutation.isPending} 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" 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 Empty discard pile
</button> </Button>
</div> </div>
</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 { Copy, Layers, Sparkles, Trash2, Loader2, Info, Crown } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Button } from '@/components/ui/button'
import { import {
useDuplicateGroupsQuery, useDuplicateGroupsQuery,
DUPLICATE_GROUPS_QUERY_KEY, DUPLICATE_GROUPS_QUERY_KEY,
@@ -269,7 +270,9 @@ function DuplicateGroupSection({
)} )}
</span> </span>
</div> </div>
<button <Button
variant="outline"
size="sm"
onClick={() => { onClick={() => {
const discardIds = group.members const discardIds = group.members
.filter((m) => m.id !== best.id) .filter((m) => m.id !== best.id)
@@ -278,16 +281,12 @@ function DuplicateGroupSection({
onKeepBest(discardIds) onKeepBest(discardIds)
}} }}
disabled={isPending} disabled={isPending}
className={clsx( className="hover:border-reject/50 hover:bg-reject/10 hover:text-reject"
'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'
)}
title="Keep the highest-resolution copy and discard the rest" 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} Keep best, discard {discardCount}
</button> </Button>
</header> </header>
<div <div

View File

@@ -1,5 +1,15 @@
import { useEffect, useMemo, useRef, useState } from 'react' 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 clsx from 'clsx'
import { import {
useFilterStore, useFilterStore,
@@ -8,9 +18,20 @@ import {
type SortField, type SortField,
} from '../../store/filterStore' } from '../../store/filterStore'
import { useTagsQuery } from '../../hooks/useTagsQuery' import { useTagsQuery } from '../../hooks/useTagsQuery'
import type { Tag } from '../../services/api' import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import { FilterPill } from './FilterPill' import { FilterPill } from './FilterPill'
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels' 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 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. * underlying control, and shows a short value summary inline when active.
* Replaces the old expandable FilterBar + ActiveFilterChips combo. * 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 filterState = useFilterStore()
const dateFrom = useFilterStore((s) => s.dateFrom) const dateFrom = useFilterStore((s) => s.dateFrom)
const dateTo = useFilterStore((s) => s.dateTo) const dateTo = useFilterStore((s) => s.dateTo)
@@ -46,6 +79,8 @@ export function FilterBar() {
const sortBy = useFilterStore((s) => s.sortBy) const sortBy = useFilterStore((s) => s.sortBy)
const sortOrder = useFilterStore((s) => s.sortOrder) const sortOrder = useFilterStore((s) => s.sortOrder)
const tagIds = useFilterStore((s) => s.tagIds) const tagIds = useFilterStore((s) => s.tagIds)
const needsReview = useFilterStore((s) => s.needsReview)
const setNeedsReview = useFilterStore((s) => s.setNeedsReview)
const currentSection = useFilterStore((s) => s.currentSection) const currentSection = useFilterStore((s) => s.currentSection)
// Only the Flag pill is hidden inside the Discarded section. Flag has // 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 setColorLabel = useFilterStore((s) => s.setColorLabel)
const setFlag = useFilterStore((s) => s.setFlag) const setFlag = useFilterStore((s) => s.setFlag)
const setTagIds = useFilterStore((s) => s.setTagIds) const setTagIds = useFilterStore((s) => s.setTagIds)
const toggleTagId = useFilterStore((s) => s.toggleTagId)
const setSortBy = useFilterStore((s) => s.setSortBy) const setSortBy = useFilterStore((s) => s.setSortBy)
const toggleSortOrder = useFilterStore((s) => s.toggleSortOrder) const toggleSortOrder = useFilterStore((s) => s.toggleSortOrder)
const clearAll = useFilterStore((s) => s.clearAll) const clearAll = useFilterStore((s) => s.clearAll)
@@ -106,12 +140,14 @@ export function FilterBar() {
const colorActive = colorLabel !== null const colorActive = colorLabel !== null
const colorValue = colorActive ? colorLabel : null const colorValue = colorActive ? colorLabel : null
const flagActive = flag !== 'any' const flagActive = flag !== 'any' || needsReview
const flagValue = flagActive const flagValue = needsReview
? flag === 'date_warning' ? 'needs review'
? 'date issues' : flag !== 'any'
: flag ? flag === 'date_warning'
: null ? 'date issues'
: flag
: null
const tagActive = tagIds.length > 0 const tagActive = tagIds.length > 0
const activeTagNames = allTags const activeTagNames = allTags
@@ -129,258 +165,281 @@ export function FilterBar() {
const anyActive = hasActiveFilters(filterState) const anyActive = hasActiveFilters(filterState)
return ( return (
// Fixed bar height + py-0 so neither the active filter pills nor the <div className="flex h-9 items-center gap-3 border-b border-border bg-surface px-3 py-0">
// clear-all button can stretch the bar vertically. The fixed h-11 {/* Left sidebar toggle — pinned to the far-left edge of the bar so it
// matches the h-7 pills + 8px symmetric vertical padding. * sits flush against the panel it controls (or the viewport edge
<div className="flex h-11 items-center gap-3 border-b border-border bg-surface px-3 py-0"> * 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. */} {/* Pills — left side, scroll horizontally if they overflow. */}
<div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto"> <div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto">
{/* Date */} {/* 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 && (
<FilterPill <FilterPill
label="Flag" label="Date"
value={flagValue} value={dateValue}
isActive={flagActive} isActive={dateActive}
onClear={() => setFlag('any')} onClear={() => {
setDateFrom(null)
setDateTo(null)
}}
> >
<div className="flex flex-col gap-1"> <DateRangePicker
<button from={dateFrom}
onClick={() => setFlag('any')} to={dateTo}
className={clsx( onFromChange={setDateFrom}
'rounded px-2 py-1 text-left text-xs transition-colors', onToChange={setDateTo}
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([])}
/> />
</FilterPill> </FilterPill>
)}
{/* Sort — always present, never "active/inactive" since there's {/* Type */}
always a value. */} <FilterPill
<FilterPill label="Sort" value={sortValue} isActive> label="Type"
<div className="space-y-2"> value={typeValue}
<select isActive={typeActive}
value={sortBy} onClear={() => mediaTypes.forEach((t) => toggleMediaType(t))}
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" <MultiSelect
> searchable={false}
{SORT_OPTIONS.map((opt) => ( pinSelected={false}
<option key={opt.value} value={opt.value}> options={MEDIA_TYPES.map(({ value, label }) => ({
{opt.label} value,
</option> label,
))} }))}
</select> values={mediaTypes}
<button onChange={(next) => {
onClick={toggleSortOrder} // Diff against current selection — store uses per-item toggles.
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" for (const t of MEDIA_TYPES.map((m) => m.value)) {
> const wasOn = mediaTypes.includes(t)
{sortOrder === 'desc' ? ( const nowOn = next.includes(t as MediaType)
<> if (wasOn !== nowOn) toggleMediaType(t)
<ArrowDown className="h-3.5 w-3.5" /> }
Descending }}
</> />
) : ( </FilterPill>
<>
<ArrowUp className="h-3.5 w-3.5" /> {/* Rating */}
Ascending <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>
</div> </FilterPill>
</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 * cluster on the right. Lives inside the pills container so it
* shares the same flex group and gap and reads as "another * shares the same flex group and gap and reads as "another
* pill". Only renders when any filter is active. */} * pill". Only renders when any filter is active. */}
{anyActive && ( {anyActive && (
<button <button
onClick={clearAll} 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" 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" title="Clear all filters in this section"
> >
Clear all Clear all
</button> </button>
)} )}
</div> </div>
{/* Search — pinned to the right edge of the bar. Same id as before {/* Search — pinned to the right edge of the bar. Same id as before
* so the global "/" focus shortcut still finds it. */} * so the global "/" focus shortcut still finds it. */}
<div className="relative w-56 flex-shrink-0"> <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" /> <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" id="topbar-search"
type="text" type="text"
value={searchQuery} value={searchQuery}
@@ -393,7 +452,7 @@ export function FilterBar() {
} }
}} }}
placeholder="Search photos…" 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 && ( {searchQuery && (
<button <button
@@ -409,148 +468,129 @@ export function FilterBar() {
</button> </button>
)} )}
</div> </div>
</div>
)
}
interface TagFilterPopoverProps { {/* Right sidebar toggle — pinned to the far-right edge. */}
allTags: Tag[] <button
selectedIds: string[] onClick={onToggleRightSidebar}
onToggle: (id: string) => void className="flex-shrink-0 rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
onClear: () => void title={rightSidebarOpen ? 'Collapse panel (I)' : 'Expand panel (I)'}
} aria-label={rightSidebarOpen ? 'Collapse right panel' : 'Expand right panel'}
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'
)}
> >
{selected && <Check className="h-2.5 w-2.5" strokeWidth={3} />} {rightSidebarOpen ? (
</span> <PanelRightClose className="h-3.5 w-3.5" />
<span className="min-w-0 flex-1 truncate">{tag.name}</span> ) : (
<span className="flex-shrink-0 tabular-nums text-[10px] text-text-muted"> <PanelRightOpen className="h-3.5 w-3.5" />
{tag.photo_count} )}
</span> </button>
</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 { ChevronDown, X } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
interface FilterPillProps { interface FilterPillProps {
/** Category label, always shown ("Date", "Type", etc.). */ /** Category label, always shown ("Date", "Type", etc.). */
@@ -19,13 +22,18 @@ interface FilterPillProps {
children: React.ReactNode children: React.ReactNode
/** Force the popover open programmatically (rare). */ /** Force the popover open programmatically (rare). */
defaultOpen?: boolean 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 * small popover with the actual control; the popover closes on outside
* click or Escape. Active filters tint the pill primary and show their * click or Escape. Built on Radix Popover so positioning, focus
* current value inline. * management, portal rendering, and dismissal come for free — the
* pill only owns the trigger styling and the "clear" affordance.
*/ */
export function FilterPill({ export function FilterPill({
label, label,
@@ -34,128 +42,58 @@ export function FilterPill({
onClear, onClear,
children, children,
defaultOpen = false, defaultOpen = false,
contentClassName,
}: FilterPillProps) { }: 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 ( return (
<> <Popover defaultOpen={defaultOpen}>
<button <PopoverTrigger asChild>
ref={buttonRef} <button
onClick={() => setOpen((v) => !v)} title={isActive && value ? `${label}: ${value}` : label}
// Hover to see the active value as a tooltip — keeps the pill at className={clsx(
// a constant width regardless of state. The popover is the // Fixed height + py-0 so neither the X clear icon nor the
// canonical place to read/edit the filter value. // chevron can stretch the pill vertically when the active
title={isActive && value ? `${label}: ${value}` : label} // state swaps them in.
className={clsx( 'flex h-7 items-center gap-1 rounded-full border px-2.5 py-0 text-xs transition-colors',
// Fixed height + py-0 so neither the X clear icon nor the isActive
// chevron can stretch the pill vertically when the active ? 'border-primary/40 bg-primary/15 text-primary'
// state swaps them in. : 'border-border bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
'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' <span className={clsx(isActive && 'font-medium')}>{label}</span>
: 'border-border bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text' {isActive && onClear ? (
)} <span
> role="button"
<span className={clsx(isActive && 'font-medium')}>{label}</span> tabIndex={0}
{isActive && onClear ? ( onClick={(e) => {
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation()
onClear()
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation() e.stopPropagation()
onClear() onClear()
} }}
}} onKeyDown={(e) => {
// Same h-4 w-4 as the chevron slot below so swapping the if (e.key === 'Enter' || e.key === ' ') {
// two doesn't change the pill's footprint. e.preventDefault()
className="ml-0.5 inline-flex h-4 w-4 cursor-pointer items-center justify-center rounded-full hover:bg-primary/30" e.stopPropagation()
title={`Clear ${label}`} onClear()
aria-label={`Clear ${label}`} }
> }}
<X className="h-3 w-3" /> className="ml-0.5 inline-flex h-3.5 w-4 cursor-pointer items-center justify-center rounded-full hover:bg-primary/30"
</span> title={`Clear ${label}`}
) : ( aria-label={`Clear ${label}`}
<span className="inline-flex h-4 w-4 items-center justify-center"> >
<ChevronDown className="h-3 w-3 opacity-60" /> <X className="h-3 w-3" />
</span> </span>
)} ) : (
</button> <span className="inline-flex h-3.5 w-4 items-center justify-center">
<ChevronDown className="h-3 w-3 opacity-60" />
{open && </span>
createPortal( )}
<div </button>
ref={popoverRef} </PopoverTrigger>
style={{ <PopoverContent
position: 'fixed', align="start"
top: popoverPos?.top ?? -9999, className={clsx('w-auto', contentClassName ?? 'p-2')}
left: popoverPos?.left ?? -9999, >
visibility: popoverPos ? 'visible' : 'hidden', {children}
}} </PopoverContent>
className="z-50 min-w-[220px] rounded-lg border border-border bg-surface p-3 shadow-xl" </Popover>
>
{children}
</div>,
document.body
)}
</>
) )
} }

View File

@@ -92,8 +92,14 @@ export function ActiveHeapCard() {
}} }}
> >
{visible.length === 0 ? ( {visible.length === 0 ? (
<div className="flex h-full items-center justify-center px-2 text-center text-[11px] text-text-faint"> <div className="flex h-full items-center justify-center px-2 text-center">
Pick photos with P to fill the heap <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>
) : ( ) : (
<div className="relative h-full"> <div className="relative h-full">

View File

@@ -1,7 +1,6 @@
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import { X, Folder, AlertCircle } from 'lucide-react' import { Folder, AlertCircle } from 'lucide-react'
import clsx from 'clsx'
import { import {
heaps as heapsApi, heaps as heapsApi,
type Heap, type Heap,
@@ -10,6 +9,26 @@ import {
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery' import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery' import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
import { toast } from '../ToastContainer' 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 { interface FlatFolder {
id: string id: string
@@ -37,11 +56,6 @@ interface HeapConvertDialogProps {
onClose: () => void 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) { export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [targetId, setTargetId] = useState('') const [targetId, setTargetId] = useState('')
@@ -49,19 +63,15 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
const [deleteHeap, setDeleteHeap] = useState(false) const [deleteHeap, setDeleteHeap] = useState(false)
const [subfolderName, setSubfolderName] = useState('') 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 { data: tree = [] } = useFolderTreeQuery()
const folders = useMemo<FlatFolder[]>(() => flattenTree(tree), [tree]) const folders = useMemo<FlatFolder[]>(() => flattenTree(tree), [tree])
// Default to the first folder when the dialog opens or folders load.
useEffect(() => { useEffect(() => {
if (!targetId && folders.length > 0) { if (!targetId && folders.length > 0) {
setTargetId(folders[0].id) setTargetId(folders[0].id)
} }
}, [folders, targetId]) }, [folders, targetId])
// Reset state on close, prefill subfolder name when opened.
useEffect(() => { useEffect(() => {
if (heap) { if (heap) {
setSubfolderName(heap.name) setSubfolderName(heap.name)
@@ -79,8 +89,6 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
target_id: targetId, target_id: targetId,
mode, mode,
delete_heap: deleteHeap, 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, subfolder_name: subfolderName.trim() || null,
}), }),
onSuccess: (data) => { onSuccess: (data) => {
@@ -99,149 +107,130 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
toast.error('Convert failed', e?.response?.data?.detail || e.message), toast.error('Convert failed', e?.response?.data?.detail || e.message),
}) })
if (!heap) return null
const targetFolder = folders.find((f) => f.id === targetId) const targetFolder = folders.find((f) => f.id === targetId)
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <Dialog open={!!heap} onOpenChange={(o) => !o && onClose()}>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={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="space-y-4">
<div className="mb-4 flex items-center justify-between"> {/* Target picker */}
<h2 className="text-lg font-semibold text-text"> <div className="space-y-1.5">
Convert "{heap.name}" to folder <Label>Target folder</Label>
</h2> {folders.length === 0 ? (
<button <div className="rounded border border-border bg-bg px-3 py-2 text-xs text-text-muted">
onClick={onClose} No folders available
disabled={convertMutation.isPending} </div>
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text" ) : (
> <Select value={targetId} onValueChange={setTargetId}>
<X className="h-5 w-5" /> <SelectTrigger>
</button> <SelectValue />
</div> </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 */} {/* Subfolder name */}
<div className="mb-4"> <div className="space-y-1.5">
<label className="mb-1 block text-xs text-text-muted">Target folder</label> <Label htmlFor="heap-convert-subfolder">Subfolder name</Label>
{folders.length === 0 ? ( <Input
<div className="rounded border border-border bg-bg px-3 py-2 text-xs text-text-muted"> id="heap-convert-subfolder"
No folders available type="text"
</div> value={subfolderName}
) : ( onChange={(e) => setSubfolderName(e.target.value)}
<select placeholder="(none — use parent directly)"
value={targetId} />
onChange={(e) => setTargetId(e.target.value)} <p className="text-xs text-text-faint">
className="w-full rounded border border-border bg-bg px-2 py-1.5 text-sm text-text focus:border-primary focus:outline-none" {subfolderName.trim() && targetFolder
> ? `Will create ${targetFolder.path}/${subfolderName.trim()} if missing.`
{folders.map((f) => ( : 'Photos go directly into the parent folder.'}
<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}
</p> </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> </div>
{/* Subfolder name */} <DialogFooter>
<div className="mb-4"> <Button
<label className="mb-1 block text-xs text-text-muted"> variant="outline"
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
onClick={onClose} onClick={onClose}
disabled={convertMutation.isPending} disabled={convertMutation.isPending}
className="rounded bg-surface-2 px-4 py-2 text-sm text-text hover:bg-surface-offset disabled:opacity-50"
> >
Cancel Cancel
</button> </Button>
<button <Button
onClick={() => convertMutation.mutate()} onClick={() => convertMutation.mutate()}
disabled={!targetId || convertMutation.isPending} 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'} {convertMutation.isPending ? 'Converting…' : 'Convert'}
</button> </Button>
</div> </DialogFooter>
</div> </DialogContent>
</div> </Dialog>
) )
} }

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react' import { useState } from 'react'
import { import {
ShoppingBasket, ShoppingBasket,
Plus, Plus,
@@ -23,6 +23,15 @@ import { toast } from '../ToastContainer'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail' import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
import { HeapConvertDialog } from './HeapConvertDialog' import { HeapConvertDialog } from './HeapConvertDialog'
import { ShareDialog } from '../sharing/ShareDialog' 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 * 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. // the draft name. Mirrors the folder rename pattern in LeftSidebar.
const [renamingId, setRenamingId] = useState<string | null>(null) const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameDraft, setRenameDraft] = useState('') const [renameDraft, setRenameDraft] = useState('')
// Which heap's burger menu is currently open. null when no menu is open. // Which heap's burger menu is currently open. Drives the trigger's
// The popover closes on outside click and Escape via the effect below. // hover-visible state via DropdownMenu's open prop — Radix handles
// outside-click + Escape dismissal internally.
const [openMenuId, setOpenMenuId] = useState<string | null>(null) 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 = () => { const invalidate = () => {
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY }) queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
@@ -217,7 +208,7 @@ export function HeapsPanel() {
className="flex items-center gap-1 px-2 py-0.5" className="flex items-center gap-1 px-2 py-0.5"
style={{ paddingLeft: '20px' }} style={{ paddingLeft: '20px' }}
> >
<input <Input
autoFocus autoFocus
type="text" type="text"
value={newName} value={newName}
@@ -230,15 +221,16 @@ export function HeapsPanel() {
} }
}} }}
placeholder="Heap name" 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} onClick={handleCreate}
disabled={!newName.trim() || createMutation.isPending} 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 Add
</button> </Button>
</div> </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', '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', isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
// Active-heap row gets a soft primary wash so the // 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. // when viewing a different section.
isActive && !isFiltered && 'bg-primary/8 text-text', isActive && !isFiltered && 'bg-primary/8 text-text',
isDropTarget && 'ring-2 ring-primary bg-primary/10' isDropTarget && 'ring-2 ring-primary bg-primary/10'
@@ -323,7 +315,7 @@ export function HeapsPanel() {
/> />
{isRenaming ? ( {isRenaming ? (
<input <Input
autoFocus autoFocus
type="text" type="text"
value={renameDraft} value={renameDraft}
@@ -337,7 +329,7 @@ export function HeapsPanel() {
setRenamingId(null) 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 && ( {isActive && (
<span <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" 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 Active
</span> </span>
@@ -389,93 +381,81 @@ export function HeapsPanel() {
{/* Kebab menu — collects rename / duplicate / convert / {/* Kebab menu — collects rename / duplicate / convert /
* delete so the row stays compact. */} * delete so the row stays compact. */}
<div <DropdownMenu
className={clsx( open={isMenuOpen}
'relative flex-shrink-0', onOpenChange={(o) => setOpenMenuId(o ? heap.id : null)}
isMenuOpen ? 'block' : 'hidden group-hover:block'
)}
> >
<button <div
onClick={(e) => { className={clsx(
e.stopPropagation() 'relative flex-shrink-0',
setOpenMenuId(isMenuOpen ? null : heap.id) isMenuOpen ? 'block' : 'hidden group-hover:block'
}} )}
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}
> >
<MoreHorizontal className="h-3.5 w-3.5" /> <DropdownMenuTrigger asChild>
</button> <button
onClick={(e) => e.stopPropagation()}
{isMenuOpen && ( className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
<div title="More actions"
ref={menuRef} aria-label="More heap actions"
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" <MoreHorizontal className="h-3.5 w-3.5" />
onClick={(e) => e.stopPropagation()} </button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent
align="end"
className="min-w-[160px]"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem
onClick={() => {
setRenamingId(heap.id)
setRenameDraft(heap.name)
}}
> >
<MenuItem <Pencil className="h-3.5 w-3.5 text-text-muted" />
icon={<Pencil className="h-3.5 w-3.5" />} Rename
label="Rename" </DropdownMenuItem>
onClick={() => { <DropdownMenuItem
setOpenMenuId(null) onClick={() => duplicateMutation.mutate(heap.id)}
setRenamingId(heap.id) >
setRenameDraft(heap.name) <Copy className="h-3.5 w-3.5 text-text-muted" />
}} Duplicate
/> </DropdownMenuItem>
<MenuItem <DropdownMenuItem onClick={() => setConvertingHeap(heap)}>
icon={<Copy className="h-3.5 w-3.5" />} <FolderOutput className="h-3.5 w-3.5 text-text-muted" />
label="Duplicate" Move to folder
onClick={() => { </DropdownMenuItem>
setOpenMenuId(null) <DropdownMenuItem onClick={() => setSharingHeap(heap)}>
duplicateMutation.mutate(heap.id) <Users className="h-3.5 w-3.5 text-text-muted" />
}} Share
/> </DropdownMenuItem>
<MenuItem <DropdownMenuItem
icon={<FolderOutput className="h-3.5 w-3.5" />} onClick={() =>
label="Move to folder…" downloads.trigger(downloads.heapUrl(heap.id))
onClick={() => { }
setOpenMenuId(null) >
setConvertingHeap(heap) <DownloadIcon className="h-3.5 w-3.5 text-text-muted" />
}} Download as zip
/> </DropdownMenuItem>
<MenuItem <DropdownMenuSeparator />
icon={<Users className="h-3.5 w-3.5" />} <DropdownMenuItem
label="Share…" onClick={() => {
onClick={() => { if (
setOpenMenuId(null) confirm(
setSharingHeap(heap) `Delete heap "${heap.name}"? Photos are not affected.`
}} )
/> ) {
<MenuItem deleteMutation.mutate(heap.id)
icon={<DownloadIcon className="h-3.5 w-3.5" />} }
label="Download as zip" }}
onClick={() => { className="text-reject focus:bg-reject/10 focus:text-reject"
setOpenMenuId(null) >
downloads.trigger(downloads.heapUrl(heap.id)) <Trash2 className="h-3.5 w-3.5" />
}} Delete
/> </DropdownMenuItem>
<div className="my-1 h-px bg-border" /> </DropdownMenuContent>
<MenuItem </DropdownMenu>
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>
</div> </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 { import {
ChevronRight, ChevronRight,
ChevronDown, ChevronDown,
@@ -15,7 +15,6 @@ import {
Layers2, Layers2,
MoreHorizontal, MoreHorizontal,
Pencil, Pencil,
PanelLeftClose,
Settings, Settings,
Users, Users,
Eye, Eye,
@@ -51,6 +50,15 @@ import { UploadModal } from '../upload/UploadModal'
import { useSharedFoldersQuery } from '../../hooks/useSharingQueries' import { useSharedFoldersQuery } from '../../hooks/useSharingQueries'
import { useAuth } from '../../contexts/AuthContext' import { useAuth } from '../../contexts/AuthContext'
import { useFeaturesQuery } from '../../hooks/useFeaturesQuery' 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 { interface TreeItem {
id: string id: string
@@ -62,14 +70,15 @@ interface TreeItem {
/** For folder rows only: the user-set "hide from views" flag. Drives /** For folder rows only: the user-set "hide from views" flag. Drives
* the muted styling + eye-off badge + menu item label. */ * the muted styling + eye-off badge + menu item label. */
isHidden?: boolean 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 { export function LeftSidebar() {
onCollapse: () => void
}
export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
const { user, isAdmin, logout } = useAuth() const { user, isAdmin, logout } = useAuth()
const scanActivity = useScanActivity()
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps'])) const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
// Inline rename state for source-root rows. Stores the id being edited // Inline rename state for source-root rows. Stores the id being edited
// and the draft name. Double-click a folder row to start. // 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 navigateToSection = useFilterStore((s) => s.navigateToSection)
const currentSection = useFilterStore((s) => s.currentSection) const currentSection = useFilterStore((s) => s.currentSection)
const { data: allTags = [] } = useTagsQuery() const { data: allTags = [] } = useTagsQuery()
const { data: faceClusters = [] } = useTagsQuery('face_cluster')
const { data: stats } = useLibraryStatsQuery() const { data: stats } = useLibraryStatsQuery()
const { data: featuresMap } = useFeaturesQuery() const { data: featuresMap } = useFeaturesQuery()
const visionOn = featuresMap ? featuresMap['vision.enabled'] !== false : true const visionOn = featuresMap ? featuresMap['vision.enabled'] !== false : true
const facesOn = visionOn && (featuresMap ? featuresMap['vision.faces.enabled'] !== false : true) const tagsOn = true
const tagsOn =
visionOn &&
(featuresMap
? featuresMap['vision.detector.enabled'] !== false ||
featuresMap['vision.classifier.enabled'] !== false
: true)
const [dropTargetId, setDropTargetId] = useState<string | null>(null) const [dropTargetId, setDropTargetId] = useState<string | null>(null)
// Per-folder kebab menu open state. Stores the tree-item id ("folder-..." // 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 [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 // "Create new folder under {parent}" inline state. parentId is the
// Folder.id (no "folder-" prefix). // Folder.id (no "folder-" prefix).
@@ -295,8 +282,8 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
case 'tags': case 'tags':
navigateToSection('tags', { groupBy: 'tag' }) navigateToSection('tags', { groupBy: 'tag' })
break break
case 'people': case 'needs-review':
navigateToSection('people', { groupBy: 'tag' }) navigateToSection('needs-review', { needsReview: true })
break break
case 'colors': case 'colors':
navigateToSection('colors', { groupBy: 'color' }) navigateToSection('colors', { groupBy: 'color' })
@@ -417,6 +404,7 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
count: node.photo_count, count: node.photo_count,
type: 'folder', type: 'folder',
isHidden: node.is_hidden, isHidden: node.is_hidden,
path: node.path,
children: node.children.length > 0 children: node.children.length > 0
? node.children.map(folderNodeToTreeItem) ? node.children.map(folderNodeToTreeItem)
: undefined, : undefined,
@@ -425,7 +413,6 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
// Total tag count for the badge on the Tags entry (user tags only). // Total tag count for the badge on the Tags entry (user tags only).
const userTags = allTags.filter((t) => t.kind === 'user') const userTags = allTags.filter((t) => t.kind === 'user')
const tagsTotalCount = userTags.reduce((sum, t) => sum + (t.photo_count || 0), 0) 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[] = [ 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: '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 }, { 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 }] : []), ...(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: '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: '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" /> }, { id: 'memories', label: 'Memories', icon: <Clock className="h-4 w-4" /> },
@@ -516,7 +503,7 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
style={ style={
isSectionHeader isSectionHeader
? undefined ? undefined
: { paddingLeft: `${8 + (depth - 1) * 12}px` } : { paddingLeft: `${depth * 20}px` }
} }
onClick={() => { onClick={() => {
if (renamingId === item.id) return if (renamingId === item.id) return
@@ -580,7 +567,7 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
)} )}
</button> </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 {/* 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) */} {/* Label (or inline rename input for folder rows) */}
{renamingId === item.id ? ( {renamingId === item.id ? (
<input <Input
autoFocus autoFocus
type="text" type="text"
value={renameDraft} value={renameDraft}
@@ -627,7 +614,7 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
setRenamingId(null) 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 <span
@@ -641,6 +628,26 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
</span> </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 {/* Count Badge — fixed-width slot so counts line up in a column
* across rows regardless of digit count. Section headers skip * across rows regardless of digit count. Section headers skip
* the badge entirely (they're labels, not navigable rows). */} * 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 folderId = item.id.slice('folder-'.length)
const isMenuOpen = openMenuId === item.id const isMenuOpen = openMenuId === item.id
return ( return (
<div <DropdownMenu
className={clsx( open={isMenuOpen}
'relative flex-shrink-0', onOpenChange={(o) => setOpenMenuId(o ? item.id : null)}
isMenuOpen ? 'block' : 'hidden group-hover:block'
)}
> >
<button <div
onClick={(e) => { className={clsx(
e.stopPropagation() 'relative flex-shrink-0',
setOpenMenuId(isMenuOpen ? null : item.id) isMenuOpen ? 'block' : 'hidden group-hover:block'
}} )}
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}
> >
<MoreHorizontal className="h-3.5 w-3.5" /> <DropdownMenuTrigger asChild>
</button> <button
onClick={(e) => e.stopPropagation()}
{isMenuOpen && ( className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
<div title="More actions"
ref={menuRef} aria-label="More folder actions"
role="menu" >
onClick={(e) => e.stopPropagation()} <MoreHorizontal className="h-3.5 w-3.5" />
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" </button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent
align="end"
className="min-w-[180px]"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem
onClick={() =>
setUploadTarget({ open: true, folderId })
}
> >
<FolderMenuItem <UploadIcon className="h-3.5 w-3.5 text-text-muted" />
icon={<UploadIcon className="h-3.5 w-3.5" />} Upload here
label="Upload here…" </DropdownMenuItem>
onClick={() => { <DropdownMenuItem
setOpenMenuId(null) onClick={() => {
setUploadTarget({ open: true, folderId }) setCreatingUnder(folderId)
}} setCreateDraft('')
/> if (!expandedItems.has(item.id)) {
<FolderMenuItem toggleExpanded(item.id)
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" />
)
} }
label={item.isHidden ? 'Show in views' : 'Hide from views'} }}
onClick={() => { >
setOpenMenuId(null) <FolderPlus className="h-3.5 w-3.5 text-text-muted" />
toggleHiddenMutation.mutate({ New sub-folder
id: folderId, </DropdownMenuItem>
hidden: !item.isHidden, <DropdownMenuItem
}) onClick={() => {
}} setRenamingId(item.id)
/> setRenameDraft(item.label)
<FolderMenuItem }}
icon={<Users className="h-3.5 w-3.5" />} >
label="Share…" <Pencil className="h-3.5 w-3.5 text-text-muted" />
onClick={() => { Rename
setOpenMenuId(null) </DropdownMenuItem>
setSharingFolder({ id: folderId, name: item.label }) <DropdownMenuItem
}} onClick={() =>
/> toggleHiddenMutation.mutate({
<FolderMenuItem id: folderId,
icon={<DownloadIcon className="h-3.5 w-3.5" />} hidden: !item.isHidden,
label="Download as zip" })
onClick={() => { }
setOpenMenuId(null) >
downloads.trigger(downloads.folderUrl(folderId)) {item.isHidden ? (
}} <Eye className="h-3.5 w-3.5 text-text-muted" />
/> ) : (
<div className="my-1 h-px bg-border" /> <EyeOff className="h-3.5 w-3.5 text-text-muted" />
<FolderMenuItem )}
icon={<Trash2 className="h-3.5 w-3.5" />} {item.isHidden ? 'Show in views' : 'Hide from views'}
label="Delete folder…" </DropdownMenuItem>
destructive <DropdownMenuItem
onClick={() => { onClick={() =>
setOpenMenuId(null) setSharingFolder({ id: folderId, name: item.label })
setDeletingFolder({ }
id: folderId, >
name: item.label, <Users className="h-3.5 w-3.5 text-text-muted" />
photoCount: item.count, Share
}) </DropdownMenuItem>
}} <DropdownMenuItem
/> onClick={() =>
</div> downloads.trigger(downloads.folderUrl(folderId))
)} }
</div> >
<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) && ( creatingUnder === item.id.slice('folder-'.length) && (
<div <div
className="flex items-center gap-1 px-2 py-1" 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" /> <FolderPlus className="h-3 w-3 flex-shrink-0 text-text-muted" />
<input <input
@@ -847,14 +848,6 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
> >
<UploadIcon className="h-3.5 w-3.5" /> <UploadIcon 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 (Tab)"
aria-label="Collapse panel"
>
<PanelLeftClose className="h-3.5 w-3.5" />
</button>
</div> </div>
</div> </div>
{/* Active heap card — pinned just below the Library header so {/* 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 { 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 clsx from 'clsx'
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import { format } from 'date-fns' import { format } from 'date-fns'
@@ -22,6 +22,9 @@ import { stripPhotosFromCache } from '../../hooks/usePhotosQuery'
import { toast } from '../ToastContainer' import { toast } from '../ToastContainer'
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel' import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels' 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. * 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 * - 2+ photos selected → renders a slim bulk-action panel that fans out
* rating / color / discard / pick across the entire selection. * rating / color / discard / pick across the entire selection.
*/ */
interface RightSidebarProps { export function RightSidebar() {
onCollapse: () => void
}
export function RightSidebar({ onCollapse }: RightSidebarProps) {
const { selectedPhotos, activePhotoId, clearSelection } = usePhotoStore() const { selectedPhotos, activePhotoId, clearSelection } = usePhotoStore()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -159,7 +158,7 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
const { data: allTags = [] } = useTagsQuery() const { data: allTags = [] } = useTagsQuery()
const [tagInput, setTagInput] = useState('') 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 { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
const heapMutation = useMutation({ const heapMutation = useMutation({
@@ -212,23 +211,17 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
</h2> </h2>
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
{selectedPhotos.length > 0 && ( {selectedPhotos.length > 0 && (
<button <Button
variant="ghost"
size="icon"
className="h-6 w-6 text-text-muted"
onClick={clearSelection} onClick={clearSelection}
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear selection (Esc)" title="Clear selection (Esc)"
aria-label="Clear selection" aria-label="Clear selection"
> >
<X className="h-3.5 w-3.5" /> <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>
</div> </div>
) )
@@ -317,7 +310,7 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
{/* Bulk rating */} {/* Bulk rating */}
<div> <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"> <div className="flex gap-1">
{[1, 2, 3, 4, 5].map((value) => ( {[1, 2, 3, 4, 5].map((value) => (
<button <button
@@ -345,7 +338,7 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
{/* Bulk color */} {/* Bulk color */}
<div> <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"> <div className="flex items-center gap-1.5">
{COLOR_LABEL_OPTIONS.map(({ value, className }) => ( {COLOR_LABEL_OPTIONS.map(({ value, className }) => (
<button <button
@@ -374,18 +367,18 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
{/* Bulk flag */} {/* Bulk flag */}
<div> <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"> <div className="flex gap-2">
<button <Button
size="sm"
onClick={() => { onClick={() => {
if (!activeHeap) return if (!activeHeap) return
heapMutation.mutate({ ids: selectedPhotos, remove: allMembers }) heapMutation.mutate({ ids: selectedPhotos, remove: allMembers })
}} }}
disabled={!activeHeap || heapMutation.isPending} disabled={!activeHeap || heapMutation.isPending}
className={clsx( className={clsx(
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
allMembers 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' : 'bg-surface-2 text-text-muted hover:bg-surface-offset'
)} )}
title={ title={
@@ -396,16 +389,18 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
: 'Set an active heap first' : 'Set an active heap first'
} }
> >
<ShoppingBasket className="h-3 w-3" /> <ShoppingBasket className="mr-1 h-3 w-3" />
{allMembers ? 'Picked' : 'Pick'} {allMembers ? 'Selected' : 'Select'}
</button> </Button>
<button <Button
variant="secondary"
size="sm"
onClick={() => bulkDiscardMutation.mutate(selectedPhotos)} 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 Discard
</button> </Button>
</div> </div>
</div> </div>
@@ -414,7 +409,7 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
* input adds an existing tag if it matches a name, or creates * input adds an existing tag if it matches a name, or creates
* a new tag and applies it. */} * a new tag and applies it. */}
<div> <div>
<label className="mb-1 block text-xs text-text-muted">Tags</label> <Label className="mb-1 block">Tags</Label>
<BulkTagsEditor <BulkTagsEditor
allTags={allTags} allTags={allTags}
tagInput={tagInput} tagInput={tagInput}
@@ -447,7 +442,7 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
* clock (1970 epoch) and for legacy libraries where the folder * clock (1970 epoch) and for legacy libraries where the folder
* structure is the only trustworthy date signal. */} * structure is the only trustworthy date signal. */}
<div> <div>
<label className="mb-1 block text-xs text-text-muted">Date Taken</label> <Label className="mb-1 block">Date Taken</Label>
<BulkTakenAtEditor <BulkTakenAtEditor
disabled={ disabled={
bulkTakenAtMutation.isPending || bulkTakenAtMapMutation.isPending bulkTakenAtMutation.isPending || bulkTakenAtMapMutation.isPending
@@ -530,33 +525,36 @@ function BulkTakenAtEditor({
<div className="space-y-2"> <div className="space-y-2">
{/* Apply-one row */} {/* Apply-one row */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<input <Input
type="datetime-local" type="datetime-local"
value={uniformDraft} value={uniformDraft}
onChange={(e) => setUniformDraft(e.target.value)} onChange={(e) => setUniformDraft(e.target.value)}
disabled={disabled} 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} onClick={handleApplyUniform}
disabled={disabled || !uniformDraft} 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`} title={`Apply this date to all ${selectedCount} selected`}
> >
Apply Apply
</button> </Button>
</div> </div>
{/* Guess-from-path preview */} {/* Guess-from-path preview */}
{preview === null ? ( {preview === null ? (
<button <Button
variant="outline"
size="sm"
onClick={handleGuess} onClick={handleGuess}
disabled={disabled} 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" title="Scan each photo's folder + filename for a date pattern"
> >
Guess from folder paths Guess from folder paths
</button> </Button>
) : ( ) : (
<div className="rounded border border-border bg-bg p-2 text-[11px]"> <div className="rounded border border-border bg-bg p-2 text-[11px]">
<div className="mb-1.5 text-text-muted"> <div className="mb-1.5 text-text-muted">
@@ -582,20 +580,23 @@ function BulkTakenAtEditor({
</ul> </ul>
)} )}
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<button <Button
size="sm"
onClick={handleApplyPreview} onClick={handleApplyPreview}
disabled={disabled || preview.hits.length === 0} 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} Apply {preview.hits.length}
</button> </Button>
<button <Button
variant="secondary"
size="sm"
onClick={() => setPreview(null)} onClick={() => setPreview(null)}
disabled={disabled} 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 Cancel
</button> </Button>
</div> </div>
</div> </div>
)} )}
@@ -652,7 +653,7 @@ function BulkTagsEditor({
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<input <Input
type="text" type="text"
value={tagInput} value={tagInput}
onChange={(e) => onTagInputChange(e.target.value)} onChange={(e) => onTagInputChange(e.target.value)}
@@ -666,18 +667,20 @@ function BulkTagsEditor({
}} }}
placeholder="Filter or create…" placeholder="Filter or create…"
disabled={disabled} 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 && ( {trimmed && !exactMatch && (
<button <Button
variant="outline"
size="sm"
onClick={handleSubmit} onClick={handleSubmit}
disabled={disabled} 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 Create "{trimmed}" and apply
</button> </Button>
)} )}
{filtered.length > 0 ? ( {filtered.length > 0 ? (

View File

@@ -1,14 +1,6 @@
import { PanelLeftOpen, PanelRightOpen } from 'lucide-react'
import desertBg from '../../assets/desert.png' import desertBg from '../../assets/desert.png'
import muleSprites from '../../assets/mule-sprites.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 // Block-character ASCII rendering of "Mulimago" — sits on a black plate
// in place of the old text title. // in place of the old text title.
const MULIMAGO_ASCII = `▖ ▖ ▜ ▘ 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 * 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 * backdrop. Sidebar toggle buttons live on the FilterBar.
* 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.
*/ */
export function TopBar({ export function TopBar() {
leftSidebarOpen,
rightSidebarOpen,
onExpandLeft,
onExpandRight,
}: TopBarProps) {
return ( return (
<header <header
className="relative flex h-16 items-center justify-between overflow-hidden border-b border-border px-4" 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"> <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 <div
aria-label="Mulimago" aria-label="Mulimago"
className="h-12 w-14" className="h-12 w-14"
@@ -100,16 +69,6 @@ export function TopBar({
<span className="text-[10px] font-serif text-black/80"> <span className="text-[10px] font-serif text-black/80">
Built with hubris {toRoman(new Date().getFullYear())} Built with hubris {toRoman(new Date().getFullYear())}
</span> </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> </div>
</header> </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 clsx from 'clsx'
import type { Photo } from '../../types/photo' import type { Photo } from '../../types/photo'
import { photos as photosApi } from '../../services/api' import { photos as photosApi } from '../../services/api'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
interface PreviewFilmstripProps { interface PreviewFilmstripProps {
photos: Photo[] photos: Photo[]
@@ -13,6 +14,7 @@ const CELL_SIZE = 72
export function PreviewFilmstrip({ photos, currentIndex, onSelect }: PreviewFilmstripProps) { export function PreviewFilmstrip({ photos, currentIndex, onSelect }: PreviewFilmstripProps) {
const activeRef = useRef<HTMLButtonElement>(null) const activeRef = useRef<HTMLButtonElement>(null)
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
useEffect(() => { useEffect(() => {
activeRef.current?.scrollIntoView({ 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"> <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) => { {photos.map((photo, index) => {
const isActive = index === currentIndex const isActive = index === currentIndex
const isInActiveHeap = activeHeapMembers.has(photo.id)
return ( return (
<button <button
key={photo.id} key={photo.id}
ref={isActive ? activeRef : null} ref={isActive ? activeRef : null}
onClick={() => onSelect(photo.id)} onClick={() => onSelect(photo.id)}
className={clsx( className={clsx(
'relative shrink-0 overflow-hidden rounded-sm transition-all', '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)]',
'hover:opacity-100', isActive && 'scale-90 opacity-100 ring-2 ring-blue-500 ring-offset-2 ring-offset-surface',
isActive !isActive && isInActiveHeap && 'opacity-100',
? 'ring-2 ring-primary opacity-100' !isActive && !isInActiveHeap && 'opacity-60 hover:opacity-100 hover:ring-1 hover:ring-text-muted/70'
: 'opacity-60'
)} )}
style={{ width: CELL_SIZE, height: CELL_SIZE }} style={{ width: CELL_SIZE, height: CELL_SIZE }}
title={photo.filename} title={photo.filename}
> >
<FilmstripThumb photo={photo} /> <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> </button>
) )
})} })}
@@ -77,7 +85,10 @@ function FilmstripThumb({ photo }: { photo: Photo }) {
onError={() => setErrored(true)} onError={() => setErrored(true)}
className={clsx( className={clsx(
'h-full w-full object-cover transition-opacity duration-200', '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 }) { 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 ( 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 <video
key={photo.id} key={photo.id}
src={getVideoSrc(photo)} src={getVideoSrc(photo)}
controls controls
autoPlay autoPlay
muted muted
className="max-h-full max-w-full" className="max-h-full max-w-full object-contain"
/> />
</div> </div>
) )

View File

@@ -10,6 +10,9 @@ import { PreviewImage } from './PreviewImage'
import { PreviewFilmstrip } from './PreviewFilmstrip' import { PreviewFilmstrip } from './PreviewFilmstrip'
import { getPreviewImageSrc, isVideo } from './previewSrc' import { getPreviewImageSrc, isVideo } from './previewSrc'
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel' import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
import { KeyboardHints } from '../KeyboardHints'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
export function PreviewView() { export function PreviewView() {
const activePhotoId = usePhotoStore((s) => s.activePhotoId) const activePhotoId = usePhotoStore((s) => s.activePhotoId)
@@ -18,25 +21,17 @@ export function PreviewView() {
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const previouslyFocusedRef = useRef<HTMLElement | null>(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 // Track the photo we OPENED preview on as a defensive fallback only.
// store's getState (which is guaranteed to reflect the value the // On close we restore the LAST viewed photo so the grid focus matches
// openPreview action just wrote, even if the React subscription // what the user just saw (filmstrip/arrow nav can land them anywhere).
// 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.
const openOriginRef = useRef<string | null>( const openOriginRef = useRef<string | null>(
activePhotoId ?? usePhotoStore.getState().activePhotoId activePhotoId ?? usePhotoStore.getState().activePhotoId
) )
const closePreview = useCallback(() => { 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 = const id =
openOriginRef.current ?? usePhotoStore.getState().activePhotoId usePhotoStore.getState().activePhotoId ?? openOriginRef.current
usePhotoStore.setState({ usePhotoStore.setState({
viewMode: 'grid', viewMode: 'grid',
activePhotoId: id, 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" 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> <div>No photo to display</div>
<button <Button variant="outline" size="sm" className="mt-4" onClick={closePreview}>
onClick={closePreview}
className="mt-4 rounded border border-border px-3 py-1 text-sm hover:bg-surface"
>
Close Close
</button> </Button>
</div> </div>
) )
} }
@@ -234,30 +226,39 @@ export function PreviewView() {
{/* Top-right action buttons */} {/* Top-right action buttons */}
<div className="absolute right-3 top-3 z-10 flex items-center gap-2"> <div className="absolute right-3 top-3 z-10 flex items-center gap-2">
<button <Button
size="icon"
onClick={() => setInfoPanelOpen((v) => !v)} onClick={() => setInfoPanelOpen((v) => !v)}
className={ className={cn(
'flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white transition hover:bg-black/80 ' + 'h-9 w-9 rounded-full bg-black/60 text-white hover:bg-black/80',
(infoPanelOpen ? 'ring-2 ring-primary' : '') infoPanelOpen && 'ring-2 ring-primary'
} )}
title="Toggle info panel (I)" title="Toggle info panel (I)"
aria-label="Toggle info panel" aria-label="Toggle info panel"
aria-pressed={infoPanelOpen} aria-pressed={infoPanelOpen}
> >
<Info className="h-5 w-5" /> <Info className="h-5 w-5" />
</button> </Button>
<button <Button
size="icon"
onClick={closePreview} 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)" title="Close (Esc)"
aria-label="Close preview" aria-label="Close preview"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </Button>
</div> </div>
<PreviewImage photo={currentPhoto} /> <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 <PreviewFilmstrip
photos={photos} photos={photos}
currentIndex={safeIndex} currentIndex={safeIndex}

View File

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

View File

@@ -1,9 +1,27 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Users, Trash2, X } from 'lucide-react' import { Users, Trash2 } from 'lucide-react'
import clsx from 'clsx'
import { sharing, type ShareInfo } from '../../services/api' 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 { interface ShareDialogProps {
isOpen: boolean isOpen: boolean
@@ -13,7 +31,13 @@ interface ShareDialogProps {
onClose: () => void 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 [username, setUsername] = useState('')
const [permission, setPermission] = useState<'read' | 'write'>('read') const [permission, setPermission] = useState<'read' | 'write'>('read')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -40,7 +64,9 @@ export function ShareDialog({ isOpen, type, targetId, targetName, onClose }: Sha
setPermission('read') setPermission('read')
setError(null) setError(null)
queryClient.invalidateQueries({ queryKey: sharesQueryKey }) 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) => { onError: (err: any) => {
setError(err?.response?.data?.detail || 'Failed to share') 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), : sharing.revokeFolderShare(targetId, shareId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: sharesQueryKey }) 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(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setUsername('') setUsername('')
@@ -75,103 +94,102 @@ export function ShareDialog({ isOpen, type, targetId, targetName, onClose }: Sha
} }
}, [isOpen]) }, [isOpen])
if (!isOpen) return null
return ( return (
<div className="fixed inset-0 z-50"> <Dialog open={isOpen} onOpenChange={(o) => !o && onClose()}>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} /> <DialogContent className="max-w-[420px]">
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"> <DialogHeader>
<div className="relative z-10 w-[420px] rounded-lg border border-border bg-surface p-5 shadow-2xl"> <DialogTitle className="flex items-center gap-2">
<div className="mb-4 flex items-center justify-between"> <Users className="h-4 w-4 text-text-muted" />
<div className="flex items-center gap-2"> Share {type === 'heap' ? 'heap' : 'folder'}
<Users className="h-4 w-4 text-text-muted" /> </DialogTitle>
<h2 className="text-base font-semibold text-text"> </DialogHeader>
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>
<div className="mb-3 text-sm text-text-muted"> <div className="text-sm text-text-muted">
Sharing <span className="font-medium text-text">{targetName}</span> 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> </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 { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery' import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
import { toast } from '../ToastContainer' 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 { import {
COLOR_LABEL_OPTIONS, COLOR_LABEL_OPTIONS,
type ColorLabel, 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 className="space-y-2.5 border-b border-border p-3">
<div> <div>
<label className="mb-1 block text-xs text-text-muted">Filename</label> <label className="mb-1 block text-xs text-text-muted">Filename</label>
<input <Input
type="text" type="text"
value={filenameDraft} value={filenameDraft}
onChange={(e) => setFilenameDraft(e.target.value)} onChange={(e) => setFilenameDraft(e.target.value)}
@@ -405,7 +412,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
<div> <div>
<label className="mb-1 block text-xs text-text-muted">Title</label> <label className="mb-1 block text-xs text-text-muted">Title</label>
<input <Input
type="text" type="text"
value={titleDraft} value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)} onChange={(e) => setTitleDraft(e.target.value)}
@@ -425,7 +432,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
<div> <div>
<label className="mb-1 block text-xs text-text-muted">Notes</label> <label className="mb-1 block text-xs text-text-muted">Notes</label>
<textarea <Textarea
value={notesDraft} value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)} onChange={(e) => setNotesDraft(e.target.value)}
onBlur={commitNotes} onBlur={commitNotes}
@@ -494,7 +501,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
</div> </div>
</div> </div>
{/* Flag — Pick + Discard */} {/* Flag — Select + Discard */}
<div> <div>
<label className="mb-1 block text-xs text-text-muted">Flag</label> <label className="mb-1 block text-xs text-text-muted">Flag</label>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -519,7 +526,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
} }
> >
<ShoppingBasket className="h-3 w-3" /> <ShoppingBasket className="h-3 w-3" />
{isInActiveHeap ? 'Picked' : 'Pick'} {isInActiveHeap ? 'Selected' : 'Select'}
</button> </button>
<button <button
onClick={() => updateMutation.mutate({ is_discarded: !isDiscarded })} onClick={() => updateMutation.mutate({ is_discarded: !isDiscarded })}
@@ -680,20 +687,23 @@ function Section({
children: React.ReactNode children: React.ReactNode
}) { }) {
return ( return (
<div className="border-b border-border"> <Collapsible
<button open={expanded}
onClick={onToggle} onOpenChange={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" 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> <span>{title}</span>
{expanded ? ( {expanded ? (
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
) : ( ) : (
<ChevronRight className="h-3 w-3" /> <ChevronRight className="h-3 w-3" />
)} )}
</button> </CollapsibleTrigger>
{expanded && <div className="px-3 pb-2.5">{children}</div>} <CollapsibleContent className="px-3 pb-2.5">
</div> {children}
</CollapsibleContent>
</Collapsible>
) )
} }
@@ -776,7 +786,7 @@ function TagsEditor({
)} )}
<div className="relative"> <div className="relative">
<input <Input
type="text" type="text"
value={tagInput} value={tagInput}
onChange={(e) => onTagInputChange(e.target.value)} onChange={(e) => onTagInputChange(e.target.value)}
@@ -789,7 +799,7 @@ function TagsEditor({
} }
}} }}
placeholder="Add tag…" 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 && ( {suggestions.length > 0 && (
<div className="mt-1 rounded border border-border bg-bg shadow-md"> <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 { useState, useMemo, useCallback } from 'react'
import { Tag as TagIcon, ArrowLeft, Loader2 } from 'lucide-react' import { Tag as TagIcon, ArrowLeft, Loader2 } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
import { Button } from '@/components/ui/button'
import { useTagsQuery } from '../../hooks/useTagsQuery' import { useTagsQuery } from '../../hooks/useTagsQuery'
import { photos as photosApi, type Tag } from '../../services/api' import { photos as photosApi, type Tag } from '../../services/api'
import { useFilterStore } from '../../store/filterStore' import { useFilterStore } from '../../store/filterStore'
@@ -17,11 +18,10 @@ export function TagsView() {
const setTagIds = useFilterStore((s) => s.setTagIds) const setTagIds = useFilterStore((s) => s.setTagIds)
const [selectedTag, setSelectedTag] = useState<Tag | null>(null) const [selectedTag, setSelectedTag] = useState<Tag | null>(null)
// Exclude face_cluster tags (those live in PeopleView)
const tags = useMemo( const tags = useMemo(
() => () =>
allTags allTags
.filter((t) => t.kind !== 'face_cluster') .filter((t) => t.kind === 'user')
.sort((a, b) => b.photo_count - a.photo_count), .sort((a, b) => b.photo_count - a.photo_count),
[allTags] [allTags]
) )
@@ -50,13 +50,15 @@ export function TagsView() {
return ( return (
<div className="flex h-full flex-col overflow-hidden"> <div className="flex h-full flex-col overflow-hidden">
<div className="flex items-center gap-3 border-b border-border px-4 py-3"> <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} onClick={exitDetail}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
title="Back to tags" title="Back to tags"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</button> </Button>
<h2 className="text-sm font-semibold text-text">{selectedTag.name}</h2> <h2 className="text-sm font-semibold text-text">{selectedTag.name}</h2>
</div> </div>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">

View File

@@ -1,10 +1,8 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { import {
Star, Star,
ShoppingBasket,
Trash2, Trash2,
RefreshCw, RefreshCw,
Check,
Copy, Copy,
AlertTriangle, AlertTriangle,
Users, Users,
@@ -73,11 +71,9 @@ interface PhotoThumbnailProps {
* thumbnails stay at the explicit `size`. */ * thumbnails stay at the explicit `size`. */
fill?: boolean fill?: boolean
isSelected: 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 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 onClick: (e: React.MouseEvent) => void
onDoubleClick?: (e: React.MouseEvent) => void onDoubleClick?: (e: React.MouseEvent) => void
} }
@@ -88,7 +84,6 @@ export function PhotoThumbnail({
fill = false, fill = false,
isSelected, isSelected,
isInActiveHeap = false, isInActiveHeap = false,
activeHeapName = null,
onClick, onClick,
onDoubleClick, onDoubleClick,
}: PhotoThumbnailProps) { }: PhotoThumbnailProps) {
@@ -203,12 +198,27 @@ export function PhotoThumbnail({
return ( return (
<div <div
className={clsx( className={clsx(
'group relative cursor-pointer overflow-hidden rounded-sm transition-all duration-200', 'group relative cursor-pointer overflow-hidden rounded-sm will-change-transform',
// Two-tone hover ring: bright primary inner + dark offset so it // Animate INTO selection (springy ease-in over 300ms); snap
// pops on light AND dark photos. // back instantly when deselected by dropping the transition
'hover:ring-2 hover:ring-primary/60 hover:ring-offset-1 hover:ring-offset-bg', // entirely. Heap membership doesn't animate — it just paints
// the green tint overlay.
isSelected && 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' !imageLoaded && 'bg-surface animate-pulse'
)} )}
style={ style={
@@ -271,6 +281,18 @@ export function PhotoThumbnail({
</div> </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 ──────────────────────────────────────────────────── {/* ── Ornaments ────────────────────────────────────────────────────
* All overlays compose the THUMB_BADGE_* classes so they share one * All overlays compose the THUMB_BADGE_* classes so they share one
* shape/size/ring family. Colour signals semantics: * shape/size/ring family. Colour signals semantics:
@@ -315,27 +337,12 @@ export function PhotoThumbnail({
</div> </div>
)} )}
{/* TL — selection */} {/* TL — owner badge for shared photos. Selection itself is
{isSelected && ( * conveyed by the ring/outline on the wrapper, no badge needed. */}
<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. */}
{photo.owner_username && ( {photo.owner_username && (
<div <div
className={clsx( className={clsx(
'absolute left-1', 'absolute left-1 top-1',
isSelected ? 'top-7' : 'top-1',
THUMB_BADGE_BASE, THUMB_BADGE_BASE,
THUMB_BADGE_NEUTRAL, THUMB_BADGE_NEUTRAL,
'max-w-[90px]' 'max-w-[90px]'
@@ -374,22 +381,9 @@ export function PhotoThumbnail({
</div> </div>
)} )}
{/* BR — flags stack: heap (primary) · duplicate / discard (neutral). {/* BR — duplicate / discard (neutral). Heap membership is shown
* Heap is the only user-state flag here so it gets primary; the * via the green tint overlay above, no badge here. */}
* rest are metadata about the file, so they're neutral-dark. */}
<div className="absolute bottom-1 right-1 flex items-center gap-1"> <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 && ( {photo.is_duplicate && (
<div <div
className={clsx(THUMB_BADGE_BASE, THUMB_BADGE_SQUARE, THUMB_BADGE_NEUTRAL)} className={clsx(THUMB_BADGE_BASE, THUMB_BADGE_SQUARE, THUMB_BADGE_NEUTRAL)}

View File

@@ -182,21 +182,26 @@ export function Timeline() {
const prevViewModeRef = useRef(viewMode) const prevViewModeRef = useRef(viewMode)
// Auto-focus the first photo on initial grid load so arrow-key nav // 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 // works immediately without a pre-click. One-shot — after the user
// current active photo — we never clobber the user's selection or // explicitly clears the selection (Escape), we don't re-focus, so
// the one they restored by navigating back from preview. // the metadata sidebar can collapse and stay collapsed.
const didAutoFocusRef = useRef(false)
useEffect(() => { useEffect(() => {
if (didAutoFocusRef.current) return
if (viewMode !== 'grid') return if (viewMode !== 'grid') return
if (activePhotoId) return if (activePhotoId) {
didAutoFocusRef.current = true
return
}
if (photos.length === 0) return if (photos.length === 0) return
didAutoFocusRef.current = true
selectPhoto(photos[0].id) selectPhoto(photos[0].id)
}, [viewMode, activePhotoId, photos, selectPhoto]) }, [viewMode, activePhotoId, photos, selectPhoto])
// Membership in the active heap (for the basket affordance). Subscribed // Membership in the active heap (drives the green tint on each
// once at this level so we don't have hundreds of thumbnails each // thumbnail). Subscribed once at this level so we don't have hundreds
// subscribing to the same query. // of thumbnails each subscribing to the same query.
const { memberIds: activeHeapMembers, activeHeap } = useActiveHeapMembers() const { memberIds: activeHeapMembers } = useActiveHeapMembers()
const activeHeapName = activeHeap?.name ?? null
// Build the flat virtualizer items: a mix of group headers and rows of // Build the flat virtualizer items: a mix of group headers and rows of
// photos. Date headers appear only in the main timeline (groupBy='date'). // photos. Date headers appear only in the main timeline (groupBy='date').
@@ -783,7 +788,6 @@ export function Timeline() {
fill fill
isSelected={selectedPhotos.includes(photo.id)} isSelected={selectedPhotos.includes(photo.id)}
isInActiveHeap={activeHeapMembers.has(photo.id)} isInActiveHeap={activeHeapMembers.has(photo.id)}
activeHeapName={activeHeapName}
onClick={(e) => { onClick={(e) => {
if (e.shiftKey) { if (e.shiftKey) {
selectRange(photo.id) 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 { FOLDER_TREE_QUERY_KEY } from '../../hooks/useFolderTreeQuery'
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery' import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
import { toast } from '../ToastContainer' import { toast } from '../ToastContainer'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
interface UploadModalProps { interface UploadModalProps {
isOpen: boolean isOpen: boolean
@@ -123,15 +129,8 @@ export function UploadModal({ isOpen, onClose, initialFolderId }: UploadModalPro
} }
}, [isOpen, initialFolderId, folderTree, destFolderId]) }, [isOpen, initialFolderId, folderTree, destFolderId])
// Esc closes (unless mid-upload — don't orphan in-flight requests). // Esc / overlay-click dismissal lives on the Dialog primitive below.
useEffect(() => { // We only need to guard against closing while an upload is in flight.
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])
// Reset transient state on open so a previous session's queue doesn't // Reset transient state on open so a previous session's queue doesn't
// bleed into a fresh one. // 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 const overallPct = totalBytes > 0 ? Math.round((uploadedBytes / totalBytes) * 100) : 0
if (!isOpen) return null
return ( return (
<div className="fixed inset-0 z-50"> <Dialog
<div open={isOpen}
className="absolute inset-0 bg-black/60 backdrop-blur-sm" onOpenChange={(o) => {
onClick={!isUploading ? onClose : undefined} if (!o && !isUploading) onClose()
/> }}
<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"> <DialogContent className="flex max-h-[85vh] w-[760px] max-w-[760px] flex-col p-0">
{/* Header */} <DialogHeader className="border-b border-border px-5 py-3">
<div className="flex items-center justify-between border-b border-border px-5 py-3"> <DialogTitle className="flex items-center gap-2">
<div className="flex items-center gap-2"> <UploadIcon className="h-4 w-4 text-text-muted" />
<UploadIcon className="h-4 w-4 text-text-muted" /> Upload photos
<h2 className="text-base font-semibold text-text">Upload photos</h2> </DialogTitle>
</div> </DialogHeader>
<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>
{/* Body */} {/* Body */}
<div className="flex min-h-0 flex-1 gap-4 overflow-hidden p-5"> <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()} {isUploading ? 'Uploading…' : `Upload ${queue.length || ''}`.trim()}
</button> </button>
</div> </div>
</div> </DialogContent>
</div> </Dialog>
</div>
) )
} }

View File

@@ -18,14 +18,7 @@ export function useFeaturesQuery() {
}) })
} }
export function useIsFeatureEnabled( export function useIsFeatureEnabled(name: 'vision.enabled'): boolean {
name:
| 'vision.enabled'
| 'vision.ocr.enabled'
| 'vision.detector.enabled'
| 'vision.faces.enabled'
| 'vision.classifier.enabled',
): boolean {
const { data } = useFeaturesQuery() const { data } = useFeaturesQuery()
// Default to enabled while loading so we don't flash "feature off" // Default to enabled while loading so we don't flash "feature off"
// during a first-paint fetch. The backend is the source of truth; // 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('duplicates') === 'true') out.duplicates = true
if (sp.get('needs_review') === 'true') out.needsReview = true
const groupBy = sp.get('group') const groupBy = sp.get('group')
if (groupBy === 'date' || groupBy === 'tag') out.groupBy = groupBy 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.folderId) sp.set('folder_id', f.folderId)
if (f.tagIds.length > 0) sp.set('tag_ids', f.tagIds.join(',')) if (f.tagIds.length > 0) sp.set('tag_ids', f.tagIds.join(','))
if (f.duplicates) sp.set('duplicates', 'true') 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.groupBy !== 'date') sp.set('group', f.groupBy)
if (f.currentSection && f.currentSection !== 'all-photos') if (f.currentSection && f.currentSection !== 'all-photos')
sp.set('section', f.currentSection) sp.set('section', f.currentSection)

View File

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

View File

@@ -52,6 +52,7 @@ export function usePhotosQuery() {
const folderId = useFilterStore((s) => s.folderId) const folderId = useFilterStore((s) => s.folderId)
const tagIds = useFilterStore((s) => s.tagIds) const tagIds = useFilterStore((s) => s.tagIds)
const duplicates = useFilterStore((s) => s.duplicates) const duplicates = useFilterStore((s) => s.duplicates)
const needsReview = useFilterStore((s) => s.needsReview)
const groupBy = useFilterStore((s) => s.groupBy) const groupBy = useFilterStore((s) => s.groupBy)
const sortBy = useFilterStore((s) => s.sortBy) const sortBy = useFilterStore((s) => s.sortBy)
const sortOrder = useFilterStore((s) => s.sortOrder) const sortOrder = useFilterStore((s) => s.sortOrder)
@@ -71,11 +72,12 @@ export function usePhotosQuery() {
folderId, folderId,
tagIds, tagIds,
duplicates, duplicates,
needsReview,
groupBy, groupBy,
sortBy, sortBy,
sortOrder, 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() 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