Compare commits
7 Commits
07b9660e92
...
bd904aca36
| Author | SHA1 | Date | |
|---|---|---|---|
| bd904aca36 | |||
| 696477eefd | |||
| fc63b1f69d | |||
| ac5b18b60c | |||
| a5b4054a71 | |||
| a55839d9a2 | |||
| 749e836617 |
40
.env
40
.env
@@ -1,30 +1,20 @@
|
||||
# Environment variables for Mulita
|
||||
#
|
||||
# Set PHOTO_DIRS to the HOST path of your photo library. The compose file
|
||||
# mounts this at /photos inside the container, and on first boot Mulita
|
||||
# auto-creates a source root pointing at /photos so your library is
|
||||
# scanned with zero further configuration.
|
||||
#
|
||||
# Examples:
|
||||
# macOS / Linux: PHOTO_DIRS=/Users/you/Pictures
|
||||
# Network share: PHOTO_DIRS=/mnt/nas/photos
|
||||
# Windows (WSL): PHOTO_DIRS=/mnt/c/Users/you/Pictures
|
||||
# Mulita / PhotoVault local environment.
|
||||
# See .env.example for the full list of knobs and their docs.
|
||||
|
||||
# REQUIRED — host path to your photo library.
|
||||
PHOTO_DIRS=/Users/dtoro/Pictures/MulitaTest
|
||||
|
||||
# Redis configuration
|
||||
REDIS_URL=redis://localhost:6379
|
||||
# Ports — change if 3000 / 8001 collide with other services on the host.
|
||||
FRONTEND_PORT=3000
|
||||
BACKEND_PORT=8001
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Database URL
|
||||
DATABASE_URL=sqlite+aiosqlite:///data/db/mulita.db
|
||||
# CORS — wildcard for local dev. Lock down for real deployments.
|
||||
ALLOWED_ORIGINS=*
|
||||
|
||||
# Celery configuration
|
||||
CELERY_BROKER_URL=redis://localhost:6379
|
||||
CELERY_RESULT_BACKEND=redis://localhost:6379
|
||||
# Logging + timezone.
|
||||
LOG_LEVEL=INFO
|
||||
TZ=UTC
|
||||
|
||||
# Celery worker pool.
|
||||
CELERYD_CONCURRENCY=4
|
||||
|
||||
# API settings
|
||||
API_HOST=0.0.0.0
|
||||
API_PORT=8000
|
||||
|
||||
# Frontend settings
|
||||
VITE_API_URL=http://localhost:8000
|
||||
83
.env.example
Normal file
83
.env.example
Normal file
@@ -0,0 +1,83 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Mulita / PhotoVault — example environment file
|
||||
#
|
||||
# Copy this file to `.env` and adjust the values for your setup. Every key
|
||||
# below has a sensible default in docker-compose.yml, so you only need to
|
||||
# uncomment the ones you actually want to change.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# ── REQUIRED ─────────────────────────────────────────────────────────────────
|
||||
|
||||
# Host path to your photo library. The compose file mounts this at /photos
|
||||
# inside the backend + worker containers. The backend creates a default
|
||||
# source root pointing at /photos on first boot, so once this is set the
|
||||
# library is scanned with zero further configuration.
|
||||
#
|
||||
# Examples:
|
||||
# macOS / Linux: PHOTO_DIRS=/Users/you/Pictures
|
||||
# Network share: PHOTO_DIRS=/mnt/nas/photos
|
||||
# Windows (WSL): PHOTO_DIRS=/mnt/c/Users/you/Pictures
|
||||
PHOTO_DIRS=./photos
|
||||
|
||||
|
||||
# ── PORTS ────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Host port the SPA is served on. Browse to http://<host>:<FRONTEND_PORT>/.
|
||||
FRONTEND_PORT=3000
|
||||
|
||||
# Host port for the backend API. Almost never needed directly — the frontend
|
||||
# nginx proxies /api/ to the backend over the internal compose network. Kept
|
||||
# exposed for debugging / curl.
|
||||
BACKEND_PORT=8001
|
||||
|
||||
# Redis host port. Internal services reach Redis on its container name; this
|
||||
# is just for local debugging.
|
||||
REDIS_PORT=6379
|
||||
|
||||
|
||||
# ── CORS ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Comma-separated list of allowed origins for direct browser access to the
|
||||
# backend. Same-origin requests through the nginx / vite proxy never trip
|
||||
# CORS, so this only matters when something hits the backend port directly
|
||||
# from a different origin (e.g. another machine, dev tools, a reverse proxy
|
||||
# under a different hostname).
|
||||
#
|
||||
# Default "*" is permissive, fine for a single-user homelab. Lock it down in
|
||||
# real deployments:
|
||||
# ALLOWED_ORIGINS=https://photos.example.com
|
||||
# ALLOWED_ORIGINS=https://photos.example.com,http://192.168.1.10:3000
|
||||
ALLOWED_ORIGINS=*
|
||||
|
||||
|
||||
# ── LOGGING / TIMEZONE ───────────────────────────────────────────────────────
|
||||
|
||||
# Python log level for the backend and Celery worker. Bump to DEBUG when
|
||||
# chasing scan / thumbnail issues.
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Container timezone. Affects the timestamps in logs and the "added at"
|
||||
# field on newly imported photos. Defaults to UTC.
|
||||
# TZ=Europe/Berlin
|
||||
# TZ=America/New_York
|
||||
TZ=UTC
|
||||
|
||||
|
||||
# ── WORKER CONCURRENCY ───────────────────────────────────────────────────────
|
||||
|
||||
# How many parallel Celery worker processes to spin up. Each one can run
|
||||
# one scan / thumbnail / metadata job at a time. Bump on a beefy host with a
|
||||
# big library; lower on a Pi.
|
||||
CELERYD_CONCURRENCY=4
|
||||
|
||||
|
||||
# ── INTERNAL (rarely overridden) ─────────────────────────────────────────────
|
||||
|
||||
# These point at the in-compose Redis and the bind-mounted SQLite db. Override
|
||||
# only if you're running Mulita without docker-compose or against an external
|
||||
# Redis.
|
||||
# REDIS_URL=redis://redis:6379
|
||||
# CELERY_BROKER_URL=redis://redis:6379
|
||||
# CELERY_RESULT_BACKEND=redis://redis:6379
|
||||
# DATABASE_URL=sqlite+aiosqlite:////data/db/mulita.db
|
||||
51
README.md
51
README.md
@@ -43,19 +43,16 @@ git clone <repository-url>
|
||||
cd muleimage
|
||||
```
|
||||
|
||||
2. Set **one** environment variable in `.env` — the **host** directory
|
||||
that contains your photo library. Whatever you point at will become
|
||||
your library inside Mulita.
|
||||
2. Copy the example env file and set **one** variable — the **host**
|
||||
directory that contains your photo library. Whatever you point at
|
||||
will become your library inside Mulita.
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
PHOTO_DIRS=/Users/you/Pictures
|
||||
|
||||
# or any folder
|
||||
PHOTO_DIRS=/mnt/nas/photos
|
||||
|
||||
# Windows (WSL)
|
||||
PHOTO_DIRS=/mnt/c/Users/you/Pictures
|
||||
cp .env.example .env
|
||||
# then edit .env and set PHOTO_DIRS:
|
||||
# macOS / Linux: PHOTO_DIRS=/Users/you/Pictures
|
||||
# Network share: PHOTO_DIRS=/mnt/nas/photos
|
||||
# Windows (WSL): PHOTO_DIRS=/mnt/c/Users/you/Pictures
|
||||
```
|
||||
|
||||
3. Start the stack:
|
||||
@@ -70,6 +67,38 @@ docker compose up -d
|
||||
|
||||
You don't need to touch `mulita.yml` or the API to get started.
|
||||
|
||||
### Configuration knobs
|
||||
|
||||
Everything is environment-driven. `PHOTO_DIRS` is the only required
|
||||
value; the rest have sensible defaults documented in `.env.example`:
|
||||
|
||||
| Variable | Default | Notes |
|
||||
|----------------------|---------|----------------------------------------------------|
|
||||
| `PHOTO_DIRS` | — | **Required.** Host path mounted at `/photos`. |
|
||||
| `FRONTEND_PORT` | `3000` | SPA host port. Bump if `3000` is taken. |
|
||||
| `BACKEND_PORT` | `8001` | Direct backend port (debug only — frontend uses internal nginx proxy). |
|
||||
| `REDIS_PORT` | `6379` | Redis host port (internal services don't need it). |
|
||||
| `ALLOWED_ORIGINS` | `*` | Comma-separated CORS origins for direct backend access. Lock down for prod, e.g. `https://photos.example.com`. |
|
||||
| `LOG_LEVEL` | `INFO` | Backend + worker log level. `DEBUG` for chasing scan issues. |
|
||||
| `TZ` | `UTC` | Container timezone. Affects log timestamps and "added at". |
|
||||
| `CELERYD_CONCURRENCY`| `4` | Parallel worker processes (scans, thumbs, metadata). Lower on a Pi, higher on a beefy host. |
|
||||
|
||||
### Accessing from another machine
|
||||
|
||||
The frontend talks to the backend through its bundled nginx, which
|
||||
proxies `/api/` to the backend on the internal compose network. That
|
||||
means requests are always **same-origin** as the page, so accessing
|
||||
Mulita from another host works without any CORS dance:
|
||||
|
||||
```
|
||||
http://<your-server-ip>:3000
|
||||
```
|
||||
|
||||
If you want to put it behind a reverse proxy at e.g.
|
||||
`https://photos.your.tld`, set `ALLOWED_ORIGINS` to that host so the
|
||||
backend's direct port (`BACKEND_PORT`) also accepts cross-origin
|
||||
requests if anything bypasses the proxy.
|
||||
|
||||
### How libraries are managed
|
||||
|
||||
Mulita is **config-driven**: the host directory you mount via
|
||||
|
||||
@@ -70,7 +70,29 @@ class Settings(BaseSettings):
|
||||
# API settings
|
||||
api_host: str = Field(default="0.0.0.0", env="API_HOST")
|
||||
api_port: int = Field(default=8000, env="API_PORT")
|
||||
|
||||
|
||||
# CORS — comma-separated list of allowed origins, or "*" for any.
|
||||
# Same-origin requests (the normal case behind nginx / vite proxy)
|
||||
# never trip CORS, so this is only for direct browser access from
|
||||
# other origins (LAN IP, reverse proxy, dev tools).
|
||||
allowed_origins: str = Field(default="*", env="ALLOWED_ORIGINS")
|
||||
|
||||
# Logging — accepts standard python levels (DEBUG, INFO, WARNING,
|
||||
# ERROR, CRITICAL). Bumped from INFO when chasing a problem.
|
||||
log_level: str = Field(default="INFO", env="LOG_LEVEL")
|
||||
|
||||
@property
|
||||
def cors_origins(self) -> list[str]:
|
||||
"""Parse the ALLOWED_ORIGINS env var into a list. Accepts:
|
||||
- "*" → wildcard (single-element list ["*"])
|
||||
- "http://a.com,http://b.com" → split + strip
|
||||
Empty entries are dropped.
|
||||
"""
|
||||
raw = (self.allowed_origins or "").strip()
|
||||
if not raw or raw == "*":
|
||||
return ["*"]
|
||||
return [o.strip() for o in raw.split(",") if o.strip()]
|
||||
|
||||
# App configuration from YAML
|
||||
_config: Optional[MulitaConfig] = None
|
||||
|
||||
|
||||
@@ -62,11 +62,20 @@ app = FastAPI(
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# Configure CORS
|
||||
# Configure CORS. The frontend normally talks to the backend through the
|
||||
# nginx (prod) or vite (dev) proxy, so requests are same-origin and never
|
||||
# trip CORS. ALLOWED_ORIGINS in .env controls the fallback for direct
|
||||
# browser access from other origins (LAN IP, reverse proxy under a
|
||||
# different host). Defaults to "*" since this is a single-user homelab
|
||||
# tool; lock it down by setting e.g. ALLOWED_ORIGINS=https://photos.your.tld
|
||||
# in production deployments.
|
||||
_origins = settings.cors_origins
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3000", "http://localhost:5173"], # Frontend URLs
|
||||
allow_credentials=True,
|
||||
allow_origins=_origins,
|
||||
# Wildcard origins can't be combined with credentials per the CORS
|
||||
# spec, so credentials get auto-disabled in that case.
|
||||
allow_credentials=_origins != ["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@@ -137,6 +137,46 @@ async def update_heap(
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{heap_id}/duplicate", status_code=201)
|
||||
async def duplicate_heap(heap_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Create a new heap with the same membership as an existing one. The
|
||||
new heap is named "{original} (copy)" and is never the active target —
|
||||
duplicating shouldn't quietly steal the user's T-key destination.
|
||||
"""
|
||||
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
||||
source = result.scalar_one_or_none()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Heap not found")
|
||||
|
||||
new_heap = Heap(name=f"{source.name} (copy)", is_active=False)
|
||||
db.add(new_heap)
|
||||
await db.flush() # populate new_heap.id without committing yet
|
||||
|
||||
# Bulk-copy the membership rows. SELECT photo_id FROM heap_photos WHERE
|
||||
# heap_id = :src — INSERT each into the new heap. Done as a single
|
||||
# INSERT...SELECT to avoid round-tripping ids through Python.
|
||||
member_rows = await db.execute(
|
||||
select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id)
|
||||
)
|
||||
photo_ids = [row[0] for row in member_rows.all()]
|
||||
if photo_ids:
|
||||
await db.execute(
|
||||
insert(heap_photos),
|
||||
[{"heap_id": new_heap.id, "photo_id": pid} for pid in photo_ids],
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(new_heap)
|
||||
return {
|
||||
"id": new_heap.id,
|
||||
"name": new_heap.name,
|
||||
"is_active": False,
|
||||
"photo_count": len(photo_ids),
|
||||
"created_at": new_heap.created_at,
|
||||
"updated_at": new_heap.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{heap_id}", status_code=204)
|
||||
async def delete_heap(heap_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Delete a heap. Photos themselves are unaffected — only the membership
|
||||
|
||||
@@ -12,30 +12,68 @@ router = APIRouter()
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_library_stats(db: AsyncSession = Depends(get_db)):
|
||||
"""Get library statistics"""
|
||||
# Count total photos
|
||||
total_photos = await db.execute(
|
||||
select(func.count(Photo.id)).where(Photo.media_type.in_(['photo', 'heic', 'raw']))
|
||||
)
|
||||
photo_count = total_photos.scalar()
|
||||
|
||||
# Count total videos
|
||||
total_videos = await db.execute(
|
||||
select(func.count(Photo.id)).where(Photo.media_type == 'video')
|
||||
)
|
||||
video_count = total_videos.scalar()
|
||||
|
||||
# Calculate total size
|
||||
total_size = await db.execute(
|
||||
select(func.sum(Photo.file_size))
|
||||
)
|
||||
size = total_size.scalar() or 0
|
||||
|
||||
"""Get library statistics + per-section counts. Each section count
|
||||
matches the filter the sidebar applies when you click it, so the
|
||||
sidebar badges and the timeline below them stay in sync.
|
||||
|
||||
- all_photos: non-discarded photos + videos (matches the All
|
||||
Photos section's default filter)
|
||||
- rated: non-discarded with rating >= 1
|
||||
- duplicates: non-discarded with is_duplicate = true
|
||||
- discarded: is_discarded = true
|
||||
- total_size: raw bytes across every row, including discarded
|
||||
"""
|
||||
not_discarded = Photo.is_discarded.is_(False)
|
||||
|
||||
all_photos_count = (
|
||||
await db.execute(select(func.count(Photo.id)).where(not_discarded))
|
||||
).scalar() or 0
|
||||
|
||||
rated_count = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(not_discarded, Photo.rating >= 1)
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
duplicates_count = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(
|
||||
not_discarded, Photo.is_duplicate.is_(True)
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
discarded_count = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(Photo.is_discarded.is_(True))
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
# Legacy split (kept for the existing /stats consumers).
|
||||
photo_count = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(
|
||||
Photo.media_type.in_(['photo', 'heic', 'raw'])
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
video_count = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(Photo.media_type == 'video')
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
size = (await db.execute(select(func.sum(Photo.file_size)))).scalar() or 0
|
||||
|
||||
return {
|
||||
"all_photos": all_photos_count,
|
||||
"rated": rated_count,
|
||||
"duplicates": duplicates_count,
|
||||
"discarded": discarded_count,
|
||||
"total_photos": photo_count,
|
||||
"total_videos": video_count,
|
||||
"total_size": size,
|
||||
"total_size_gb": round(size / (1024**3), 2) if size else 0
|
||||
"total_size_gb": round(size / (1024**3), 2) if size else 0,
|
||||
}
|
||||
|
||||
@router.post("/scan")
|
||||
|
||||
@@ -146,24 +146,37 @@ async def list_photos(
|
||||
)
|
||||
|
||||
# Tag filter — comma-separated tag ids, AND semantics. A photo must
|
||||
# have a row in photo_tags for EVERY listed tag. Implemented as one
|
||||
# subquery per tag id since SQLite doesn't have an efficient
|
||||
# "set-contains-all" operator.
|
||||
# have a row in photo_tags for EVERY listed tag. Implemented as a
|
||||
# single GROUP BY ... HAVING COUNT(DISTINCT) = N subquery so the cost
|
||||
# is independent of the number of tags being filtered.
|
||||
if tag_ids:
|
||||
tag_id_list = [t.strip() for t in tag_ids.split(',') if t.strip()]
|
||||
for tid in tag_id_list:
|
||||
filters.append(
|
||||
Photo.id.in_(
|
||||
select(photo_tags.c.photo_id).where(photo_tags.c.tag_id == tid)
|
||||
if tag_id_list:
|
||||
matching_photos = (
|
||||
select(photo_tags.c.photo_id)
|
||||
.where(photo_tags.c.tag_id.in_(tag_id_list))
|
||||
.group_by(photo_tags.c.photo_id)
|
||||
.having(
|
||||
func.count(func.distinct(photo_tags.c.tag_id)) == len(tag_id_list)
|
||||
)
|
||||
)
|
||||
filters.append(Photo.id.in_(matching_photos))
|
||||
|
||||
# Apply all filters
|
||||
if filters:
|
||||
query = query.where(and_(*filters))
|
||||
|
||||
# Apply sorting
|
||||
sort_column = getattr(Photo, sort, Photo.taken_at)
|
||||
# Apply sorting. The sort field is whitelisted so a malicious client
|
||||
# can't pass an arbitrary column name (e.g. "filepath" leaks paths or
|
||||
# any other Photo attribute the model exposes).
|
||||
SORT_WHITELIST = {
|
||||
"taken_at": Photo.taken_at,
|
||||
"added_at": Photo.added_at,
|
||||
"filename": Photo.filename,
|
||||
"file_size": Photo.file_size,
|
||||
"rating": Photo.rating,
|
||||
}
|
||||
sort_column = SORT_WHITELIST.get(sort, Photo.taken_at)
|
||||
if order == "desc":
|
||||
query = query.order_by(sort_column.desc())
|
||||
else:
|
||||
@@ -849,6 +862,54 @@ async def bulk_action(
|
||||
elif action.action == 'set_color':
|
||||
for photo in photos:
|
||||
photo.color_label = action.value
|
||||
elif action.action == 'add_tags':
|
||||
# value is a list of tag ids. We bulk-insert (photo_id, tag_id)
|
||||
# rows for every (photo, tag) combination that doesn't already
|
||||
# exist, so the operation is idempotent.
|
||||
tag_ids = action.value or []
|
||||
if not isinstance(tag_ids, list) or not tag_ids:
|
||||
return {"status": "success", "added": 0, "message": "No tags supplied"}
|
||||
photo_ids = [p.id for p in photos]
|
||||
existing = await db.execute(
|
||||
select(photo_tags.c.photo_id, photo_tags.c.tag_id).where(
|
||||
photo_tags.c.photo_id.in_(photo_ids),
|
||||
photo_tags.c.tag_id.in_(tag_ids),
|
||||
)
|
||||
)
|
||||
existing_pairs = {(row[0], row[1]) for row in existing.all()}
|
||||
new_rows = [
|
||||
{"photo_id": pid, "tag_id": tid}
|
||||
for pid in photo_ids
|
||||
for tid in tag_ids
|
||||
if (pid, tid) not in existing_pairs
|
||||
]
|
||||
if new_rows:
|
||||
from sqlalchemy import insert
|
||||
await db.execute(insert(photo_tags), new_rows)
|
||||
await db.commit()
|
||||
return {
|
||||
"status": "success",
|
||||
"added": len(new_rows),
|
||||
"message": f"Added {len(new_rows)} tag link{'s' if len(new_rows) != 1 else ''}",
|
||||
}
|
||||
elif action.action == 'remove_tags':
|
||||
tag_ids = action.value or []
|
||||
if not isinstance(tag_ids, list) or not tag_ids:
|
||||
return {"status": "success", "removed": 0, "message": "No tags supplied"}
|
||||
photo_ids = [p.id for p in photos]
|
||||
from sqlalchemy import delete as sql_delete
|
||||
result = await db.execute(
|
||||
sql_delete(photo_tags).where(
|
||||
photo_tags.c.photo_id.in_(photo_ids),
|
||||
photo_tags.c.tag_id.in_(tag_ids),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return {
|
||||
"status": "success",
|
||||
"removed": result.rowcount or 0,
|
||||
"message": f"Removed tag link{'s' if (result.rowcount or 0) != 1 else ''}",
|
||||
}
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
frontend:
|
||||
build:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: mulita-frontend
|
||||
ports:
|
||||
- "3000:80"
|
||||
# Host port is configurable via FRONTEND_PORT in .env so multiple
|
||||
# instances / other services on the same host don't collide.
|
||||
- "${FRONTEND_PORT:-3000}:80"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
@@ -20,7 +22,10 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: mulita-backend
|
||||
ports:
|
||||
- "8001:8000"
|
||||
# Direct backend access on the host is rarely needed (the frontend
|
||||
# talks to it through the nginx /api proxy on the same network),
|
||||
# but it's exposed for debugging / curl. Override with BACKEND_PORT.
|
||||
- "${BACKEND_PORT:-8001}:8000"
|
||||
volumes:
|
||||
- ./mulita.yml:/app/config/mulita.yml:ro
|
||||
# The single host → container mount for your photo library. Set
|
||||
@@ -38,6 +43,9 @@ services:
|
||||
- CELERY_BROKER_URL=redis://redis:6379
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379
|
||||
- PHOTO_DIRS=${PHOTO_DIRS:-/photos}
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- TZ=${TZ:-UTC}
|
||||
depends_on:
|
||||
- redis
|
||||
networks:
|
||||
@@ -49,7 +57,7 @@ services:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: mulita-worker
|
||||
command: celery -A app.tasks.celery worker --loglevel=info --concurrency=4
|
||||
command: celery -A app.tasks.celery worker --loglevel=${LOG_LEVEL:-info} --concurrency=${CELERYD_CONCURRENCY:-4}
|
||||
volumes:
|
||||
- ./mulita.yml:/app/config/mulita.yml:ro
|
||||
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
||||
@@ -62,7 +70,9 @@ services:
|
||||
- CELERY_BROKER_URL=redis://redis:6379
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379
|
||||
- PHOTO_DIRS=${PHOTO_DIRS:-/photos}
|
||||
- CELERYD_CONCURRENCY=4
|
||||
- CELERYD_CONCURRENCY=${CELERYD_CONCURRENCY:-4}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- TZ=${TZ:-UTC}
|
||||
depends_on:
|
||||
- redis
|
||||
- backend
|
||||
@@ -73,8 +83,10 @@ services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: mulita-redis
|
||||
# Host port exposed only for local debugging; the backend / worker
|
||||
# reach Redis via the internal mulita-network on its container name.
|
||||
ports:
|
||||
- "6379:6379"
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Timeline } from './components/timeline/Timeline'
|
||||
import { LeftSidebar } from './components/layout/LeftSidebar'
|
||||
import { RightSidebar } from './components/layout/RightSidebar'
|
||||
@@ -37,22 +37,22 @@ function App() {
|
||||
|
||||
// Auto-show right sidebar when photos are selected — but only in grid mode,
|
||||
// so leaving the preview doesn't fight the user's prior sidebar state.
|
||||
if (viewMode === 'grid') {
|
||||
// Lives in an effect (not the render body) to avoid setState-during-render
|
||||
// and the cascading re-renders the audit caught.
|
||||
useEffect(() => {
|
||||
if (viewMode !== 'grid') return
|
||||
if (selectedPhotos.length > 0 && !rightSidebarOpen) {
|
||||
setRightSidebarOpen(true)
|
||||
} else if (selectedPhotos.length === 0 && rightSidebarOpen) {
|
||||
setRightSidebarOpen(false)
|
||||
}
|
||||
}
|
||||
}, [viewMode, selectedPhotos.length, rightSidebarOpen])
|
||||
|
||||
const showRightSidebar = rightSidebarOpen && viewMode === 'grid'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-bg text-text">
|
||||
<TopBar />
|
||||
<FilterBar />
|
||||
<DiscardActionBar />
|
||||
<KeyboardHints />
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left Sidebar */}
|
||||
@@ -64,9 +64,15 @@ function App() {
|
||||
<LeftSidebar />
|
||||
</div>
|
||||
|
||||
{/* Main Content - Timeline */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Timeline />
|
||||
{/* Main column — filter bar, discard bar, timeline. Lives to the
|
||||
* right of the left sidebar so the filter row doesn't bleed
|
||||
* across the sidebar. */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<FilterBar />
|
||||
<DiscardActionBar />
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Timeline />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar */}
|
||||
@@ -79,6 +85,10 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating keyboard hints — pinned bottom-center, glassy. Sits
|
||||
* above the timeline and below the toast layer. */}
|
||||
<KeyboardHints />
|
||||
|
||||
{/* Scan Progress Indicator */}
|
||||
<ScanProgress />
|
||||
|
||||
|
||||
@@ -25,22 +25,22 @@ export function KeyboardHints() {
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex justify-center border-b border-border bg-surface/60 px-4 py-1.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="pointer-events-none fixed bottom-4 left-1/2 z-30 -translate-x-1/2">
|
||||
<div className="pointer-events-auto flex items-center gap-3 rounded-full border border-border/60 bg-surface/40 px-4 py-1.5 shadow-lg ring-1 ring-white/5 backdrop-blur-md">
|
||||
{hints.map((hint, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<kbd className="rounded bg-surface-offset px-2 py-0.5 text-[11px] font-medium text-text">
|
||||
<kbd className="rounded bg-surface-offset/80 px-1.5 py-0.5 text-[11px] font-medium text-text">
|
||||
{hint.key}
|
||||
</kbd>
|
||||
<span className="text-xs text-text-muted">{hint.action}</span>
|
||||
{i < hints.length - 1 && (
|
||||
<span className="ml-2 text-text-faint">•</span>
|
||||
<span className="ml-1 text-text-faint">•</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{selectedCount > 0 && (
|
||||
<>
|
||||
<span className="ml-2 text-text-faint">•</span>
|
||||
<span className="text-text-faint">•</span>
|
||||
<span className="text-xs font-medium text-primary">
|
||||
{selectedCount} selected
|
||||
</span>
|
||||
|
||||
@@ -50,6 +50,7 @@ export function ScanProgress() {
|
||||
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['heaps'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['tags'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
|
||||
|
||||
if (isVisible && (scanStatus?.processed_files ?? 0) > 0) {
|
||||
// Keep showing for 3 seconds after scan completes
|
||||
|
||||
@@ -8,6 +8,7 @@ import { discard as discardApi, photos as photosApi } from '../../services/api'
|
||||
import { toast } from '../ToastContainer'
|
||||
import { ConfirmDialog } from '../dialogs/ConfirmDialog'
|
||||
import { registerUndoable } from '../../store/undoStore'
|
||||
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
|
||||
|
||||
/**
|
||||
* Top-of-timeline bar visible only when the discarded filter is active.
|
||||
@@ -32,10 +33,12 @@ export function DiscardActionBar() {
|
||||
async () => {
|
||||
await photosApi.bulkDiscard(ids)
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||
}
|
||||
)
|
||||
clearSelection()
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||
},
|
||||
onError: (e: any) => toast.error('Restore failed', e.message || 'Unknown error'),
|
||||
})
|
||||
@@ -58,6 +61,7 @@ export function DiscardActionBar() {
|
||||
}
|
||||
clearSelection()
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||
setDeleteSelectedOpen(false)
|
||||
},
|
||||
onError: (e: any) =>
|
||||
@@ -79,6 +83,7 @@ export function DiscardActionBar() {
|
||||
}
|
||||
clearSelection()
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||
setConfirmOpen(false)
|
||||
},
|
||||
onError: (e: any) => toast.error('Empty failed', e.message || 'Unknown error'),
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { Star, X, ArrowDown, ArrowUp } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Star, X, ArrowDown, ArrowUp, Search } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
useFilterStore,
|
||||
hasActiveFilters,
|
||||
type MediaType,
|
||||
type ColorLabel,
|
||||
type SortField,
|
||||
} from '../../store/filterStore'
|
||||
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
||||
import { FilterPill } from './FilterPill'
|
||||
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
|
||||
const MEDIA_TYPES: { value: MediaType; label: string }[] = [
|
||||
{ value: 'photo', label: 'Photo' },
|
||||
@@ -17,15 +20,6 @@ const MEDIA_TYPES: { value: MediaType; label: string }[] = [
|
||||
{ value: 'heic', label: 'HEIC' },
|
||||
]
|
||||
|
||||
const COLOR_LABEL_OPTIONS: { value: ColorLabel; className: string }[] = [
|
||||
{ value: 'red', className: 'bg-red-500' },
|
||||
{ value: 'orange', className: 'bg-orange-500' },
|
||||
{ value: 'yellow', className: 'bg-yellow-400' },
|
||||
{ value: 'green', className: 'bg-green-500' },
|
||||
{ value: 'blue', className: 'bg-blue-500' },
|
||||
{ value: 'purple', className: 'bg-purple-500' },
|
||||
]
|
||||
|
||||
const SORT_OPTIONS: { value: SortField; label: string }[] = [
|
||||
{ value: 'taken_at', label: 'Date taken' },
|
||||
{ value: 'added_at', label: 'Date added' },
|
||||
@@ -66,6 +60,26 @@ export function FilterBar() {
|
||||
|
||||
const { data: allTags = [] } = useTagsQuery()
|
||||
|
||||
// Search box. Local state mirrors the store so typing stays responsive
|
||||
// while we debounce store writes (each store write triggers a re-fetch).
|
||||
const storeQ = useFilterStore((s) => s.q)
|
||||
const setStoreQ = useFilterStore((s) => s.setQ)
|
||||
const [searchQuery, setSearchQuery] = useState(storeQ)
|
||||
useEffect(() => {
|
||||
setSearchQuery(storeQ)
|
||||
}, [storeQ])
|
||||
const debounceRef = useRef<number | null>(null)
|
||||
useEffect(() => {
|
||||
if (searchQuery === storeQ) return
|
||||
if (debounceRef.current) window.clearTimeout(debounceRef.current)
|
||||
debounceRef.current = window.setTimeout(() => {
|
||||
setStoreQ(searchQuery)
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
return () => {
|
||||
if (debounceRef.current) window.clearTimeout(debounceRef.current)
|
||||
}
|
||||
}, [searchQuery, storeQ, setStoreQ])
|
||||
|
||||
// Pre-compute pill values + active flags so the JSX stays terse.
|
||||
const dateActive = dateFrom !== null || dateTo !== null
|
||||
const dateValue = dateActive
|
||||
@@ -102,7 +116,43 @@ export function FilterBar() {
|
||||
const anyActive = hasActiveFilters(filterState)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto border-b border-border bg-surface px-3 py-1.5">
|
||||
<div className="flex items-center gap-3 border-b border-border bg-surface px-3 py-1.5">
|
||||
{/* Search — left of the pill cluster. Same id as before so the
|
||||
* global "/" focus shortcut still finds it. */}
|
||||
<div className="relative w-56 flex-shrink-0">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
id="topbar-search"
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setSearchQuery('')
|
||||
setStoreQ('')
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
placeholder="Search photos…"
|
||||
className="w-full rounded-full border border-border bg-surface-2 py-1 pl-8 pr-7 text-xs text-text placeholder-text-muted focus:border-primary focus:outline-none"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('')
|
||||
setStoreQ('')
|
||||
}}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-full p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
|
||||
title="Clear search (Esc)"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pills — centered, scroll horizontally if they overflow. */}
|
||||
<div className="flex flex-1 items-center justify-center gap-1.5 overflow-x-auto">
|
||||
{/* Date */}
|
||||
<FilterPill
|
||||
label="Date"
|
||||
@@ -325,11 +375,13 @@ export function FilterBar() {
|
||||
</button>
|
||||
</div>
|
||||
</FilterPill>
|
||||
</div>
|
||||
|
||||
{/* Clear-all — pinned right of the pill cluster. */}
|
||||
{anyActive && (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="ml-auto whitespace-nowrap rounded-full border border-border px-2.5 py-1 text-xs text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
className="flex-shrink-0 whitespace-nowrap rounded-full border border-border px-2.5 py-1 text-xs text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
title="Clear all filters in this section"
|
||||
>
|
||||
Clear all
|
||||
|
||||
@@ -6,9 +6,10 @@ import clsx from 'clsx'
|
||||
interface FilterPillProps {
|
||||
/** Category label, always shown ("Date", "Type", etc.). */
|
||||
label: string
|
||||
/** When the filter is active, a short summary of its current value
|
||||
* ("≥ 3★", "RAW + Photo", "Mar 2024 → Apr 2026"). Renders inside the
|
||||
* pill so the user sees the state without opening the popover. */
|
||||
/** Currently unused in the rendered output — the inline value display
|
||||
* was making active pills wider than inactive ones. Kept on the
|
||||
* interface so callers don't have to change. The value is still
|
||||
* surfaced via the title attribute for hover discovery. */
|
||||
value?: string | null
|
||||
isActive?: boolean
|
||||
/** When provided + isActive, an X appears inside the pill that clears
|
||||
@@ -94,6 +95,10 @@ export function FilterPill({
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
// Hover to see the active value as a tooltip — keeps the pill at
|
||||
// a constant width regardless of state. The popover is the
|
||||
// canonical place to read/edit the filter value.
|
||||
title={isActive && value ? `${label}: ${value}` : label}
|
||||
className={clsx(
|
||||
'flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs transition-colors',
|
||||
isActive
|
||||
@@ -102,21 +107,27 @@ export function FilterPill({
|
||||
)}
|
||||
>
|
||||
<span className={clsx(isActive && 'font-medium')}>{label}</span>
|
||||
{isActive && value && (
|
||||
<span className="font-mono text-[11px] opacity-90">{value}</span>
|
||||
)}
|
||||
{isActive && onClear ? (
|
||||
<button
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClear()
|
||||
}}
|
||||
className="ml-0.5 rounded-full p-0.5 hover:bg-primary/30"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClear()
|
||||
}
|
||||
}}
|
||||
className="ml-1 inline-flex h-5 w-5 cursor-pointer items-center justify-center rounded-full hover:bg-primary/30"
|
||||
title={`Clear ${label}`}
|
||||
aria-label={`Clear ${label}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3 opacity-60" />
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
ShoppingBasket,
|
||||
Plus,
|
||||
Target,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FolderOutput,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Copy,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
@@ -40,6 +43,32 @@ export function HeapsPanel() {
|
||||
// the drop highlight ring. Only one heap can be the target at a time.
|
||||
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
||||
const [convertingHeap, setConvertingHeap] = useState<Heap | null>(null)
|
||||
// Inline rename state for heap rows: stores the heap id being edited and
|
||||
// the draft name. Mirrors the folder rename pattern in LeftSidebar.
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null)
|
||||
const [renameDraft, setRenameDraft] = useState('')
|
||||
// Which heap's burger menu is currently open. null when no menu is open.
|
||||
// The popover closes on outside click and Escape via the effect below.
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!openMenuId) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setOpenMenuId(null)
|
||||
}
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpenMenuId(null)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [openMenuId])
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
||||
@@ -80,6 +109,24 @@ export function HeapsPanel() {
|
||||
toast.error('Failed to delete heap', e.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: ({ heapId, name }: { heapId: string; name: string }) =>
|
||||
heapsApi.update(heapId, { name }),
|
||||
onSuccess: () => invalidate(),
|
||||
onError: (e: any) =>
|
||||
toast.error('Failed to rename heap', e.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
const duplicateMutation = useMutation({
|
||||
mutationFn: (heapId: string) => heapsApi.duplicate(heapId),
|
||||
onSuccess: (heap) => {
|
||||
invalidate()
|
||||
toast.success('Heap duplicated', heap.name)
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Failed to duplicate heap', e.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
// Drop handler: add the dragged photos to the target heap. Optimistically
|
||||
// updates the membership cache so the basket affordance flips immediately,
|
||||
// mirroring the keyboard P-toggle pattern.
|
||||
@@ -202,18 +249,35 @@ export function HeapsPanel() {
|
||||
const isFiltered = currentSection === `heap-${heap.id}`
|
||||
const isActive = heap.is_active
|
||||
const isDropTarget = dropTargetId === heap.id
|
||||
const isRenaming = renamingId === heap.id
|
||||
const isMenuOpen = openMenuId === heap.id
|
||||
|
||||
const commitRename = () => {
|
||||
const next = renameDraft.trim()
|
||||
if (next && next !== heap.name) {
|
||||
renameMutation.mutate({ heapId: heap.id, name: next })
|
||||
}
|
||||
setRenamingId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={heap.id}
|
||||
className={clsx(
|
||||
'group flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-[13px]',
|
||||
'group relative flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-[13px]',
|
||||
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
||||
isDropTarget && 'ring-2 ring-primary bg-primary/10'
|
||||
)}
|
||||
style={{ paddingLeft: '32px' }}
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
if (isRenaming) return
|
||||
navigateToSection(`heap-${heap.id}`, { heapId: heap.id })
|
||||
}
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setRenamingId(heap.id)
|
||||
setRenameDraft(heap.name)
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) {
|
||||
e.preventDefault()
|
||||
@@ -222,8 +286,6 @@ export function HeapsPanel() {
|
||||
}
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
// Only clear if we're actually leaving this row, not just
|
||||
// moving over a child element.
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
if (dropTargetId === heap.id) setDropTargetId(null)
|
||||
}
|
||||
@@ -249,63 +311,136 @@ export function HeapsPanel() {
|
||||
isFiltered ? 'text-primary' : 'text-text-muted'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={clsx(
|
||||
'flex-1 truncate',
|
||||
isActive && 'font-semibold'
|
||||
)}
|
||||
title={heap.name}
|
||||
>
|
||||
{heap.name}
|
||||
</span>
|
||||
|
||||
{isRenaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={renameDraft}
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur()
|
||||
} else if (e.key === 'Escape') {
|
||||
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"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={clsx('flex-1 truncate', isActive && 'font-semibold')}
|
||||
title={heap.name}
|
||||
>
|
||||
{heap.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Right-aligned cluster. Active indicator + count are
|
||||
* always visible; set-active and kebab appear on hover
|
||||
* to the RIGHT of the count, displacing it slightly so
|
||||
* the count column lines up with the rest of the
|
||||
* sidebar in the resting state. */}
|
||||
{isActive && (
|
||||
<Target
|
||||
className="h-3 w-3 text-primary"
|
||||
className="h-3 w-3 flex-shrink-0 text-primary"
|
||||
aria-label="Active heap (T target)"
|
||||
/>
|
||||
)}
|
||||
{heap.photo_count > 0 && (
|
||||
<span className="rounded bg-surface-offset px-1.5 py-0.5 text-xs text-text-muted">
|
||||
{heap.photo_count > 0 ? (
|
||||
<span className="flex h-5 min-w-[24px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1.5 text-xs tabular-nums text-text-muted">
|
||||
{heap.photo_count}
|
||||
</span>
|
||||
) : (
|
||||
<span className="h-5 min-w-[24px] flex-shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!isActive) setActiveMutation.mutate(heap.id)
|
||||
}}
|
||||
className={clsx(
|
||||
'rounded p-0.5 hover:bg-surface-offset hover:text-text',
|
||||
isActive
|
||||
? 'invisible'
|
||||
: 'invisible text-text-muted group-hover:visible'
|
||||
{!isActive && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setActiveMutation.mutate(heap.id)
|
||||
}}
|
||||
className="invisible flex-shrink-0 rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:visible"
|
||||
title="Set as active heap (T target)"
|
||||
aria-label="Set as active heap"
|
||||
>
|
||||
<Target className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Kebab menu — collects rename / duplicate / convert /
|
||||
* delete so the row stays compact. */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpenMenuId(isMenuOpen ? null : heap.id)
|
||||
}}
|
||||
className={clsx(
|
||||
'rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text',
|
||||
isMenuOpen ? 'visible' : 'invisible group-hover:visible'
|
||||
)}
|
||||
title="More actions"
|
||||
aria-label="More heap actions"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isMenuOpen}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
{isMenuOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
className="absolute right-0 top-full z-30 mt-1 min-w-[160px] overflow-hidden rounded-lg border border-border bg-surface py-1 text-sm shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MenuItem
|
||||
icon={<Pencil className="h-3.5 w-3.5" />}
|
||||
label="Rename"
|
||||
onClick={() => {
|
||||
setOpenMenuId(null)
|
||||
setRenamingId(heap.id)
|
||||
setRenameDraft(heap.name)
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={<Copy className="h-3.5 w-3.5" />}
|
||||
label="Duplicate"
|
||||
onClick={() => {
|
||||
setOpenMenuId(null)
|
||||
duplicateMutation.mutate(heap.id)
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={<FolderOutput className="h-3.5 w-3.5" />}
|
||||
label="Move to folder…"
|
||||
onClick={() => {
|
||||
setOpenMenuId(null)
|
||||
setConvertingHeap(heap)
|
||||
}}
|
||||
/>
|
||||
<div className="my-1 h-px bg-border" />
|
||||
<MenuItem
|
||||
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||
label="Delete"
|
||||
destructive
|
||||
onClick={() => {
|
||||
setOpenMenuId(null)
|
||||
if (
|
||||
confirm(
|
||||
`Delete heap "${heap.name}"? Photos are not affected.`
|
||||
)
|
||||
) {
|
||||
deleteMutation.mutate(heap.id)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
title="Set as active heap (T target)"
|
||||
>
|
||||
<Target className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setConvertingHeap(heap)
|
||||
}}
|
||||
className="invisible rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:visible"
|
||||
title="Convert to folder…"
|
||||
>
|
||||
<FolderOutput className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (confirm(`Delete heap "${heap.name}"? Photos are not affected.`)) {
|
||||
deleteMutation.mutate(heap.id)
|
||||
}
|
||||
}}
|
||||
className="invisible rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-reject group-hover:visible"
|
||||
title="Delete heap"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -319,3 +454,31 @@ export function HeapsPanel() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ import { HeapsPanel } from '../heaps/HeapsPanel'
|
||||
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
||||
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
|
||||
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
||||
import {
|
||||
useLibraryStatsQuery,
|
||||
LIBRARY_STATS_QUERY_KEY,
|
||||
} from '../../hooks/useLibraryStatsQuery'
|
||||
import { registerUndoable } from '../../store/undoStore'
|
||||
import type { Photo } from '../../types/photo'
|
||||
|
||||
@@ -45,6 +49,7 @@ export function LeftSidebar() {
|
||||
const navigateToSection = useFilterStore((s) => s.navigateToSection)
|
||||
const currentSection = useFilterStore((s) => s.currentSection)
|
||||
const { data: allTags = [] } = useTagsQuery()
|
||||
const { data: stats } = useLibraryStatsQuery()
|
||||
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
||||
|
||||
// Bulk discard mutation for the drag-onto-Discarded interaction.
|
||||
@@ -56,9 +61,11 @@ export function LeftSidebar() {
|
||||
async () => {
|
||||
await photosApi.bulkRestore(photoIds)
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||
}
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Discard failed', e?.message || 'Unknown error'),
|
||||
@@ -270,11 +277,11 @@ export function LeftSidebar() {
|
||||
label: 'Views',
|
||||
icon: <Layers2 className="h-4 w-4" />,
|
||||
children: [
|
||||
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: 0 },
|
||||
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 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: 'tags', label: 'Tags', icon: <TagIcon className="h-4 w-4" />, count: tagsTotalCount },
|
||||
{ id: 'duplicates', label: 'Duplicates', icon: <Copy className="h-4 w-4" />, count: 0 },
|
||||
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: 0 },
|
||||
{ id: 'duplicates', label: 'Duplicates', icon: <Copy className="h-4 w-4" />, count: stats?.duplicates ?? 0 },
|
||||
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: stats?.discarded ?? 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -438,11 +445,14 @@ export function LeftSidebar() {
|
||||
<span className="flex-1 truncate">{item.label}</span>
|
||||
)}
|
||||
|
||||
{/* Count Badge */}
|
||||
{item.count !== undefined && item.count > 0 && (
|
||||
<span className="rounded bg-surface-offset px-1.5 py-0.5 text-xs text-text-muted">
|
||||
{/* Count Badge — fixed-width slot so counts line up in a column
|
||||
* across rows regardless of digit count. */}
|
||||
{item.count !== undefined && item.count > 0 ? (
|
||||
<span className="flex h-5 min-w-[24px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1.5 text-xs tabular-nums text-text-muted">
|
||||
{item.count}
|
||||
</span>
|
||||
) : (
|
||||
<span className="h-5 min-w-[24px] flex-shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import { X, Star, Info, ShoppingBasket, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { X, Star, Info, ShoppingBasket, Trash2, Plus } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { photos as photosApi, heaps as heapsApi } from '../../services/api'
|
||||
import {
|
||||
photos as photosApi,
|
||||
heaps as heapsApi,
|
||||
tags as tagsApi,
|
||||
} from '../../services/api'
|
||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
|
||||
import { toast } from '../ToastContainer'
|
||||
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
|
||||
|
||||
type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
|
||||
|
||||
const COLOR_LABEL_OPTIONS: { value: ColorLabel; className: string }[] = [
|
||||
{ value: 'red', className: 'bg-red-500' },
|
||||
{ value: 'orange', className: 'bg-orange-500' },
|
||||
{ value: 'yellow', className: 'bg-yellow-400' },
|
||||
{ value: 'green', className: 'bg-green-500' },
|
||||
{ value: 'blue', className: 'bg-blue-500' },
|
||||
{ value: 'purple', className: 'bg-purple-500' },
|
||||
]
|
||||
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
||||
|
||||
/**
|
||||
* Right-hand details panel.
|
||||
@@ -49,6 +45,59 @@ export function RightSidebar() {
|
||||
onSuccess: invalidatePhotoQueries,
|
||||
})
|
||||
|
||||
// Bulk tag mutations. Tag mutations also need to invalidate the tags
|
||||
// query so the FilterBar / sidebar tag counts stay fresh.
|
||||
const invalidateTagsAndPhotos = () => {
|
||||
invalidatePhotoQueries()
|
||||
queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY })
|
||||
}
|
||||
const bulkAddTagsMutation = useMutation({
|
||||
mutationFn: ({ ids, tagIds }: { ids: string[]; tagIds: string[] }) =>
|
||||
photosApi.bulkAddTags(ids, tagIds),
|
||||
onSuccess: (data) => {
|
||||
const added = data?.added ?? 0
|
||||
toast.success(
|
||||
'Tags added',
|
||||
`${added} new link${added === 1 ? '' : 's'}`
|
||||
)
|
||||
invalidateTagsAndPhotos()
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Add tags failed', e?.message || 'Unknown error'),
|
||||
})
|
||||
const bulkRemoveTagsMutation = useMutation({
|
||||
mutationFn: ({ ids, tagIds }: { ids: string[]; tagIds: string[] }) =>
|
||||
photosApi.bulkRemoveTags(ids, tagIds),
|
||||
onSuccess: (data) => {
|
||||
const removed = data?.removed ?? 0
|
||||
toast.success(
|
||||
'Tags removed',
|
||||
`${removed} link${removed === 1 ? '' : 's'} removed`
|
||||
)
|
||||
invalidateTagsAndPhotos()
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Remove tags failed', e?.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
// Idempotent create-and-attach: lets the user type a brand-new tag
|
||||
// name and apply it to the whole selection in one click.
|
||||
const createAndAttachMutation = useMutation({
|
||||
mutationFn: async ({ name, ids }: { name: string; ids: string[] }) => {
|
||||
const created = await tagsApi.create(name)
|
||||
return photosApi.bulkAddTags(ids, [created.id])
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Tag created and applied')
|
||||
invalidateTagsAndPhotos()
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Create tag failed', e?.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
const { data: allTags = [] } = useTagsQuery()
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
|
||||
// Active heap membership for the bulk Pick toggle.
|
||||
const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
|
||||
|
||||
@@ -101,12 +150,13 @@ export function RightSidebar() {
|
||||
const id = activePhotoId ?? selectedPhotos[0]
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-surface">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<div className="flex h-12 flex-shrink-0 items-center justify-between border-b border-border px-4">
|
||||
<h2 className="text-sm font-semibold text-text">Photo Details</h2>
|
||||
<button
|
||||
onClick={clearSelection}
|
||||
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
title="Clear selection"
|
||||
aria-label="Clear selection"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -232,7 +282,154 @@ export function RightSidebar() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk tags. Click an existing tag chip to apply it to the
|
||||
* whole selection; long-press / X icon to remove. The text
|
||||
* input adds an existing tag if it matches a name, or creates
|
||||
* a new tag and applies it. */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Tags</label>
|
||||
<BulkTagsEditor
|
||||
allTags={allTags}
|
||||
tagInput={tagInput}
|
||||
onTagInputChange={setTagInput}
|
||||
disabled={
|
||||
bulkAddTagsMutation.isPending ||
|
||||
bulkRemoveTagsMutation.isPending ||
|
||||
createAndAttachMutation.isPending
|
||||
}
|
||||
onApply={(tagId) =>
|
||||
bulkAddTagsMutation.mutate({ ids: selectedPhotos, tagIds: [tagId] })
|
||||
}
|
||||
onRemove={(tagId) =>
|
||||
bulkRemoveTagsMutation.mutate({
|
||||
ids: selectedPhotos,
|
||||
tagIds: [tagId],
|
||||
})
|
||||
}
|
||||
onCreate={(name) => {
|
||||
createAndAttachMutation.mutate({ name, ids: selectedPhotos })
|
||||
setTagInput('')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface BulkTagsEditorProps {
|
||||
allTags: { id: string; name: string; color: string | null }[]
|
||||
tagInput: string
|
||||
onTagInputChange: (value: string) => void
|
||||
disabled: boolean
|
||||
onApply: (tagId: string) => void
|
||||
onRemove: (tagId: string) => void
|
||||
onCreate: (name: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact bulk tag editor for the multi-select right sidebar. Unlike the
|
||||
* single-photo TagsEditor we don't show "current tags" — there's no clean
|
||||
* single-photo notion of that across an arbitrary selection. Instead the
|
||||
* user picks an existing tag (apply to all) or types a new one (create
|
||||
* and apply to all).
|
||||
*/
|
||||
function BulkTagsEditor({
|
||||
allTags,
|
||||
tagInput,
|
||||
onTagInputChange,
|
||||
disabled,
|
||||
onApply,
|
||||
onRemove,
|
||||
onCreate,
|
||||
}: BulkTagsEditorProps) {
|
||||
const trimmed = tagInput.trim()
|
||||
const lower = trimmed.toLowerCase()
|
||||
|
||||
const filtered = trimmed
|
||||
? allTags.filter((t) => t.name.toLowerCase().includes(lower))
|
||||
: allTags
|
||||
|
||||
const exactMatch = trimmed
|
||||
? allTags.find((t) => t.name.toLowerCase() === lower)
|
||||
: null
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!trimmed || disabled) return
|
||||
if (exactMatch) {
|
||||
onApply(exactMatch.id)
|
||||
onTagInputChange('')
|
||||
} else {
|
||||
onCreate(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => onTagInputChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
} else if (e.key === 'Escape') {
|
||||
onTagInputChange('')
|
||||
}
|
||||
}}
|
||||
placeholder="Filter or create…"
|
||||
disabled={disabled}
|
||||
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text placeholder-text-faint focus:border-primary focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
|
||||
{trimmed && !exactMatch && (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled}
|
||||
className="flex w-full items-center justify-center gap-1 rounded border border-dashed border-primary/50 px-2 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Create "{trimmed}" and apply
|
||||
</button>
|
||||
)}
|
||||
|
||||
{filtered.length > 0 ? (
|
||||
<div className="flex max-h-40 flex-wrap gap-1 overflow-y-auto">
|
||||
{filtered.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="flex items-center gap-1 rounded bg-surface-2 px-2 py-0.5 text-xs text-text"
|
||||
style={
|
||||
tag.color
|
||||
? { backgroundColor: `${tag.color}33`, color: tag.color }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={() => onApply(tag.id)}
|
||||
disabled={disabled}
|
||||
className="hover:underline disabled:opacity-50"
|
||||
title={`Apply "${tag.name}" to selection`}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRemove(tag.id)}
|
||||
disabled={disabled}
|
||||
className="rounded p-0.5 opacity-60 hover:bg-surface-offset hover:opacity-100 disabled:opacity-30"
|
||||
title={`Remove "${tag.name}" from selection`}
|
||||
aria-label={`Remove ${tag.name} from selection`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-text-faint">No tags match</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,46 +1,17 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Search, X, ShoppingBasket } from 'lucide-react'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
import { ShoppingBasket } from 'lucide-react'
|
||||
import { useHeapsQuery } from '../../hooks/useHeapsQuery'
|
||||
import muliLogo from '../../assets/muli-logo.png'
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
|
||||
/**
|
||||
* Slim top bar — just the logo and the active-heap pill. The search input
|
||||
* lives in the FilterBar now (next to the rest of the filter controls).
|
||||
*/
|
||||
export function TopBar() {
|
||||
// Filter store is the source of truth for search; the input has a local
|
||||
// mirror so typing stays responsive while we debounce store updates.
|
||||
const storeQ = useFilterStore((s) => s.q)
|
||||
const setStoreQ = useFilterStore((s) => s.setQ)
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(storeQ)
|
||||
|
||||
// Keep local input in sync if the store is changed externally (URL hydrate,
|
||||
// active-chip removal, clear-all).
|
||||
useEffect(() => {
|
||||
setSearchQuery(storeQ)
|
||||
}, [storeQ])
|
||||
|
||||
// Debounce local input -> store.
|
||||
const debounceRef = useRef<number | null>(null)
|
||||
useEffect(() => {
|
||||
if (searchQuery === storeQ) return
|
||||
if (debounceRef.current) window.clearTimeout(debounceRef.current)
|
||||
debounceRef.current = window.setTimeout(() => {
|
||||
setStoreQ(searchQuery)
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
return () => {
|
||||
if (debounceRef.current) window.clearTimeout(debounceRef.current)
|
||||
}
|
||||
}, [searchQuery, storeQ, setStoreQ])
|
||||
|
||||
// Currently active heap. Shown as a pill so the user always knows where
|
||||
// their next P-press will land.
|
||||
const { data: heapsList = [] } = useHeapsQuery()
|
||||
const activeHeap = heapsList.find((h) => h.is_active)
|
||||
|
||||
return (
|
||||
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
|
||||
{/* Left — logo + active heap pill */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src={muliLogo} alt="Mulita" className="h-7 w-7 object-contain" />
|
||||
@@ -57,41 +28,6 @@ export function TopBar() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Center — search */}
|
||||
<div className="flex max-w-xl flex-1 items-center px-8">
|
||||
<div className="relative w-full">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
id="topbar-search"
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setSearchQuery('')
|
||||
setStoreQ('')
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
placeholder="Search photos..."
|
||||
className="w-full rounded-md border border-border bg-bg py-1.5 pl-9 pr-9 text-sm text-text placeholder-text-muted focus:border-primary focus:outline-none"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('')
|
||||
setStoreQ('')
|
||||
}}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
title="Clear search"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right — reserved for future actions */}
|
||||
<div className="flex items-center gap-2" />
|
||||
</header>
|
||||
)
|
||||
|
||||
@@ -23,6 +23,10 @@ import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
|
||||
import { toast } from '../ToastContainer'
|
||||
import {
|
||||
COLOR_LABEL_OPTIONS,
|
||||
type ColorLabel,
|
||||
} from '../../constants/colorLabels'
|
||||
|
||||
interface PhotoTagSummary {
|
||||
id: string
|
||||
@@ -47,17 +51,6 @@ interface PhotoDetails {
|
||||
tags?: PhotoTagSummary[]
|
||||
}
|
||||
|
||||
type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
|
||||
|
||||
const COLOR_LABEL_OPTIONS: { value: ColorLabel; className: string }[] = [
|
||||
{ value: 'red', className: 'bg-red-500' },
|
||||
{ value: 'orange', className: 'bg-orange-500' },
|
||||
{ value: 'yellow', className: 'bg-yellow-400' },
|
||||
{ value: 'green', className: 'bg-green-500' },
|
||||
{ value: 'blue', className: 'bg-blue-500' },
|
||||
{ value: 'purple', className: 'bg-purple-500' },
|
||||
]
|
||||
|
||||
interface ExifData {
|
||||
Make?: string
|
||||
Model?: string
|
||||
|
||||
@@ -137,9 +137,7 @@ function buildItems(
|
||||
|
||||
photos.forEach((photo, globalIndex) => {
|
||||
const dateStr =
|
||||
sortBy === 'taken_at'
|
||||
? photo.taken_at
|
||||
: (photo as any).added_at ?? photo.taken_at
|
||||
sortBy === 'taken_at' ? photo.taken_at : photo.added_at ?? photo.taken_at
|
||||
let label: string
|
||||
if (dateStr) {
|
||||
try {
|
||||
@@ -259,13 +257,17 @@ export function Timeline() {
|
||||
return () => el.removeEventListener('scroll', onScroll)
|
||||
}, [])
|
||||
|
||||
// Find the latest header whose start <= scrollTop. That's the label of
|
||||
// the group containing whatever is currently at the top of the viewport.
|
||||
// Find the latest header whose BOTTOM is above the viewport top. That's
|
||||
// the group whose natural in-grid header has scrolled out of view —
|
||||
// exactly the case where we want to pin the label as a sticky overlay.
|
||||
// If the natural header is still visible (scrolled but not yet past),
|
||||
// we return null and let the in-grid label do the work, avoiding the
|
||||
// duplicate-label flash.
|
||||
const stickyLabel = useMemo(() => {
|
||||
if (headerOffsets.length === 0) return null
|
||||
let current: string | null = null
|
||||
for (const h of headerOffsets) {
|
||||
if (h.offset <= scrollTop) current = h.label
|
||||
if (h.offset + HEADER_HEIGHT <= scrollTop) current = h.label
|
||||
else break
|
||||
}
|
||||
return current
|
||||
@@ -424,7 +426,7 @@ export function Timeline() {
|
||||
* positioned children so it isn't affected by translateY transforms.
|
||||
* Updates as the user scrolls past month boundaries. */}
|
||||
{stickyLabel && (
|
||||
<div className="pointer-events-none absolute left-0 right-0 top-0 z-20 border-b border-border bg-bg/90 px-4 py-1 backdrop-blur-sm">
|
||||
<div className="pointer-events-none absolute left-0 right-0 top-0 z-20 border-b-2 border-border bg-bg/95 px-4 py-1.5 shadow-sm backdrop-blur">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-text">
|
||||
{stickyLabel}
|
||||
</h3>
|
||||
|
||||
22
frontend/src/constants/colorLabels.ts
Normal file
22
frontend/src/constants/colorLabels.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Single source of truth for the six Lightroom-style color labels.
|
||||
* Both filter UIs and edit UIs (FilterBar, PhotoInfoPanel, RightSidebar)
|
||||
* read from this list so dot colors and ordering stay consistent.
|
||||
*/
|
||||
|
||||
export type ColorLabel =
|
||||
| 'red'
|
||||
| 'orange'
|
||||
| 'yellow'
|
||||
| 'green'
|
||||
| 'blue'
|
||||
| 'purple'
|
||||
|
||||
export const COLOR_LABEL_OPTIONS: { value: ColorLabel; className: string }[] = [
|
||||
{ value: 'red', className: 'bg-red-500' },
|
||||
{ value: 'orange', className: 'bg-orange-500' },
|
||||
{ value: 'yellow', className: 'bg-yellow-400' },
|
||||
{ value: 'green', className: 'bg-green-500' },
|
||||
{ value: 'blue', className: 'bg-blue-500' },
|
||||
{ value: 'purple', className: 'bg-purple-500' },
|
||||
]
|
||||
@@ -28,9 +28,14 @@ const ALLOWED_SORT_FIELDS: SortField[] = [
|
||||
]
|
||||
const ALLOWED_SORT_ORDERS: SortOrder[] = ['asc', 'desc']
|
||||
|
||||
function parseUrl(): Partial<FilterState> {
|
||||
// What parseUrl returns: a partial filter state, plus the optional
|
||||
// section id (which lives on the store but isn't part of FilterState
|
||||
// itself). The hydrate action accepts this exact shape.
|
||||
type HydratePayload = Partial<FilterState> & { currentSection?: string }
|
||||
|
||||
function parseUrl(): HydratePayload {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
const out: Partial<FilterState> = {}
|
||||
const out: HydratePayload = {}
|
||||
|
||||
const q = sp.get('q')
|
||||
if (q) out.q = q
|
||||
@@ -83,7 +88,7 @@ function parseUrl(): Partial<FilterState> {
|
||||
if (groupBy === 'date' || groupBy === 'tag') out.groupBy = groupBy
|
||||
|
||||
const section = sp.get('section')
|
||||
if (section) (out as any).currentSection = section
|
||||
if (section) out.currentSection = section
|
||||
|
||||
const sortBy = sp.get('sort')
|
||||
if (sortBy && ALLOWED_SORT_FIELDS.includes(sortBy as SortField)) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { photos as photosApi, heaps as heapsApi, type Heap } from '../services/a
|
||||
import { HEAPS_QUERY_KEY } from './useHeapsQuery'
|
||||
import { toast } from '../components/ToastContainer'
|
||||
import { registerUndoable, useUndoStore } from '../store/undoStore'
|
||||
import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery'
|
||||
|
||||
interface KeyboardShortcutsProps {
|
||||
onToggleLeftSidebar: () => void
|
||||
@@ -58,6 +59,7 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
||||
const invalidatePhotoQueries = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['photo'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||
}
|
||||
|
||||
const bulkRatingMutation = useMutation({
|
||||
|
||||
18
frontend/src/hooks/useLibraryStatsQuery.ts
Normal file
18
frontend/src/hooks/useLibraryStatsQuery.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { library, type LibraryStats } from '../services/api'
|
||||
|
||||
export const LIBRARY_STATS_QUERY_KEY = ['library', 'stats'] as const
|
||||
|
||||
/**
|
||||
* Per-section counts for the LeftSidebar badges (All Photos, Rated,
|
||||
* Duplicates, Discarded). Cached briefly so navigating around doesn't
|
||||
* re-fetch on every click; invalidated on photo mutations through the
|
||||
* standard ['photos'] invalidation in the mutation onSuccess paths.
|
||||
*/
|
||||
export function useLibraryStatsQuery() {
|
||||
return useQuery<LibraryStats>({
|
||||
queryKey: LIBRARY_STATS_QUERY_KEY,
|
||||
queryFn: library.stats,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const API_BASE_URL = 'http://localhost:8001/api/v1'
|
||||
// Relative API base. In production the nginx in front of the SPA proxies
|
||||
// /api/ to the backend container; in dev the vite server has the same
|
||||
// proxy in vite.config.ts. Using a relative URL means requests are
|
||||
// always same-origin, so the app works whether you hit it from
|
||||
// localhost, a LAN IP, or a reverse proxy without any CORS dance.
|
||||
const API_BASE_URL = '/api/v1'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
@@ -116,6 +121,28 @@ export const photos = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Add the listed tags to every listed photo. Idempotent — re-adding
|
||||
* an existing (photo, tag) pair is a no-op. Returns { added: N }. */
|
||||
bulkAddTags: async (photoIds: string[], tagIds: string[]) => {
|
||||
const response = await api.post('/photos/bulk', {
|
||||
ids: photoIds,
|
||||
action: 'add_tags',
|
||||
value: tagIds,
|
||||
})
|
||||
return response.data as { status: string; added: number }
|
||||
},
|
||||
|
||||
/** Remove the listed tags from every listed photo. Removing a
|
||||
* non-member is a no-op. Returns { removed: N }. */
|
||||
bulkRemoveTags: async (photoIds: string[], tagIds: string[]) => {
|
||||
const response = await api.post('/photos/bulk', {
|
||||
ids: photoIds,
|
||||
action: 'remove_tags',
|
||||
value: tagIds,
|
||||
})
|
||||
return response.data as { status: string; removed: number }
|
||||
},
|
||||
|
||||
/** Move photos into a target folder (or source root). Returns
|
||||
* { moved, errors[] }. */
|
||||
move: async (photoIds: string[], targetId: string) => {
|
||||
@@ -163,12 +190,23 @@ export const library = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
stats: async () => {
|
||||
stats: async (): Promise<LibraryStats> => {
|
||||
const response = await api.get('/library/stats')
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export interface LibraryStats {
|
||||
all_photos: number
|
||||
rated: number
|
||||
duplicates: number
|
||||
discarded: number
|
||||
total_photos: number
|
||||
total_videos: number
|
||||
total_size: number
|
||||
total_size_gb: number
|
||||
}
|
||||
|
||||
// Heaps API
|
||||
export interface Heap {
|
||||
id: string
|
||||
@@ -202,6 +240,13 @@ export const heaps = {
|
||||
await api.delete(`/heaps/${heapId}`)
|
||||
},
|
||||
|
||||
/** Duplicate a heap, copying its membership but never marking the new
|
||||
* one as active. The new heap is named "{name} (copy)". */
|
||||
duplicate: async (heapId: string): Promise<Heap> => {
|
||||
const response = await api.post(`/heaps/${heapId}/duplicate`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Lightweight: just the photo ids in a heap, for client-side membership
|
||||
* lookups (the basket affordance on thumbnails). */
|
||||
photoIds: async (heapId: string): Promise<string[]> => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { create } from 'zustand'
|
||||
import type { ColorLabel } from '../constants/colorLabels'
|
||||
|
||||
export type MediaType = 'photo' | 'video' | 'raw' | 'heic'
|
||||
export type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
|
||||
export type { ColorLabel }
|
||||
export type FlagFilter = 'any' | 'discarded'
|
||||
export type SortField =
|
||||
| 'taken_at'
|
||||
|
||||
@@ -16,7 +16,8 @@ export interface Photo {
|
||||
is_discarded: boolean
|
||||
is_duplicate: boolean
|
||||
file_hash: string
|
||||
folder_id?: string | null
|
||||
folder_id: string | null
|
||||
added_at: string | null
|
||||
thumb_small?: string
|
||||
thumb_medium?: string
|
||||
thumb_large?: string
|
||||
|
||||
Reference in New Issue
Block a user