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
|
# Mulita / PhotoVault local environment.
|
||||||
#
|
# See .env.example for the full list of knobs and their docs.
|
||||||
# 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
|
# REQUIRED — host path to your photo library.
|
||||||
# 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
|
|
||||||
PHOTO_DIRS=/Users/dtoro/Pictures/MulitaTest
|
PHOTO_DIRS=/Users/dtoro/Pictures/MulitaTest
|
||||||
|
|
||||||
# Redis configuration
|
# Ports — change if 3000 / 8001 collide with other services on the host.
|
||||||
REDIS_URL=redis://localhost:6379
|
FRONTEND_PORT=3000
|
||||||
|
BACKEND_PORT=8001
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
# Database URL
|
# CORS — wildcard for local dev. Lock down for real deployments.
|
||||||
DATABASE_URL=sqlite+aiosqlite:///data/db/mulita.db
|
ALLOWED_ORIGINS=*
|
||||||
|
|
||||||
# Celery configuration
|
# Logging + timezone.
|
||||||
CELERY_BROKER_URL=redis://localhost:6379
|
LOG_LEVEL=INFO
|
||||||
CELERY_RESULT_BACKEND=redis://localhost:6379
|
TZ=UTC
|
||||||
|
|
||||||
|
# Celery worker pool.
|
||||||
CELERYD_CONCURRENCY=4
|
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
|
cd muleimage
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Set **one** environment variable in `.env` — the **host** directory
|
2. Copy the example env file and set **one** variable — the **host**
|
||||||
that contains your photo library. Whatever you point at will become
|
directory that contains your photo library. Whatever you point at
|
||||||
your library inside Mulita.
|
will become your library inside Mulita.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# macOS / Linux
|
cp .env.example .env
|
||||||
PHOTO_DIRS=/Users/you/Pictures
|
# then edit .env and set PHOTO_DIRS:
|
||||||
|
# macOS / Linux: PHOTO_DIRS=/Users/you/Pictures
|
||||||
# or any folder
|
# Network share: PHOTO_DIRS=/mnt/nas/photos
|
||||||
PHOTO_DIRS=/mnt/nas/photos
|
# Windows (WSL): PHOTO_DIRS=/mnt/c/Users/you/Pictures
|
||||||
|
|
||||||
# Windows (WSL)
|
|
||||||
PHOTO_DIRS=/mnt/c/Users/you/Pictures
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Start the stack:
|
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.
|
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
|
### How libraries are managed
|
||||||
|
|
||||||
Mulita is **config-driven**: the host directory you mount via
|
Mulita is **config-driven**: the host directory you mount via
|
||||||
|
|||||||
@@ -71,6 +71,28 @@ class Settings(BaseSettings):
|
|||||||
api_host: str = Field(default="0.0.0.0", env="API_HOST")
|
api_host: str = Field(default="0.0.0.0", env="API_HOST")
|
||||||
api_port: int = Field(default=8000, env="API_PORT")
|
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
|
# App configuration from YAML
|
||||||
_config: Optional[MulitaConfig] = None
|
_config: Optional[MulitaConfig] = None
|
||||||
|
|
||||||
|
|||||||
@@ -62,11 +62,20 @@ app = FastAPI(
|
|||||||
lifespan=lifespan
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["http://localhost:3000", "http://localhost:5173"], # Frontend URLs
|
allow_origins=_origins,
|
||||||
allow_credentials=True,
|
# 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_methods=["*"],
|
||||||
allow_headers=["*"],
|
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)
|
@router.delete("/{heap_id}", status_code=204)
|
||||||
async def delete_heap(heap_id: str, db: AsyncSession = Depends(get_db)):
|
async def delete_heap(heap_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
"""Delete a heap. Photos themselves are unaffected — only the membership
|
"""Delete a heap. Photos themselves are unaffected — only the membership
|
||||||
|
|||||||
@@ -12,30 +12,68 @@ router = APIRouter()
|
|||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
async def get_library_stats(db: AsyncSession = Depends(get_db)):
|
async def get_library_stats(db: AsyncSession = Depends(get_db)):
|
||||||
"""Get library statistics"""
|
"""Get library statistics + per-section counts. Each section count
|
||||||
# Count total photos
|
matches the filter the sidebar applies when you click it, so the
|
||||||
total_photos = await db.execute(
|
sidebar badges and the timeline below them stay in sync.
|
||||||
select(func.count(Photo.id)).where(Photo.media_type.in_(['photo', 'heic', 'raw']))
|
|
||||||
)
|
|
||||||
photo_count = total_photos.scalar()
|
|
||||||
|
|
||||||
# Count total videos
|
- all_photos: non-discarded photos + videos (matches the All
|
||||||
total_videos = await db.execute(
|
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')
|
select(func.count(Photo.id)).where(Photo.media_type == 'video')
|
||||||
)
|
)
|
||||||
video_count = total_videos.scalar()
|
).scalar() or 0
|
||||||
|
|
||||||
# Calculate total size
|
size = (await db.execute(select(func.sum(Photo.file_size)))).scalar() or 0
|
||||||
total_size = await db.execute(
|
|
||||||
select(func.sum(Photo.file_size))
|
|
||||||
)
|
|
||||||
size = total_size.scalar() or 0
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
"all_photos": all_photos_count,
|
||||||
|
"rated": rated_count,
|
||||||
|
"duplicates": duplicates_count,
|
||||||
|
"discarded": discarded_count,
|
||||||
"total_photos": photo_count,
|
"total_photos": photo_count,
|
||||||
"total_videos": video_count,
|
"total_videos": video_count,
|
||||||
"total_size": size,
|
"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")
|
@router.post("/scan")
|
||||||
|
|||||||
@@ -146,24 +146,37 @@ async def list_photos(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Tag filter — comma-separated tag ids, AND semantics. A photo must
|
# Tag filter — comma-separated tag ids, AND semantics. A photo must
|
||||||
# have a row in photo_tags for EVERY listed tag. Implemented as one
|
# have a row in photo_tags for EVERY listed tag. Implemented as a
|
||||||
# subquery per tag id since SQLite doesn't have an efficient
|
# single GROUP BY ... HAVING COUNT(DISTINCT) = N subquery so the cost
|
||||||
# "set-contains-all" operator.
|
# is independent of the number of tags being filtered.
|
||||||
if tag_ids:
|
if tag_ids:
|
||||||
tag_id_list = [t.strip() for t in tag_ids.split(',') if t.strip()]
|
tag_id_list = [t.strip() for t in tag_ids.split(',') if t.strip()]
|
||||||
for tid in tag_id_list:
|
if tag_id_list:
|
||||||
filters.append(
|
matching_photos = (
|
||||||
Photo.id.in_(
|
select(photo_tags.c.photo_id)
|
||||||
select(photo_tags.c.photo_id).where(photo_tags.c.tag_id == tid)
|
.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
|
# Apply all filters
|
||||||
if filters:
|
if filters:
|
||||||
query = query.where(and_(*filters))
|
query = query.where(and_(*filters))
|
||||||
|
|
||||||
# Apply sorting
|
# Apply sorting. The sort field is whitelisted so a malicious client
|
||||||
sort_column = getattr(Photo, sort, Photo.taken_at)
|
# 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":
|
if order == "desc":
|
||||||
query = query.order_by(sort_column.desc())
|
query = query.order_by(sort_column.desc())
|
||||||
else:
|
else:
|
||||||
@@ -849,6 +862,54 @@ async def bulk_action(
|
|||||||
elif action.action == 'set_color':
|
elif action.action == 'set_color':
|
||||||
for photo in photos:
|
for photo in photos:
|
||||||
photo.color_label = action.value
|
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:
|
else:
|
||||||
raise HTTPException(status_code=400, detail="Invalid action")
|
raise HTTPException(status_code=400, detail="Invalid action")
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: mulita-frontend
|
container_name: mulita-frontend
|
||||||
ports:
|
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:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
networks:
|
networks:
|
||||||
@@ -20,7 +22,10 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: mulita-backend
|
container_name: mulita-backend
|
||||||
ports:
|
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:
|
volumes:
|
||||||
- ./mulita.yml:/app/config/mulita.yml:ro
|
- ./mulita.yml:/app/config/mulita.yml:ro
|
||||||
# The single host → container mount for your photo library. Set
|
# The single host → container mount for your photo library. Set
|
||||||
@@ -38,6 +43,9 @@ services:
|
|||||||
- CELERY_BROKER_URL=redis://redis:6379
|
- CELERY_BROKER_URL=redis://redis:6379
|
||||||
- CELERY_RESULT_BACKEND=redis://redis:6379
|
- CELERY_RESULT_BACKEND=redis://redis:6379
|
||||||
- PHOTO_DIRS=${PHOTO_DIRS:-/photos}
|
- PHOTO_DIRS=${PHOTO_DIRS:-/photos}
|
||||||
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
|
||||||
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
|
- TZ=${TZ:-UTC}
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
networks:
|
networks:
|
||||||
@@ -49,7 +57,7 @@ services:
|
|||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: mulita-worker
|
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:
|
volumes:
|
||||||
- ./mulita.yml:/app/config/mulita.yml:ro
|
- ./mulita.yml:/app/config/mulita.yml:ro
|
||||||
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
||||||
@@ -62,7 +70,9 @@ services:
|
|||||||
- CELERY_BROKER_URL=redis://redis:6379
|
- CELERY_BROKER_URL=redis://redis:6379
|
||||||
- CELERY_RESULT_BACKEND=redis://redis:6379
|
- CELERY_RESULT_BACKEND=redis://redis:6379
|
||||||
- PHOTO_DIRS=${PHOTO_DIRS:-/photos}
|
- PHOTO_DIRS=${PHOTO_DIRS:-/photos}
|
||||||
- CELERYD_CONCURRENCY=4
|
- CELERYD_CONCURRENCY=${CELERYD_CONCURRENCY:-4}
|
||||||
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
|
- TZ=${TZ:-UTC}
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
- backend
|
- backend
|
||||||
@@ -73,8 +83,10 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: mulita-redis
|
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:
|
ports:
|
||||||
- "6379:6379"
|
- "${REDIS_PORT:-6379}:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Timeline } from './components/timeline/Timeline'
|
import { Timeline } from './components/timeline/Timeline'
|
||||||
import { LeftSidebar } from './components/layout/LeftSidebar'
|
import { LeftSidebar } from './components/layout/LeftSidebar'
|
||||||
import { RightSidebar } from './components/layout/RightSidebar'
|
import { RightSidebar } from './components/layout/RightSidebar'
|
||||||
@@ -37,22 +37,22 @@ function App() {
|
|||||||
|
|
||||||
// Auto-show right sidebar when photos are selected — but only in grid mode,
|
// 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.
|
// 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) {
|
if (selectedPhotos.length > 0 && !rightSidebarOpen) {
|
||||||
setRightSidebarOpen(true)
|
setRightSidebarOpen(true)
|
||||||
} else if (selectedPhotos.length === 0 && rightSidebarOpen) {
|
} else if (selectedPhotos.length === 0 && rightSidebarOpen) {
|
||||||
setRightSidebarOpen(false)
|
setRightSidebarOpen(false)
|
||||||
}
|
}
|
||||||
}
|
}, [viewMode, selectedPhotos.length, rightSidebarOpen])
|
||||||
|
|
||||||
const showRightSidebar = rightSidebarOpen && viewMode === 'grid'
|
const showRightSidebar = rightSidebarOpen && viewMode === 'grid'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen bg-bg text-text">
|
<div className="flex flex-col h-screen bg-bg text-text">
|
||||||
<TopBar />
|
<TopBar />
|
||||||
<FilterBar />
|
|
||||||
<DiscardActionBar />
|
|
||||||
<KeyboardHints />
|
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Left Sidebar */}
|
{/* Left Sidebar */}
|
||||||
@@ -64,10 +64,16 @@ function App() {
|
|||||||
<LeftSidebar />
|
<LeftSidebar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content - 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">
|
<div className="flex-1 overflow-auto">
|
||||||
<Timeline />
|
<Timeline />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Right Sidebar */}
|
{/* Right Sidebar */}
|
||||||
<div
|
<div
|
||||||
@@ -79,6 +85,10 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Floating keyboard hints — pinned bottom-center, glassy. Sits
|
||||||
|
* above the timeline and below the toast layer. */}
|
||||||
|
<KeyboardHints />
|
||||||
|
|
||||||
{/* Scan Progress Indicator */}
|
{/* Scan Progress Indicator */}
|
||||||
<ScanProgress />
|
<ScanProgress />
|
||||||
|
|
||||||
|
|||||||
@@ -25,22 +25,22 @@ export function KeyboardHints() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center border-b border-border bg-surface/60 px-4 py-1.5">
|
<div className="pointer-events-none fixed bottom-4 left-1/2 z-30 -translate-x-1/2">
|
||||||
<div className="flex items-center gap-3">
|
<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) => (
|
{hints.map((hint, i) => (
|
||||||
<div key={i} className="flex items-center gap-1.5">
|
<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}
|
{hint.key}
|
||||||
</kbd>
|
</kbd>
|
||||||
<span className="text-xs text-text-muted">{hint.action}</span>
|
<span className="text-xs text-text-muted">{hint.action}</span>
|
||||||
{i < hints.length - 1 && (
|
{i < hints.length - 1 && (
|
||||||
<span className="ml-2 text-text-faint">•</span>
|
<span className="ml-1 text-text-faint">•</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{selectedCount > 0 && (
|
{selectedCount > 0 && (
|
||||||
<>
|
<>
|
||||||
<span className="ml-2 text-text-faint">•</span>
|
<span className="text-text-faint">•</span>
|
||||||
<span className="text-xs font-medium text-primary">
|
<span className="text-xs font-medium text-primary">
|
||||||
{selectedCount} selected
|
{selectedCount} selected
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export function ScanProgress() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
|
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['heaps'] })
|
queryClient.invalidateQueries({ queryKey: ['heaps'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['tags'] })
|
queryClient.invalidateQueries({ queryKey: ['tags'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
|
||||||
|
|
||||||
if (isVisible && (scanStatus?.processed_files ?? 0) > 0) {
|
if (isVisible && (scanStatus?.processed_files ?? 0) > 0) {
|
||||||
// Keep showing for 3 seconds after scan completes
|
// 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 { toast } from '../ToastContainer'
|
||||||
import { ConfirmDialog } from '../dialogs/ConfirmDialog'
|
import { ConfirmDialog } from '../dialogs/ConfirmDialog'
|
||||||
import { registerUndoable } from '../../store/undoStore'
|
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.
|
* Top-of-timeline bar visible only when the discarded filter is active.
|
||||||
@@ -32,10 +33,12 @@ export function DiscardActionBar() {
|
|||||||
async () => {
|
async () => {
|
||||||
await photosApi.bulkDiscard(ids)
|
await photosApi.bulkDiscard(ids)
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
clearSelection()
|
clearSelection()
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||||
},
|
},
|
||||||
onError: (e: any) => toast.error('Restore failed', e.message || 'Unknown error'),
|
onError: (e: any) => toast.error('Restore failed', e.message || 'Unknown error'),
|
||||||
})
|
})
|
||||||
@@ -58,6 +61,7 @@ export function DiscardActionBar() {
|
|||||||
}
|
}
|
||||||
clearSelection()
|
clearSelection()
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||||
setDeleteSelectedOpen(false)
|
setDeleteSelectedOpen(false)
|
||||||
},
|
},
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
@@ -79,6 +83,7 @@ export function DiscardActionBar() {
|
|||||||
}
|
}
|
||||||
clearSelection()
|
clearSelection()
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||||
setConfirmOpen(false)
|
setConfirmOpen(false)
|
||||||
},
|
},
|
||||||
onError: (e: any) => toast.error('Empty failed', e.message || 'Unknown error'),
|
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 clsx from 'clsx'
|
||||||
import {
|
import {
|
||||||
useFilterStore,
|
useFilterStore,
|
||||||
hasActiveFilters,
|
hasActiveFilters,
|
||||||
type MediaType,
|
type MediaType,
|
||||||
type ColorLabel,
|
|
||||||
type SortField,
|
type SortField,
|
||||||
} from '../../store/filterStore'
|
} from '../../store/filterStore'
|
||||||
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
||||||
import { FilterPill } from './FilterPill'
|
import { FilterPill } from './FilterPill'
|
||||||
|
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
||||||
|
|
||||||
|
const SEARCH_DEBOUNCE_MS = 300
|
||||||
|
|
||||||
const MEDIA_TYPES: { value: MediaType; label: string }[] = [
|
const MEDIA_TYPES: { value: MediaType; label: string }[] = [
|
||||||
{ value: 'photo', label: 'Photo' },
|
{ value: 'photo', label: 'Photo' },
|
||||||
@@ -17,15 +20,6 @@ const MEDIA_TYPES: { value: MediaType; label: string }[] = [
|
|||||||
{ value: 'heic', label: 'HEIC' },
|
{ 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 }[] = [
|
const SORT_OPTIONS: { value: SortField; label: string }[] = [
|
||||||
{ value: 'taken_at', label: 'Date taken' },
|
{ value: 'taken_at', label: 'Date taken' },
|
||||||
{ value: 'added_at', label: 'Date added' },
|
{ value: 'added_at', label: 'Date added' },
|
||||||
@@ -66,6 +60,26 @@ export function FilterBar() {
|
|||||||
|
|
||||||
const { data: allTags = [] } = useTagsQuery()
|
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.
|
// Pre-compute pill values + active flags so the JSX stays terse.
|
||||||
const dateActive = dateFrom !== null || dateTo !== null
|
const dateActive = dateFrom !== null || dateTo !== null
|
||||||
const dateValue = dateActive
|
const dateValue = dateActive
|
||||||
@@ -102,7 +116,43 @@ export function FilterBar() {
|
|||||||
const anyActive = hasActiveFilters(filterState)
|
const anyActive = hasActiveFilters(filterState)
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Date */}
|
||||||
<FilterPill
|
<FilterPill
|
||||||
label="Date"
|
label="Date"
|
||||||
@@ -325,11 +375,13 @@ export function FilterBar() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</FilterPill>
|
</FilterPill>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clear-all — pinned right of the pill cluster. */}
|
||||||
{anyActive && (
|
{anyActive && (
|
||||||
<button
|
<button
|
||||||
onClick={clearAll}
|
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"
|
title="Clear all filters in this section"
|
||||||
>
|
>
|
||||||
Clear all
|
Clear all
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import clsx from 'clsx'
|
|||||||
interface FilterPillProps {
|
interface FilterPillProps {
|
||||||
/** Category label, always shown ("Date", "Type", etc.). */
|
/** Category label, always shown ("Date", "Type", etc.). */
|
||||||
label: string
|
label: string
|
||||||
/** When the filter is active, a short summary of its current value
|
/** Currently unused in the rendered output — the inline value display
|
||||||
* ("≥ 3★", "RAW + Photo", "Mar 2024 → Apr 2026"). Renders inside the
|
* was making active pills wider than inactive ones. Kept on the
|
||||||
* pill so the user sees the state without opening the popover. */
|
* interface so callers don't have to change. The value is still
|
||||||
|
* surfaced via the title attribute for hover discovery. */
|
||||||
value?: string | null
|
value?: string | null
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
/** When provided + isActive, an X appears inside the pill that clears
|
/** When provided + isActive, an X appears inside the pill that clears
|
||||||
@@ -94,6 +95,10 @@ export function FilterPill({
|
|||||||
<button
|
<button
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
onClick={() => setOpen((v) => !v)}
|
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(
|
className={clsx(
|
||||||
'flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs transition-colors',
|
'flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs transition-colors',
|
||||||
isActive
|
isActive
|
||||||
@@ -102,21 +107,27 @@ export function FilterPill({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={clsx(isActive && 'font-medium')}>{label}</span>
|
<span className={clsx(isActive && 'font-medium')}>{label}</span>
|
||||||
{isActive && value && (
|
|
||||||
<span className="font-mono text-[11px] opacity-90">{value}</span>
|
|
||||||
)}
|
|
||||||
{isActive && onClear ? (
|
{isActive && onClear ? (
|
||||||
<button
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onClear()
|
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}`}
|
title={`Clear ${label}`}
|
||||||
aria-label={`Clear ${label}`}
|
aria-label={`Clear ${label}`}
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<ChevronDown className="h-3 w-3 opacity-60" />
|
<ChevronDown className="h-3 w-3 opacity-60" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
ShoppingBasket,
|
ShoppingBasket,
|
||||||
Plus,
|
Plus,
|
||||||
Target,
|
Target,
|
||||||
X,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
FolderOutput,
|
FolderOutput,
|
||||||
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
|
Copy,
|
||||||
|
Trash2,
|
||||||
} from 'lucide-react'
|
} 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'
|
||||||
@@ -40,6 +43,32 @@ export function HeapsPanel() {
|
|||||||
// the drop highlight ring. Only one heap can be the target at a time.
|
// the drop highlight ring. Only one heap can be the target at a time.
|
||||||
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
||||||
const [convertingHeap, setConvertingHeap] = useState<Heap | 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 = () => {
|
const invalidate = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
||||||
@@ -80,6 +109,24 @@ export function HeapsPanel() {
|
|||||||
toast.error('Failed to delete heap', e.message || 'Unknown error'),
|
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
|
// Drop handler: add the dragged photos to the target heap. Optimistically
|
||||||
// updates the membership cache so the basket affordance flips immediately,
|
// updates the membership cache so the basket affordance flips immediately,
|
||||||
// mirroring the keyboard P-toggle pattern.
|
// mirroring the keyboard P-toggle pattern.
|
||||||
@@ -202,18 +249,35 @@ export function HeapsPanel() {
|
|||||||
const isFiltered = currentSection === `heap-${heap.id}`
|
const isFiltered = currentSection === `heap-${heap.id}`
|
||||||
const isActive = heap.is_active
|
const isActive = heap.is_active
|
||||||
const isDropTarget = dropTargetId === heap.id
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={heap.id}
|
key={heap.id}
|
||||||
className={clsx(
|
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',
|
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
||||||
isDropTarget && 'ring-2 ring-primary bg-primary/10'
|
isDropTarget && 'ring-2 ring-primary bg-primary/10'
|
||||||
)}
|
)}
|
||||||
style={{ paddingLeft: '32px' }}
|
style={{ paddingLeft: '32px' }}
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
|
if (isRenaming) return
|
||||||
navigateToSection(`heap-${heap.id}`, { heapId: heap.id })
|
navigateToSection(`heap-${heap.id}`, { heapId: heap.id })
|
||||||
}
|
}}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setRenamingId(heap.id)
|
||||||
|
setRenameDraft(heap.name)
|
||||||
|
}}
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) {
|
if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -222,8 +286,6 @@ export function HeapsPanel() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDragLeave={(e) => {
|
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 (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||||
if (dropTargetId === heap.id) setDropTargetId(null)
|
if (dropTargetId === heap.id) setDropTargetId(null)
|
||||||
}
|
}
|
||||||
@@ -249,63 +311,136 @@ export function HeapsPanel() {
|
|||||||
isFiltered ? 'text-primary' : 'text-text-muted'
|
isFiltered ? 'text-primary' : 'text-text-muted'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{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
|
<span
|
||||||
className={clsx(
|
className={clsx('flex-1 truncate', isActive && 'font-semibold')}
|
||||||
'flex-1 truncate',
|
|
||||||
isActive && 'font-semibold'
|
|
||||||
)}
|
|
||||||
title={heap.name}
|
title={heap.name}
|
||||||
>
|
>
|
||||||
{heap.name}
|
{heap.name}
|
||||||
</span>
|
</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 && (
|
{isActive && (
|
||||||
<Target
|
<Target
|
||||||
className="h-3 w-3 text-primary"
|
className="h-3 w-3 flex-shrink-0 text-primary"
|
||||||
aria-label="Active heap (T target)"
|
aria-label="Active heap (T target)"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{heap.photo_count > 0 && (
|
{heap.photo_count > 0 ? (
|
||||||
<span className="rounded bg-surface-offset px-1.5 py-0.5 text-xs text-text-muted">
|
<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}
|
{heap.photo_count}
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="h-5 min-w-[24px] flex-shrink-0" aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
|
{!isActive && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (!isActive) setActiveMutation.mutate(heap.id)
|
setActiveMutation.mutate(heap.id)
|
||||||
}}
|
}}
|
||||||
className={clsx(
|
className="invisible flex-shrink-0 rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:visible"
|
||||||
'rounded p-0.5 hover:bg-surface-offset hover:text-text',
|
|
||||||
isActive
|
|
||||||
? 'invisible'
|
|
||||||
: 'invisible text-text-muted group-hover:visible'
|
|
||||||
)}
|
|
||||||
title="Set as active heap (T target)"
|
title="Set as active heap (T target)"
|
||||||
|
aria-label="Set as active heap"
|
||||||
>
|
>
|
||||||
<Target className="h-3 w-3" />
|
<Target className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Kebab menu — collects rename / duplicate / convert /
|
||||||
|
* delete so the row stays compact. */}
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
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)
|
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…"
|
<div className="my-1 h-px bg-border" />
|
||||||
>
|
<MenuItem
|
||||||
<FolderOutput className="h-3 w-3" />
|
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||||
</button>
|
label="Delete"
|
||||||
<button
|
destructive
|
||||||
onClick={(e) => {
|
onClick={() => {
|
||||||
e.stopPropagation()
|
setOpenMenuId(null)
|
||||||
if (confirm(`Delete heap "${heap.name}"? Photos are not affected.`)) {
|
if (
|
||||||
|
confirm(
|
||||||
|
`Delete heap "${heap.name}"? Photos are not affected.`
|
||||||
|
)
|
||||||
|
) {
|
||||||
deleteMutation.mutate(heap.id)
|
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"
|
</div>
|
||||||
>
|
)}
|
||||||
<X className="h-3 w-3" />
|
</div>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -319,3 +454,31 @@ export function HeapsPanel() {
|
|||||||
</div>
|
</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 { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
||||||
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
|
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
|
||||||
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
||||||
|
import {
|
||||||
|
useLibraryStatsQuery,
|
||||||
|
LIBRARY_STATS_QUERY_KEY,
|
||||||
|
} from '../../hooks/useLibraryStatsQuery'
|
||||||
import { registerUndoable } from '../../store/undoStore'
|
import { registerUndoable } from '../../store/undoStore'
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
|
|
||||||
@@ -45,6 +49,7 @@ export function LeftSidebar() {
|
|||||||
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: stats } = useLibraryStatsQuery()
|
||||||
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
||||||
|
|
||||||
// Bulk discard mutation for the drag-onto-Discarded interaction.
|
// Bulk discard mutation for the drag-onto-Discarded interaction.
|
||||||
@@ -56,9 +61,11 @@ export function LeftSidebar() {
|
|||||||
async () => {
|
async () => {
|
||||||
await photosApi.bulkRestore(photoIds)
|
await photosApi.bulkRestore(photoIds)
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||||
},
|
},
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
toast.error('Discard failed', e?.message || 'Unknown error'),
|
toast.error('Discard failed', e?.message || 'Unknown error'),
|
||||||
@@ -270,11 +277,11 @@ export function LeftSidebar() {
|
|||||||
label: 'Views',
|
label: 'Views',
|
||||||
icon: <Layers2 className="h-4 w-4" />,
|
icon: <Layers2 className="h-4 w-4" />,
|
||||||
children: [
|
children: [
|
||||||
{ id: 'all-photos', label: 'All Photos', icon: <Image 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: 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: '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: '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: 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>
|
<span className="flex-1 truncate">{item.label}</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Count Badge */}
|
{/* Count Badge — fixed-width slot so counts line up in a column
|
||||||
{item.count !== undefined && item.count > 0 && (
|
* across rows regardless of digit count. */}
|
||||||
<span className="rounded bg-surface-offset px-1.5 py-0.5 text-xs text-text-muted">
|
{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}
|
{item.count}
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="h-5 min-w-[24px] flex-shrink-0" aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</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 clsx from 'clsx'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
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 { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||||
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||||
|
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
|
||||||
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'
|
||||||
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' },
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Right-hand details panel.
|
* Right-hand details panel.
|
||||||
@@ -49,6 +45,59 @@ export function RightSidebar() {
|
|||||||
onSuccess: invalidatePhotoQueries,
|
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.
|
// Active heap membership for the bulk Pick toggle.
|
||||||
const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
|
const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
|
||||||
|
|
||||||
@@ -101,12 +150,13 @@ export function RightSidebar() {
|
|||||||
const id = activePhotoId ?? selectedPhotos[0]
|
const id = activePhotoId ?? selectedPhotos[0]
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-surface">
|
<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>
|
<h2 className="text-sm font-semibold text-text">Photo Details</h2>
|
||||||
<button
|
<button
|
||||||
onClick={clearSelection}
|
onClick={clearSelection}
|
||||||
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||||
title="Clear selection"
|
title="Clear selection"
|
||||||
|
aria-label="Clear selection"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -232,7 +282,154 @@ export function RightSidebar() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</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 { ShoppingBasket } from 'lucide-react'
|
||||||
import { Search, X, ShoppingBasket } from 'lucide-react'
|
|
||||||
import { useFilterStore } from '../../store/filterStore'
|
|
||||||
import { useHeapsQuery } from '../../hooks/useHeapsQuery'
|
import { useHeapsQuery } from '../../hooks/useHeapsQuery'
|
||||||
import muliLogo from '../../assets/muli-logo.png'
|
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() {
|
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 { data: heapsList = [] } = useHeapsQuery()
|
||||||
const activeHeap = heapsList.find((h) => h.is_active)
|
const activeHeap = heapsList.find((h) => h.is_active)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
|
<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-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<img src={muliLogo} alt="Mulita" className="h-7 w-7 object-contain" />
|
<img src={muliLogo} alt="Mulita" className="h-7 w-7 object-contain" />
|
||||||
@@ -57,41 +28,6 @@ export function TopBar() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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" />
|
<div className="flex items-center gap-2" />
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
|||||||
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||||
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 {
|
||||||
|
COLOR_LABEL_OPTIONS,
|
||||||
|
type ColorLabel,
|
||||||
|
} from '../../constants/colorLabels'
|
||||||
|
|
||||||
interface PhotoTagSummary {
|
interface PhotoTagSummary {
|
||||||
id: string
|
id: string
|
||||||
@@ -47,17 +51,6 @@ interface PhotoDetails {
|
|||||||
tags?: PhotoTagSummary[]
|
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 {
|
interface ExifData {
|
||||||
Make?: string
|
Make?: string
|
||||||
Model?: string
|
Model?: string
|
||||||
|
|||||||
@@ -137,9 +137,7 @@ function buildItems(
|
|||||||
|
|
||||||
photos.forEach((photo, globalIndex) => {
|
photos.forEach((photo, globalIndex) => {
|
||||||
const dateStr =
|
const dateStr =
|
||||||
sortBy === 'taken_at'
|
sortBy === 'taken_at' ? photo.taken_at : photo.added_at ?? photo.taken_at
|
||||||
? photo.taken_at
|
|
||||||
: (photo as any).added_at ?? photo.taken_at
|
|
||||||
let label: string
|
let label: string
|
||||||
if (dateStr) {
|
if (dateStr) {
|
||||||
try {
|
try {
|
||||||
@@ -259,13 +257,17 @@ export function Timeline() {
|
|||||||
return () => el.removeEventListener('scroll', onScroll)
|
return () => el.removeEventListener('scroll', onScroll)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Find the latest header whose start <= scrollTop. That's the label of
|
// Find the latest header whose BOTTOM is above the viewport top. That's
|
||||||
// the group containing whatever is currently at the top of the viewport.
|
// 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(() => {
|
const stickyLabel = useMemo(() => {
|
||||||
if (headerOffsets.length === 0) return null
|
if (headerOffsets.length === 0) return null
|
||||||
let current: string | null = null
|
let current: string | null = null
|
||||||
for (const h of headerOffsets) {
|
for (const h of headerOffsets) {
|
||||||
if (h.offset <= scrollTop) current = h.label
|
if (h.offset + HEADER_HEIGHT <= scrollTop) current = h.label
|
||||||
else break
|
else break
|
||||||
}
|
}
|
||||||
return current
|
return current
|
||||||
@@ -424,7 +426,7 @@ export function Timeline() {
|
|||||||
* positioned children so it isn't affected by translateY transforms.
|
* positioned children so it isn't affected by translateY transforms.
|
||||||
* Updates as the user scrolls past month boundaries. */}
|
* Updates as the user scrolls past month boundaries. */}
|
||||||
{stickyLabel && (
|
{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">
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-text">
|
||||||
{stickyLabel}
|
{stickyLabel}
|
||||||
</h3>
|
</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']
|
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 sp = new URLSearchParams(window.location.search)
|
||||||
const out: Partial<FilterState> = {}
|
const out: HydratePayload = {}
|
||||||
|
|
||||||
const q = sp.get('q')
|
const q = sp.get('q')
|
||||||
if (q) out.q = q
|
if (q) out.q = q
|
||||||
@@ -83,7 +88,7 @@ function parseUrl(): Partial<FilterState> {
|
|||||||
if (groupBy === 'date' || groupBy === 'tag') out.groupBy = groupBy
|
if (groupBy === 'date' || groupBy === 'tag') out.groupBy = groupBy
|
||||||
|
|
||||||
const section = sp.get('section')
|
const section = sp.get('section')
|
||||||
if (section) (out as any).currentSection = section
|
if (section) out.currentSection = section
|
||||||
|
|
||||||
const sortBy = sp.get('sort')
|
const sortBy = sp.get('sort')
|
||||||
if (sortBy && ALLOWED_SORT_FIELDS.includes(sortBy as SortField)) {
|
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 { 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'
|
||||||
|
|
||||||
interface KeyboardShortcutsProps {
|
interface KeyboardShortcutsProps {
|
||||||
onToggleLeftSidebar: () => void
|
onToggleLeftSidebar: () => void
|
||||||
@@ -58,6 +59,7 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
const invalidatePhotoQueries = () => {
|
const invalidatePhotoQueries = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['photo'] })
|
queryClient.invalidateQueries({ queryKey: ['photo'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||||
}
|
}
|
||||||
|
|
||||||
const bulkRatingMutation = useMutation({
|
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'
|
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({
|
const api = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
@@ -116,6 +121,28 @@ export const photos = {
|
|||||||
return response.data
|
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
|
/** Move photos into a target folder (or source root). Returns
|
||||||
* { moved, errors[] }. */
|
* { moved, errors[] }. */
|
||||||
move: async (photoIds: string[], targetId: string) => {
|
move: async (photoIds: string[], targetId: string) => {
|
||||||
@@ -163,12 +190,23 @@ export const library = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
stats: async () => {
|
stats: async (): Promise<LibraryStats> => {
|
||||||
const response = await api.get('/library/stats')
|
const response = await api.get('/library/stats')
|
||||||
return response.data
|
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
|
// Heaps API
|
||||||
export interface Heap {
|
export interface Heap {
|
||||||
id: string
|
id: string
|
||||||
@@ -202,6 +240,13 @@ export const heaps = {
|
|||||||
await api.delete(`/heaps/${heapId}`)
|
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
|
/** Lightweight: just the photo ids in a heap, for client-side membership
|
||||||
* lookups (the basket affordance on thumbnails). */
|
* lookups (the basket affordance on thumbnails). */
|
||||||
photoIds: async (heapId: string): Promise<string[]> => {
|
photoIds: async (heapId: string): Promise<string[]> => {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
|
import type { ColorLabel } from '../constants/colorLabels'
|
||||||
|
|
||||||
export type MediaType = 'photo' | 'video' | 'raw' | 'heic'
|
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 FlagFilter = 'any' | 'discarded'
|
||||||
export type SortField =
|
export type SortField =
|
||||||
| 'taken_at'
|
| 'taken_at'
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ export interface Photo {
|
|||||||
is_discarded: boolean
|
is_discarded: boolean
|
||||||
is_duplicate: boolean
|
is_duplicate: boolean
|
||||||
file_hash: string
|
file_hash: string
|
||||||
folder_id?: string | null
|
folder_id: string | null
|
||||||
|
added_at: string | null
|
||||||
thumb_small?: string
|
thumb_small?: string
|
||||||
thumb_medium?: string
|
thumb_medium?: string
|
||||||
thumb_large?: string
|
thumb_large?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user