7 Commits

Author SHA1 Message Date
bd904aca36 fix: assorted UI polish from review pass
- FilterPill: drop the inline value text from the active state. Pills
  now stay the same width whether or not a filter is set; the popover
  is the canonical place to read the value, and the title attribute
  surfaces it on hover.
- TopBar: remove the search input — search lives in the filter bar now.
- FilterBar: add a search input on the left, with the pill cluster
  centered between it and a flex-shrink-0 Clear-all on the right.
- LeftSidebar / HeapsPanel: count badges use a fixed-width slot
  (h-5 min-w-[24px], tabular-nums) so counts line up in the same
  visual column across rows. Empty rows reserve the slot.
- LeftSidebar: pull section counts (All Photos, Rated, Duplicates,
  Discarded) from a new useLibraryStatsQuery hook backed by the
  expanded /library/stats endpoint. Tags count was already wired.
- backend/library: stats endpoint returns per-section counts that
  match the filter the sidebar applies on click.
- Stats invalidation hooked into the standard photo-mutation paths.
- RightSidebar header: h-12 to match TopBar height.
- Timeline sticky date overlay: only show once the natural in-grid
  header has scrolled OUT of the viewport. Avoids the duplicate-label
  flash when both labels would be visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:45:30 +02:00
696477eefd feat: heap row kebab menu with rename + duplicate
The heap row used to fan out three small icon buttons (set active, convert
to folder, delete) on hover, which crowded the row and didn't leave room
for new actions. Collapse the destructive / occasional ones into a kebab
menu and add the missing operations.

- Right-aligned action cluster: active indicator → count badge → target
  toggle (when not active) → kebab menu, all flex-shrink-0 so the name
  truncates first.
- Kebab menu items: Rename, Duplicate, Move to folder…, Delete. Outside
  click and Escape close the popover; the trigger has aria-haspopup +
  aria-expanded. Delete still confirms via window.confirm.
- Inline rename: double-click a heap row OR pick Rename from the menu
  to edit the name in place. Enter commits, Escape cancels. Mirrors the
  folder rename pattern in LeftSidebar.
- backend: new POST /heaps/{id}/duplicate creates a copy with the same
  membership ("{name} (copy)") via INSERT...SELECT on heap_photos.
  Never marks the new heap as active so duplicating doesn't quietly
  steal the user's T-key destination.
- api.ts: heaps.duplicate wrapper.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:36:00 +02:00
fc63b1f69d config: env-driven CORS, ports, log level, timezone
The CORS allowed-origins list, host port mappings, log level, container
timezone, and worker concurrency are now all driven by environment
variables with sane defaults. Same-origin access through the nginx
proxy keeps working with no config; direct cross-origin backend
access can be locked down via ALLOWED_ORIGINS.

- backend/config: ALLOWED_ORIGINS env (comma-separated, "*" for any)
  exposed via settings.cors_origins. LOG_LEVEL too.
- backend/main: build the CORS middleware from settings.cors_origins,
  auto-disable allow_credentials when origins is wildcard (CORS spec
  forbids credentials + "*").
- docker-compose: parameterize FRONTEND_PORT, BACKEND_PORT, REDIS_PORT,
  CELERYD_CONCURRENCY, LOG_LEVEL, and TZ via ${VAR:-default} so each
  has a working fallback if the .env entry is missing.
- .env.example: new template documenting every knob with examples.
- .env: pruned to only the values that diverge from .env.example;
  removed dead VITE_API_URL.
- README: configuration knobs table + "accessing from another machine"
  section explaining the same-origin proxy story.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:31:59 +02:00
ac5b18b60c fix: cross-machine access + center filter bar + floating hints
- api.ts: switch baseURL from http://localhost:8001/api/v1 to relative
  /api/v1. Both nginx (prod) and vite (dev) already proxy /api/ to the
  backend, so requests become same-origin and the app works from any
  host (LAN IP, reverse proxy, another machine) with no CORS dance.
- backend CORS: open to "*" as a fallback for the rare direct-hit case;
  the normal flow is same-origin via the proxy and never touches CORS.
- App layout: move FilterBar and DiscardActionBar inside the main
  content column (right of the left sidebar) so the filter row no
  longer bleeds across the sidebar.
- FilterBar: justify-center the pills so they sit centered above the
  timeline. Clear-all uses ml-2 instead of ml-auto.
- KeyboardHints: convert to a floating, glassy pill pinned bottom-
  center (fixed positioning + backdrop-blur + ring) instead of a flat
  toolbar row. Removed from the column layout — now mounted as an
  overlay sibling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:28:04 +02:00
a5b4054a71 feat: bulk tag add/remove on multi-select right sidebar
The bulk action panel previously covered rating, color, flag, and pick
but had no way to apply tags across a multi-photo selection — the only
path was to tag photos one at a time via the single-photo PhotoInfoPanel.
Add it.

- backend: extend the existing /photos/bulk action endpoint with
  add_tags and remove_tags actions. add_tags is idempotent (computes
  the new (photo_id, tag_id) pair set against existing rows and inserts
  only the missing ones); remove_tags is a single DELETE WHERE IN.
- api.ts: bulkAddTags / bulkRemoveTags wrappers.
- RightSidebar: new BulkTagsEditor below the bulk flag row. Filters /
  searches the existing tag list, lets the user click any chip to apply
  it to the whole selection or X to remove it. Typing a name with no
  exact match shows a "Create and apply" button that creates the tag
  via tagsApi.create and immediately attaches it to every selected
  photo. All three mutations invalidate both the photo and tag caches
  so the FilterBar tag count + sidebar Tags section stay fresh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:25:18 +02:00
a55839d9a2 fix: more audit findings — perf, types, and a11y polish
- backend/photos: collapse the per-tag subquery loop in the tag filter
  into a single GROUP BY ... HAVING COUNT(DISTINCT) = N subquery so the
  cost is independent of how many tags the user is filtering on.
- useFilterUrlSync: type the parseUrl return value as
  Partial<FilterState> & { currentSection?: string } so the section field
  doesn't need an (out as any) cast.
- Timeline sticky header: bump opacity, padding, and border so it reads
  more clearly against the underlying grid.
- FilterPill clear: convert the nested <button> (invalid HTML — buttons
  cannot nest) to a span with role=button + keyboard handler, with a
  larger hit area.
- RightSidebar: add aria-label to the close-X buttons so screen readers
  announce them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:22:23 +02:00
749e836617 fix: high-severity findings from code audit
- backend/photos: whitelist sortable columns instead of getattr(Photo, sort).
  Previously any client-supplied string was passed to SQLAlchemy, exposing
  every Photo attribute (filepath, file_hash, etc.) as a sort target.
- App: move the auto-show-right-sidebar logic out of the render body and
  into a useEffect. The previous version called setState during render,
  causing extra re-render passes the audit caught.
- types/photo: add added_at and tighten folder_id from optional to nullable.
  Drops a (photo as any).added_at cast in Timeline.
- constants/colorLabels: extract a single COLOR_LABEL_OPTIONS used by
  FilterBar, RightSidebar, and PhotoInfoPanel. filterStore re-exports the
  ColorLabel type so existing imports keep working.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:18:19 +02:00
28 changed files with 1039 additions and 281 deletions

40
.env
View File

@@ -1,30 +1,20 @@
# Environment variables for Mulita
#
# Set PHOTO_DIRS to the HOST path of your photo library. The compose file
# mounts this at /photos inside the container, and on first boot Mulita
# auto-creates a source root pointing at /photos so your library is
# scanned with zero further configuration.
#
# Examples:
# macOS / Linux: PHOTO_DIRS=/Users/you/Pictures
# Network share: PHOTO_DIRS=/mnt/nas/photos
# Windows (WSL): PHOTO_DIRS=/mnt/c/Users/you/Pictures
# Mulita / PhotoVault local environment.
# See .env.example for the full list of knobs and their docs.
# REQUIRED — host path to your photo library.
PHOTO_DIRS=/Users/dtoro/Pictures/MulitaTest
# Redis configuration
REDIS_URL=redis://localhost:6379
# Ports — change if 3000 / 8001 collide with other services on the host.
FRONTEND_PORT=3000
BACKEND_PORT=8001
REDIS_PORT=6379
# Database URL
DATABASE_URL=sqlite+aiosqlite:///data/db/mulita.db
# CORS — wildcard for local dev. Lock down for real deployments.
ALLOWED_ORIGINS=*
# Celery configuration
CELERY_BROKER_URL=redis://localhost:6379
CELERY_RESULT_BACKEND=redis://localhost:6379
# Logging + timezone.
LOG_LEVEL=INFO
TZ=UTC
# Celery worker pool.
CELERYD_CONCURRENCY=4
# API settings
API_HOST=0.0.0.0
API_PORT=8000
# Frontend settings
VITE_API_URL=http://localhost:8000

83
.env.example Normal file
View 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

View File

@@ -43,19 +43,16 @@ git clone <repository-url>
cd muleimage
```
2. Set **one** environment variable in `.env` — the **host** directory
that contains your photo library. Whatever you point at will become
your library inside Mulita.
2. Copy the example env file and set **one** variable — the **host**
directory that contains your photo library. Whatever you point at
will become your library inside Mulita.
```bash
# macOS / Linux
PHOTO_DIRS=/Users/you/Pictures
# or any folder
PHOTO_DIRS=/mnt/nas/photos
# Windows (WSL)
PHOTO_DIRS=/mnt/c/Users/you/Pictures
cp .env.example .env
# then edit .env and set PHOTO_DIRS:
# macOS / Linux: PHOTO_DIRS=/Users/you/Pictures
# Network share: PHOTO_DIRS=/mnt/nas/photos
# Windows (WSL): PHOTO_DIRS=/mnt/c/Users/you/Pictures
```
3. Start the stack:
@@ -70,6 +67,38 @@ docker compose up -d
You don't need to touch `mulita.yml` or the API to get started.
### Configuration knobs
Everything is environment-driven. `PHOTO_DIRS` is the only required
value; the rest have sensible defaults documented in `.env.example`:
| Variable | Default | Notes |
|----------------------|---------|----------------------------------------------------|
| `PHOTO_DIRS` | — | **Required.** Host path mounted at `/photos`. |
| `FRONTEND_PORT` | `3000` | SPA host port. Bump if `3000` is taken. |
| `BACKEND_PORT` | `8001` | Direct backend port (debug only — frontend uses internal nginx proxy). |
| `REDIS_PORT` | `6379` | Redis host port (internal services don't need it). |
| `ALLOWED_ORIGINS` | `*` | Comma-separated CORS origins for direct backend access. Lock down for prod, e.g. `https://photos.example.com`. |
| `LOG_LEVEL` | `INFO` | Backend + worker log level. `DEBUG` for chasing scan issues. |
| `TZ` | `UTC` | Container timezone. Affects log timestamps and "added at". |
| `CELERYD_CONCURRENCY`| `4` | Parallel worker processes (scans, thumbs, metadata). Lower on a Pi, higher on a beefy host. |
### Accessing from another machine
The frontend talks to the backend through its bundled nginx, which
proxies `/api/` to the backend on the internal compose network. That
means requests are always **same-origin** as the page, so accessing
Mulita from another host works without any CORS dance:
```
http://<your-server-ip>:3000
```
If you want to put it behind a reverse proxy at e.g.
`https://photos.your.tld`, set `ALLOWED_ORIGINS` to that host so the
backend's direct port (`BACKEND_PORT`) also accepts cross-origin
requests if anything bypasses the proxy.
### How libraries are managed
Mulita is **config-driven**: the host directory you mount via

View File

@@ -70,7 +70,29 @@ class Settings(BaseSettings):
# API settings
api_host: str = Field(default="0.0.0.0", env="API_HOST")
api_port: int = Field(default=8000, env="API_PORT")
# CORS — comma-separated list of allowed origins, or "*" for any.
# Same-origin requests (the normal case behind nginx / vite proxy)
# never trip CORS, so this is only for direct browser access from
# other origins (LAN IP, reverse proxy, dev tools).
allowed_origins: str = Field(default="*", env="ALLOWED_ORIGINS")
# Logging — accepts standard python levels (DEBUG, INFO, WARNING,
# ERROR, CRITICAL). Bumped from INFO when chasing a problem.
log_level: str = Field(default="INFO", env="LOG_LEVEL")
@property
def cors_origins(self) -> list[str]:
"""Parse the ALLOWED_ORIGINS env var into a list. Accepts:
- "*" → wildcard (single-element list ["*"])
- "http://a.com,http://b.com" → split + strip
Empty entries are dropped.
"""
raw = (self.allowed_origins or "").strip()
if not raw or raw == "*":
return ["*"]
return [o.strip() for o in raw.split(",") if o.strip()]
# App configuration from YAML
_config: Optional[MulitaConfig] = None

View File

@@ -62,11 +62,20 @@ app = FastAPI(
lifespan=lifespan
)
# Configure CORS
# Configure CORS. The frontend normally talks to the backend through the
# nginx (prod) or vite (dev) proxy, so requests are same-origin and never
# trip CORS. ALLOWED_ORIGINS in .env controls the fallback for direct
# browser access from other origins (LAN IP, reverse proxy under a
# different host). Defaults to "*" since this is a single-user homelab
# tool; lock it down by setting e.g. ALLOWED_ORIGINS=https://photos.your.tld
# in production deployments.
_origins = settings.cors_origins
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://localhost:5173"], # Frontend URLs
allow_credentials=True,
allow_origins=_origins,
# Wildcard origins can't be combined with credentials per the CORS
# spec, so credentials get auto-disabled in that case.
allow_credentials=_origins != ["*"],
allow_methods=["*"],
allow_headers=["*"],
)

View File

@@ -137,6 +137,46 @@ async def update_heap(
}
@router.post("/{heap_id}/duplicate", status_code=201)
async def duplicate_heap(heap_id: str, db: AsyncSession = Depends(get_db)):
"""Create a new heap with the same membership as an existing one. The
new heap is named "{original} (copy)" and is never the active target —
duplicating shouldn't quietly steal the user's T-key destination.
"""
result = await db.execute(select(Heap).where(Heap.id == heap_id))
source = result.scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="Heap not found")
new_heap = Heap(name=f"{source.name} (copy)", is_active=False)
db.add(new_heap)
await db.flush() # populate new_heap.id without committing yet
# Bulk-copy the membership rows. SELECT photo_id FROM heap_photos WHERE
# heap_id = :src — INSERT each into the new heap. Done as a single
# INSERT...SELECT to avoid round-tripping ids through Python.
member_rows = await db.execute(
select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id)
)
photo_ids = [row[0] for row in member_rows.all()]
if photo_ids:
await db.execute(
insert(heap_photos),
[{"heap_id": new_heap.id, "photo_id": pid} for pid in photo_ids],
)
await db.commit()
await db.refresh(new_heap)
return {
"id": new_heap.id,
"name": new_heap.name,
"is_active": False,
"photo_count": len(photo_ids),
"created_at": new_heap.created_at,
"updated_at": new_heap.updated_at,
}
@router.delete("/{heap_id}", status_code=204)
async def delete_heap(heap_id: str, db: AsyncSession = Depends(get_db)):
"""Delete a heap. Photos themselves are unaffected — only the membership

View File

@@ -12,30 +12,68 @@ router = APIRouter()
@router.get("/stats")
async def get_library_stats(db: AsyncSession = Depends(get_db)):
"""Get library statistics"""
# Count total photos
total_photos = await db.execute(
select(func.count(Photo.id)).where(Photo.media_type.in_(['photo', 'heic', 'raw']))
)
photo_count = total_photos.scalar()
# Count total videos
total_videos = await db.execute(
select(func.count(Photo.id)).where(Photo.media_type == 'video')
)
video_count = total_videos.scalar()
# Calculate total size
total_size = await db.execute(
select(func.sum(Photo.file_size))
)
size = total_size.scalar() or 0
"""Get library statistics + per-section counts. Each section count
matches the filter the sidebar applies when you click it, so the
sidebar badges and the timeline below them stay in sync.
- all_photos: non-discarded photos + videos (matches the All
Photos section's default filter)
- rated: non-discarded with rating >= 1
- duplicates: non-discarded with is_duplicate = true
- discarded: is_discarded = true
- total_size: raw bytes across every row, including discarded
"""
not_discarded = Photo.is_discarded.is_(False)
all_photos_count = (
await db.execute(select(func.count(Photo.id)).where(not_discarded))
).scalar() or 0
rated_count = (
await db.execute(
select(func.count(Photo.id)).where(not_discarded, Photo.rating >= 1)
)
).scalar() or 0
duplicates_count = (
await db.execute(
select(func.count(Photo.id)).where(
not_discarded, Photo.is_duplicate.is_(True)
)
)
).scalar() or 0
discarded_count = (
await db.execute(
select(func.count(Photo.id)).where(Photo.is_discarded.is_(True))
)
).scalar() or 0
# Legacy split (kept for the existing /stats consumers).
photo_count = (
await db.execute(
select(func.count(Photo.id)).where(
Photo.media_type.in_(['photo', 'heic', 'raw'])
)
)
).scalar() or 0
video_count = (
await db.execute(
select(func.count(Photo.id)).where(Photo.media_type == 'video')
)
).scalar() or 0
size = (await db.execute(select(func.sum(Photo.file_size)))).scalar() or 0
return {
"all_photos": all_photos_count,
"rated": rated_count,
"duplicates": duplicates_count,
"discarded": discarded_count,
"total_photos": photo_count,
"total_videos": video_count,
"total_size": size,
"total_size_gb": round(size / (1024**3), 2) if size else 0
"total_size_gb": round(size / (1024**3), 2) if size else 0,
}
@router.post("/scan")

View File

@@ -146,24 +146,37 @@ async def list_photos(
)
# Tag filter — comma-separated tag ids, AND semantics. A photo must
# have a row in photo_tags for EVERY listed tag. Implemented as one
# subquery per tag id since SQLite doesn't have an efficient
# "set-contains-all" operator.
# have a row in photo_tags for EVERY listed tag. Implemented as a
# single GROUP BY ... HAVING COUNT(DISTINCT) = N subquery so the cost
# is independent of the number of tags being filtered.
if tag_ids:
tag_id_list = [t.strip() for t in tag_ids.split(',') if t.strip()]
for tid in tag_id_list:
filters.append(
Photo.id.in_(
select(photo_tags.c.photo_id).where(photo_tags.c.tag_id == tid)
if tag_id_list:
matching_photos = (
select(photo_tags.c.photo_id)
.where(photo_tags.c.tag_id.in_(tag_id_list))
.group_by(photo_tags.c.photo_id)
.having(
func.count(func.distinct(photo_tags.c.tag_id)) == len(tag_id_list)
)
)
filters.append(Photo.id.in_(matching_photos))
# Apply all filters
if filters:
query = query.where(and_(*filters))
# Apply sorting
sort_column = getattr(Photo, sort, Photo.taken_at)
# Apply sorting. The sort field is whitelisted so a malicious client
# can't pass an arbitrary column name (e.g. "filepath" leaks paths or
# any other Photo attribute the model exposes).
SORT_WHITELIST = {
"taken_at": Photo.taken_at,
"added_at": Photo.added_at,
"filename": Photo.filename,
"file_size": Photo.file_size,
"rating": Photo.rating,
}
sort_column = SORT_WHITELIST.get(sort, Photo.taken_at)
if order == "desc":
query = query.order_by(sort_column.desc())
else:
@@ -849,6 +862,54 @@ async def bulk_action(
elif action.action == 'set_color':
for photo in photos:
photo.color_label = action.value
elif action.action == 'add_tags':
# value is a list of tag ids. We bulk-insert (photo_id, tag_id)
# rows for every (photo, tag) combination that doesn't already
# exist, so the operation is idempotent.
tag_ids = action.value or []
if not isinstance(tag_ids, list) or not tag_ids:
return {"status": "success", "added": 0, "message": "No tags supplied"}
photo_ids = [p.id for p in photos]
existing = await db.execute(
select(photo_tags.c.photo_id, photo_tags.c.tag_id).where(
photo_tags.c.photo_id.in_(photo_ids),
photo_tags.c.tag_id.in_(tag_ids),
)
)
existing_pairs = {(row[0], row[1]) for row in existing.all()}
new_rows = [
{"photo_id": pid, "tag_id": tid}
for pid in photo_ids
for tid in tag_ids
if (pid, tid) not in existing_pairs
]
if new_rows:
from sqlalchemy import insert
await db.execute(insert(photo_tags), new_rows)
await db.commit()
return {
"status": "success",
"added": len(new_rows),
"message": f"Added {len(new_rows)} tag link{'s' if len(new_rows) != 1 else ''}",
}
elif action.action == 'remove_tags':
tag_ids = action.value or []
if not isinstance(tag_ids, list) or not tag_ids:
return {"status": "success", "removed": 0, "message": "No tags supplied"}
photo_ids = [p.id for p in photos]
from sqlalchemy import delete as sql_delete
result = await db.execute(
sql_delete(photo_tags).where(
photo_tags.c.photo_id.in_(photo_ids),
photo_tags.c.tag_id.in_(tag_ids),
)
)
await db.commit()
return {
"status": "success",
"removed": result.rowcount or 0,
"message": f"Removed tag link{'s' if (result.rowcount or 0) != 1 else ''}",
}
else:
raise HTTPException(status_code=400, detail="Invalid action")

View File

@@ -2,12 +2,14 @@ version: '3.8'
services:
frontend:
build:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: mulita-frontend
ports:
- "3000:80"
# Host port is configurable via FRONTEND_PORT in .env so multiple
# instances / other services on the same host don't collide.
- "${FRONTEND_PORT:-3000}:80"
depends_on:
- backend
networks:
@@ -20,7 +22,10 @@ services:
dockerfile: Dockerfile
container_name: mulita-backend
ports:
- "8001:8000"
# Direct backend access on the host is rarely needed (the frontend
# talks to it through the nginx /api proxy on the same network),
# but it's exposed for debugging / curl. Override with BACKEND_PORT.
- "${BACKEND_PORT:-8001}:8000"
volumes:
- ./mulita.yml:/app/config/mulita.yml:ro
# The single host → container mount for your photo library. Set
@@ -38,6 +43,9 @@ services:
- CELERY_BROKER_URL=redis://redis:6379
- CELERY_RESULT_BACKEND=redis://redis:6379
- PHOTO_DIRS=${PHOTO_DIRS:-/photos}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- TZ=${TZ:-UTC}
depends_on:
- redis
networks:
@@ -49,7 +57,7 @@ services:
context: ./backend
dockerfile: Dockerfile
container_name: mulita-worker
command: celery -A app.tasks.celery worker --loglevel=info --concurrency=4
command: celery -A app.tasks.celery worker --loglevel=${LOG_LEVEL:-info} --concurrency=${CELERYD_CONCURRENCY:-4}
volumes:
- ./mulita.yml:/app/config/mulita.yml:ro
- ${PHOTO_DIRS:-./photos}:/photos:rw
@@ -62,7 +70,9 @@ services:
- CELERY_BROKER_URL=redis://redis:6379
- CELERY_RESULT_BACKEND=redis://redis:6379
- PHOTO_DIRS=${PHOTO_DIRS:-/photos}
- CELERYD_CONCURRENCY=4
- CELERYD_CONCURRENCY=${CELERYD_CONCURRENCY:-4}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- TZ=${TZ:-UTC}
depends_on:
- redis
- backend
@@ -73,8 +83,10 @@ services:
redis:
image: redis:7-alpine
container_name: mulita-redis
# Host port exposed only for local debugging; the backend / worker
# reach Redis via the internal mulita-network on its container name.
ports:
- "6379:6379"
- "${REDIS_PORT:-6379}:6379"
volumes:
- redis_data:/data
networks:

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { Timeline } from './components/timeline/Timeline'
import { LeftSidebar } from './components/layout/LeftSidebar'
import { RightSidebar } from './components/layout/RightSidebar'
@@ -37,22 +37,22 @@ function App() {
// Auto-show right sidebar when photos are selected — but only in grid mode,
// so leaving the preview doesn't fight the user's prior sidebar state.
if (viewMode === 'grid') {
// Lives in an effect (not the render body) to avoid setState-during-render
// and the cascading re-renders the audit caught.
useEffect(() => {
if (viewMode !== 'grid') return
if (selectedPhotos.length > 0 && !rightSidebarOpen) {
setRightSidebarOpen(true)
} else if (selectedPhotos.length === 0 && rightSidebarOpen) {
setRightSidebarOpen(false)
}
}
}, [viewMode, selectedPhotos.length, rightSidebarOpen])
const showRightSidebar = rightSidebarOpen && viewMode === 'grid'
return (
<div className="flex flex-col h-screen bg-bg text-text">
<TopBar />
<FilterBar />
<DiscardActionBar />
<KeyboardHints />
<div className="flex flex-1 overflow-hidden">
{/* Left Sidebar */}
@@ -64,9 +64,15 @@ function App() {
<LeftSidebar />
</div>
{/* Main Content - Timeline */}
<div className="flex-1 overflow-auto">
<Timeline />
{/* Main column — filter bar, discard bar, timeline. Lives to the
* right of the left sidebar so the filter row doesn't bleed
* across the sidebar. */}
<div className="flex min-w-0 flex-1 flex-col">
<FilterBar />
<DiscardActionBar />
<div className="flex-1 overflow-auto">
<Timeline />
</div>
</div>
{/* Right Sidebar */}
@@ -79,6 +85,10 @@ function App() {
</div>
</div>
{/* Floating keyboard hints — pinned bottom-center, glassy. Sits
* above the timeline and below the toast layer. */}
<KeyboardHints />
{/* Scan Progress Indicator */}
<ScanProgress />

View File

@@ -25,22 +25,22 @@ export function KeyboardHints() {
]
return (
<div className="flex justify-center border-b border-border bg-surface/60 px-4 py-1.5">
<div className="flex items-center gap-3">
<div className="pointer-events-none fixed bottom-4 left-1/2 z-30 -translate-x-1/2">
<div className="pointer-events-auto flex items-center gap-3 rounded-full border border-border/60 bg-surface/40 px-4 py-1.5 shadow-lg ring-1 ring-white/5 backdrop-blur-md">
{hints.map((hint, i) => (
<div key={i} className="flex items-center gap-1.5">
<kbd className="rounded bg-surface-offset px-2 py-0.5 text-[11px] font-medium text-text">
<kbd className="rounded bg-surface-offset/80 px-1.5 py-0.5 text-[11px] font-medium text-text">
{hint.key}
</kbd>
<span className="text-xs text-text-muted">{hint.action}</span>
{i < hints.length - 1 && (
<span className="ml-2 text-text-faint"></span>
<span className="ml-1 text-text-faint"></span>
)}
</div>
))}
{selectedCount > 0 && (
<>
<span className="ml-2 text-text-faint"></span>
<span className="text-text-faint"></span>
<span className="text-xs font-medium text-primary">
{selectedCount} selected
</span>

View File

@@ -50,6 +50,7 @@ export function ScanProgress() {
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
queryClient.invalidateQueries({ queryKey: ['heaps'] })
queryClient.invalidateQueries({ queryKey: ['tags'] })
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
if (isVisible && (scanStatus?.processed_files ?? 0) > 0) {
// Keep showing for 3 seconds after scan completes

View File

@@ -8,6 +8,7 @@ import { discard as discardApi, photos as photosApi } from '../../services/api'
import { toast } from '../ToastContainer'
import { ConfirmDialog } from '../dialogs/ConfirmDialog'
import { registerUndoable } from '../../store/undoStore'
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
/**
* Top-of-timeline bar visible only when the discarded filter is active.
@@ -32,10 +33,12 @@ export function DiscardActionBar() {
async () => {
await photosApi.bulkDiscard(ids)
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
}
)
clearSelection()
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
},
onError: (e: any) => toast.error('Restore failed', e.message || 'Unknown error'),
})
@@ -58,6 +61,7 @@ export function DiscardActionBar() {
}
clearSelection()
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
setDeleteSelectedOpen(false)
},
onError: (e: any) =>
@@ -79,6 +83,7 @@ export function DiscardActionBar() {
}
clearSelection()
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
setConfirmOpen(false)
},
onError: (e: any) => toast.error('Empty failed', e.message || 'Unknown error'),

View File

@@ -1,14 +1,17 @@
import { Star, X, ArrowDown, ArrowUp } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { Star, X, ArrowDown, ArrowUp, Search } from 'lucide-react'
import clsx from 'clsx'
import {
useFilterStore,
hasActiveFilters,
type MediaType,
type ColorLabel,
type SortField,
} from '../../store/filterStore'
import { useTagsQuery } from '../../hooks/useTagsQuery'
import { FilterPill } from './FilterPill'
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
const SEARCH_DEBOUNCE_MS = 300
const MEDIA_TYPES: { value: MediaType; label: string }[] = [
{ value: 'photo', label: 'Photo' },
@@ -17,15 +20,6 @@ const MEDIA_TYPES: { value: MediaType; label: string }[] = [
{ value: 'heic', label: 'HEIC' },
]
const COLOR_LABEL_OPTIONS: { value: ColorLabel; className: string }[] = [
{ value: 'red', className: 'bg-red-500' },
{ value: 'orange', className: 'bg-orange-500' },
{ value: 'yellow', className: 'bg-yellow-400' },
{ value: 'green', className: 'bg-green-500' },
{ value: 'blue', className: 'bg-blue-500' },
{ value: 'purple', className: 'bg-purple-500' },
]
const SORT_OPTIONS: { value: SortField; label: string }[] = [
{ value: 'taken_at', label: 'Date taken' },
{ value: 'added_at', label: 'Date added' },
@@ -66,6 +60,26 @@ export function FilterBar() {
const { data: allTags = [] } = useTagsQuery()
// Search box. Local state mirrors the store so typing stays responsive
// while we debounce store writes (each store write triggers a re-fetch).
const storeQ = useFilterStore((s) => s.q)
const setStoreQ = useFilterStore((s) => s.setQ)
const [searchQuery, setSearchQuery] = useState(storeQ)
useEffect(() => {
setSearchQuery(storeQ)
}, [storeQ])
const debounceRef = useRef<number | null>(null)
useEffect(() => {
if (searchQuery === storeQ) return
if (debounceRef.current) window.clearTimeout(debounceRef.current)
debounceRef.current = window.setTimeout(() => {
setStoreQ(searchQuery)
}, SEARCH_DEBOUNCE_MS)
return () => {
if (debounceRef.current) window.clearTimeout(debounceRef.current)
}
}, [searchQuery, storeQ, setStoreQ])
// Pre-compute pill values + active flags so the JSX stays terse.
const dateActive = dateFrom !== null || dateTo !== null
const dateValue = dateActive
@@ -102,7 +116,43 @@ export function FilterBar() {
const anyActive = hasActiveFilters(filterState)
return (
<div className="flex items-center gap-1.5 overflow-x-auto border-b border-border bg-surface px-3 py-1.5">
<div className="flex items-center gap-3 border-b border-border bg-surface px-3 py-1.5">
{/* Search — left of the pill cluster. Same id as before so the
* global "/" focus shortcut still finds it. */}
<div className="relative w-56 flex-shrink-0">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted" />
<input
id="topbar-search"
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setSearchQuery('')
setStoreQ('')
e.currentTarget.blur()
}
}}
placeholder="Search photos…"
className="w-full rounded-full border border-border bg-surface-2 py-1 pl-8 pr-7 text-xs text-text placeholder-text-muted focus:border-primary focus:outline-none"
/>
{searchQuery && (
<button
onClick={() => {
setSearchQuery('')
setStoreQ('')
}}
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-full p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
title="Clear search (Esc)"
aria-label="Clear search"
>
<X className="h-3 w-3" />
</button>
)}
</div>
{/* Pills — centered, scroll horizontally if they overflow. */}
<div className="flex flex-1 items-center justify-center gap-1.5 overflow-x-auto">
{/* Date */}
<FilterPill
label="Date"
@@ -325,11 +375,13 @@ export function FilterBar() {
</button>
</div>
</FilterPill>
</div>
{/* Clear-all — pinned right of the pill cluster. */}
{anyActive && (
<button
onClick={clearAll}
className="ml-auto whitespace-nowrap rounded-full border border-border px-2.5 py-1 text-xs text-text-muted hover:bg-surface-2 hover:text-text"
className="flex-shrink-0 whitespace-nowrap rounded-full border border-border px-2.5 py-1 text-xs text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear all filters in this section"
>
Clear all

View File

@@ -6,9 +6,10 @@ import clsx from 'clsx'
interface FilterPillProps {
/** Category label, always shown ("Date", "Type", etc.). */
label: string
/** When the filter is active, a short summary of its current value
* ("≥ 3★", "RAW + Photo", "Mar 2024 → Apr 2026"). Renders inside the
* pill so the user sees the state without opening the popover. */
/** Currently unused in the rendered output — the inline value display
* was making active pills wider than inactive ones. Kept on the
* interface so callers don't have to change. The value is still
* surfaced via the title attribute for hover discovery. */
value?: string | null
isActive?: boolean
/** When provided + isActive, an X appears inside the pill that clears
@@ -94,6 +95,10 @@ export function FilterPill({
<button
ref={buttonRef}
onClick={() => setOpen((v) => !v)}
// Hover to see the active value as a tooltip — keeps the pill at
// a constant width regardless of state. The popover is the
// canonical place to read/edit the filter value.
title={isActive && value ? `${label}: ${value}` : label}
className={clsx(
'flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs transition-colors',
isActive
@@ -102,21 +107,27 @@ export function FilterPill({
)}
>
<span className={clsx(isActive && 'font-medium')}>{label}</span>
{isActive && value && (
<span className="font-mono text-[11px] opacity-90">{value}</span>
)}
{isActive && onClear ? (
<button
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation()
onClear()
}}
className="ml-0.5 rounded-full p-0.5 hover:bg-primary/30"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
onClear()
}
}}
className="ml-1 inline-flex h-5 w-5 cursor-pointer items-center justify-center rounded-full hover:bg-primary/30"
title={`Clear ${label}`}
aria-label={`Clear ${label}`}
>
<X className="h-3 w-3" />
</button>
</span>
) : (
<ChevronDown className="h-3 w-3 opacity-60" />
)}

View File

@@ -1,12 +1,15 @@
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import {
ShoppingBasket,
Plus,
Target,
X,
ChevronDown,
ChevronRight,
FolderOutput,
MoreHorizontal,
Pencil,
Copy,
Trash2,
} from 'lucide-react'
import clsx from 'clsx'
import { useMutation, useQueryClient } from '@tanstack/react-query'
@@ -40,6 +43,32 @@ export function HeapsPanel() {
// the drop highlight ring. Only one heap can be the target at a time.
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
const [convertingHeap, setConvertingHeap] = useState<Heap | null>(null)
// Inline rename state for heap rows: stores the heap id being edited and
// the draft name. Mirrors the folder rename pattern in LeftSidebar.
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameDraft, setRenameDraft] = useState('')
// Which heap's burger menu is currently open. null when no menu is open.
// The popover closes on outside click and Escape via the effect below.
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!openMenuId) return
const onDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpenMenuId(null)
}
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpenMenuId(null)
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [openMenuId])
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
@@ -80,6 +109,24 @@ export function HeapsPanel() {
toast.error('Failed to delete heap', e.message || 'Unknown error'),
})
const renameMutation = useMutation({
mutationFn: ({ heapId, name }: { heapId: string; name: string }) =>
heapsApi.update(heapId, { name }),
onSuccess: () => invalidate(),
onError: (e: any) =>
toast.error('Failed to rename heap', e.message || 'Unknown error'),
})
const duplicateMutation = useMutation({
mutationFn: (heapId: string) => heapsApi.duplicate(heapId),
onSuccess: (heap) => {
invalidate()
toast.success('Heap duplicated', heap.name)
},
onError: (e: any) =>
toast.error('Failed to duplicate heap', e.message || 'Unknown error'),
})
// Drop handler: add the dragged photos to the target heap. Optimistically
// updates the membership cache so the basket affordance flips immediately,
// mirroring the keyboard P-toggle pattern.
@@ -202,18 +249,35 @@ export function HeapsPanel() {
const isFiltered = currentSection === `heap-${heap.id}`
const isActive = heap.is_active
const isDropTarget = dropTargetId === heap.id
const isRenaming = renamingId === heap.id
const isMenuOpen = openMenuId === heap.id
const commitRename = () => {
const next = renameDraft.trim()
if (next && next !== heap.name) {
renameMutation.mutate({ heapId: heap.id, name: next })
}
setRenamingId(null)
}
return (
<div
key={heap.id}
className={clsx(
'group flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-[13px]',
'group relative flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-[13px]',
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
isDropTarget && 'ring-2 ring-primary bg-primary/10'
)}
style={{ paddingLeft: '32px' }}
onClick={() =>
onClick={() => {
if (isRenaming) return
navigateToSection(`heap-${heap.id}`, { heapId: heap.id })
}
}}
onDoubleClick={(e) => {
e.stopPropagation()
setRenamingId(heap.id)
setRenameDraft(heap.name)
}}
onDragOver={(e) => {
if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) {
e.preventDefault()
@@ -222,8 +286,6 @@ export function HeapsPanel() {
}
}}
onDragLeave={(e) => {
// Only clear if we're actually leaving this row, not just
// moving over a child element.
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
if (dropTargetId === heap.id) setDropTargetId(null)
}
@@ -249,63 +311,136 @@ export function HeapsPanel() {
isFiltered ? 'text-primary' : 'text-text-muted'
)}
/>
<span
className={clsx(
'flex-1 truncate',
isActive && 'font-semibold'
)}
title={heap.name}
>
{heap.name}
</span>
{isRenaming ? (
<input
autoFocus
type="text"
value={renameDraft}
onChange={(e) => setRenameDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
onBlur={commitRename}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
} else if (e.key === 'Escape') {
setRenamingId(null)
}
}}
className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none"
/>
) : (
<span
className={clsx('flex-1 truncate', isActive && 'font-semibold')}
title={heap.name}
>
{heap.name}
</span>
)}
{/* Right-aligned cluster. Active indicator + count are
* always visible; set-active and kebab appear on hover
* to the RIGHT of the count, displacing it slightly so
* the count column lines up with the rest of the
* sidebar in the resting state. */}
{isActive && (
<Target
className="h-3 w-3 text-primary"
className="h-3 w-3 flex-shrink-0 text-primary"
aria-label="Active heap (T target)"
/>
)}
{heap.photo_count > 0 && (
<span className="rounded bg-surface-offset px-1.5 py-0.5 text-xs text-text-muted">
{heap.photo_count > 0 ? (
<span className="flex h-5 min-w-[24px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1.5 text-xs tabular-nums text-text-muted">
{heap.photo_count}
</span>
) : (
<span className="h-5 min-w-[24px] flex-shrink-0" aria-hidden="true" />
)}
<button
onClick={(e) => {
e.stopPropagation()
if (!isActive) setActiveMutation.mutate(heap.id)
}}
className={clsx(
'rounded p-0.5 hover:bg-surface-offset hover:text-text',
isActive
? 'invisible'
: 'invisible text-text-muted group-hover:visible'
{!isActive && (
<button
onClick={(e) => {
e.stopPropagation()
setActiveMutation.mutate(heap.id)
}}
className="invisible flex-shrink-0 rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:visible"
title="Set as active heap (T target)"
aria-label="Set as active heap"
>
<Target className="h-3 w-3" />
</button>
)}
{/* Kebab menu — collects rename / duplicate / convert /
* delete so the row stays compact. */}
<div className="relative flex-shrink-0">
<button
onClick={(e) => {
e.stopPropagation()
setOpenMenuId(isMenuOpen ? null : heap.id)
}}
className={clsx(
'rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text',
isMenuOpen ? 'visible' : 'invisible group-hover:visible'
)}
title="More actions"
aria-label="More heap actions"
aria-haspopup="menu"
aria-expanded={isMenuOpen}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
{isMenuOpen && (
<div
ref={menuRef}
role="menu"
className="absolute right-0 top-full z-30 mt-1 min-w-[160px] overflow-hidden rounded-lg border border-border bg-surface py-1 text-sm shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<MenuItem
icon={<Pencil className="h-3.5 w-3.5" />}
label="Rename"
onClick={() => {
setOpenMenuId(null)
setRenamingId(heap.id)
setRenameDraft(heap.name)
}}
/>
<MenuItem
icon={<Copy className="h-3.5 w-3.5" />}
label="Duplicate"
onClick={() => {
setOpenMenuId(null)
duplicateMutation.mutate(heap.id)
}}
/>
<MenuItem
icon={<FolderOutput className="h-3.5 w-3.5" />}
label="Move to folder…"
onClick={() => {
setOpenMenuId(null)
setConvertingHeap(heap)
}}
/>
<div className="my-1 h-px bg-border" />
<MenuItem
icon={<Trash2 className="h-3.5 w-3.5" />}
label="Delete"
destructive
onClick={() => {
setOpenMenuId(null)
if (
confirm(
`Delete heap "${heap.name}"? Photos are not affected.`
)
) {
deleteMutation.mutate(heap.id)
}
}}
/>
</div>
)}
title="Set as active heap (T target)"
>
<Target className="h-3 w-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation()
setConvertingHeap(heap)
}}
className="invisible rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:visible"
title="Convert to folder…"
>
<FolderOutput className="h-3 w-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation()
if (confirm(`Delete heap "${heap.name}"? Photos are not affected.`)) {
deleteMutation.mutate(heap.id)
}
}}
className="invisible rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-reject group-hover:visible"
title="Delete heap"
>
<X className="h-3 w-3" />
</button>
</div>
</div>
)
})}
@@ -319,3 +454,31 @@ export function HeapsPanel() {
</div>
)
}
function MenuItem({
icon,
label,
onClick,
destructive = false,
}: {
icon: React.ReactNode
label: string
onClick: () => void
destructive?: boolean
}) {
return (
<button
role="menuitem"
onClick={onClick}
className={clsx(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors',
destructive
? 'text-reject hover:bg-reject/10'
: 'text-text hover:bg-surface-2'
)}
>
<span className="text-text-muted">{icon}</span>
{label}
</button>
)
}

View File

@@ -21,6 +21,10 @@ import { HeapsPanel } from '../heaps/HeapsPanel'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
import { useTagsQuery } from '../../hooks/useTagsQuery'
import {
useLibraryStatsQuery,
LIBRARY_STATS_QUERY_KEY,
} from '../../hooks/useLibraryStatsQuery'
import { registerUndoable } from '../../store/undoStore'
import type { Photo } from '../../types/photo'
@@ -45,6 +49,7 @@ export function LeftSidebar() {
const navigateToSection = useFilterStore((s) => s.navigateToSection)
const currentSection = useFilterStore((s) => s.currentSection)
const { data: allTags = [] } = useTagsQuery()
const { data: stats } = useLibraryStatsQuery()
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
// Bulk discard mutation for the drag-onto-Discarded interaction.
@@ -56,9 +61,11 @@ export function LeftSidebar() {
async () => {
await photosApi.bulkRestore(photoIds)
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
}
)
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
},
onError: (e: any) =>
toast.error('Discard failed', e?.message || 'Unknown error'),
@@ -270,11 +277,11 @@ export function LeftSidebar() {
label: 'Views',
icon: <Layers2 className="h-4 w-4" />,
children: [
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: 0 },
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 },
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: stats?.all_photos ?? 0 },
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: stats?.rated ?? 0 },
{ id: 'tags', label: 'Tags', icon: <TagIcon className="h-4 w-4" />, count: tagsTotalCount },
{ id: 'duplicates', label: 'Duplicates', icon: <Copy className="h-4 w-4" />, count: 0 },
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: 0 },
{ id: 'duplicates', label: 'Duplicates', icon: <Copy className="h-4 w-4" />, count: stats?.duplicates ?? 0 },
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: stats?.discarded ?? 0 },
],
},
{
@@ -438,11 +445,14 @@ export function LeftSidebar() {
<span className="flex-1 truncate">{item.label}</span>
)}
{/* Count Badge */}
{item.count !== undefined && item.count > 0 && (
<span className="rounded bg-surface-offset px-1.5 py-0.5 text-xs text-text-muted">
{/* Count Badge — fixed-width slot so counts line up in a column
* across rows regardless of digit count. */}
{item.count !== undefined && item.count > 0 ? (
<span className="flex h-5 min-w-[24px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1.5 text-xs tabular-nums text-text-muted">
{item.count}
</span>
) : (
<span className="h-5 min-w-[24px] flex-shrink-0" aria-hidden="true" />
)}
</div>

View File

@@ -1,23 +1,19 @@
import { X, Star, Info, ShoppingBasket, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { X, Star, Info, ShoppingBasket, Trash2, Plus } from 'lucide-react'
import clsx from 'clsx'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { usePhotoStore } from '../../store/photoStore'
import { photos as photosApi, heaps as heapsApi } from '../../services/api'
import {
photos as photosApi,
heaps as heapsApi,
tags as tagsApi,
} from '../../services/api'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
import { toast } from '../ToastContainer'
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
const COLOR_LABEL_OPTIONS: { value: ColorLabel; className: string }[] = [
{ value: 'red', className: 'bg-red-500' },
{ value: 'orange', className: 'bg-orange-500' },
{ value: 'yellow', className: 'bg-yellow-400' },
{ value: 'green', className: 'bg-green-500' },
{ value: 'blue', className: 'bg-blue-500' },
{ value: 'purple', className: 'bg-purple-500' },
]
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
/**
* Right-hand details panel.
@@ -49,6 +45,59 @@ export function RightSidebar() {
onSuccess: invalidatePhotoQueries,
})
// Bulk tag mutations. Tag mutations also need to invalidate the tags
// query so the FilterBar / sidebar tag counts stay fresh.
const invalidateTagsAndPhotos = () => {
invalidatePhotoQueries()
queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY })
}
const bulkAddTagsMutation = useMutation({
mutationFn: ({ ids, tagIds }: { ids: string[]; tagIds: string[] }) =>
photosApi.bulkAddTags(ids, tagIds),
onSuccess: (data) => {
const added = data?.added ?? 0
toast.success(
'Tags added',
`${added} new link${added === 1 ? '' : 's'}`
)
invalidateTagsAndPhotos()
},
onError: (e: any) =>
toast.error('Add tags failed', e?.message || 'Unknown error'),
})
const bulkRemoveTagsMutation = useMutation({
mutationFn: ({ ids, tagIds }: { ids: string[]; tagIds: string[] }) =>
photosApi.bulkRemoveTags(ids, tagIds),
onSuccess: (data) => {
const removed = data?.removed ?? 0
toast.success(
'Tags removed',
`${removed} link${removed === 1 ? '' : 's'} removed`
)
invalidateTagsAndPhotos()
},
onError: (e: any) =>
toast.error('Remove tags failed', e?.message || 'Unknown error'),
})
// Idempotent create-and-attach: lets the user type a brand-new tag
// name and apply it to the whole selection in one click.
const createAndAttachMutation = useMutation({
mutationFn: async ({ name, ids }: { name: string; ids: string[] }) => {
const created = await tagsApi.create(name)
return photosApi.bulkAddTags(ids, [created.id])
},
onSuccess: () => {
toast.success('Tag created and applied')
invalidateTagsAndPhotos()
},
onError: (e: any) =>
toast.error('Create tag failed', e?.message || 'Unknown error'),
})
const { data: allTags = [] } = useTagsQuery()
const [tagInput, setTagInput] = useState('')
// Active heap membership for the bulk Pick toggle.
const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
@@ -101,12 +150,13 @@ export function RightSidebar() {
const id = activePhotoId ?? selectedPhotos[0]
return (
<div className="flex h-full flex-col bg-surface">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<div className="flex h-12 flex-shrink-0 items-center justify-between border-b border-border px-4">
<h2 className="text-sm font-semibold text-text">Photo Details</h2>
<button
onClick={clearSelection}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear selection"
aria-label="Clear selection"
>
<X className="h-4 w-4" />
</button>
@@ -232,7 +282,154 @@ export function RightSidebar() {
</button>
</div>
</div>
{/* Bulk tags. Click an existing tag chip to apply it to the
* whole selection; long-press / X icon to remove. The text
* input adds an existing tag if it matches a name, or creates
* a new tag and applies it. */}
<div>
<label className="mb-1 block text-xs text-text-muted">Tags</label>
<BulkTagsEditor
allTags={allTags}
tagInput={tagInput}
onTagInputChange={setTagInput}
disabled={
bulkAddTagsMutation.isPending ||
bulkRemoveTagsMutation.isPending ||
createAndAttachMutation.isPending
}
onApply={(tagId) =>
bulkAddTagsMutation.mutate({ ids: selectedPhotos, tagIds: [tagId] })
}
onRemove={(tagId) =>
bulkRemoveTagsMutation.mutate({
ids: selectedPhotos,
tagIds: [tagId],
})
}
onCreate={(name) => {
createAndAttachMutation.mutate({ name, ids: selectedPhotos })
setTagInput('')
}}
/>
</div>
</div>
</div>
)
}
interface BulkTagsEditorProps {
allTags: { id: string; name: string; color: string | null }[]
tagInput: string
onTagInputChange: (value: string) => void
disabled: boolean
onApply: (tagId: string) => void
onRemove: (tagId: string) => void
onCreate: (name: string) => void
}
/**
* Compact bulk tag editor for the multi-select right sidebar. Unlike the
* single-photo TagsEditor we don't show "current tags" — there's no clean
* single-photo notion of that across an arbitrary selection. Instead the
* user picks an existing tag (apply to all) or types a new one (create
* and apply to all).
*/
function BulkTagsEditor({
allTags,
tagInput,
onTagInputChange,
disabled,
onApply,
onRemove,
onCreate,
}: BulkTagsEditorProps) {
const trimmed = tagInput.trim()
const lower = trimmed.toLowerCase()
const filtered = trimmed
? allTags.filter((t) => t.name.toLowerCase().includes(lower))
: allTags
const exactMatch = trimmed
? allTags.find((t) => t.name.toLowerCase() === lower)
: null
const handleSubmit = () => {
if (!trimmed || disabled) return
if (exactMatch) {
onApply(exactMatch.id)
onTagInputChange('')
} else {
onCreate(trimmed)
}
}
return (
<div className="space-y-2">
<input
type="text"
value={tagInput}
onChange={(e) => onTagInputChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
} else if (e.key === 'Escape') {
onTagInputChange('')
}
}}
placeholder="Filter or create…"
disabled={disabled}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text placeholder-text-faint focus:border-primary focus:outline-none disabled:opacity-50"
/>
{trimmed && !exactMatch && (
<button
onClick={handleSubmit}
disabled={disabled}
className="flex w-full items-center justify-center gap-1 rounded border border-dashed border-primary/50 px-2 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
>
<Plus className="h-3 w-3" />
Create "{trimmed}" and apply
</button>
)}
{filtered.length > 0 ? (
<div className="flex max-h-40 flex-wrap gap-1 overflow-y-auto">
{filtered.map((tag) => (
<span
key={tag.id}
className="flex items-center gap-1 rounded bg-surface-2 px-2 py-0.5 text-xs text-text"
style={
tag.color
? { backgroundColor: `${tag.color}33`, color: tag.color }
: undefined
}
>
<button
onClick={() => onApply(tag.id)}
disabled={disabled}
className="hover:underline disabled:opacity-50"
title={`Apply "${tag.name}" to selection`}
>
{tag.name}
</button>
<button
onClick={() => onRemove(tag.id)}
disabled={disabled}
className="rounded p-0.5 opacity-60 hover:bg-surface-offset hover:opacity-100 disabled:opacity-30"
title={`Remove "${tag.name}" from selection`}
aria-label={`Remove ${tag.name} from selection`}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
) : (
<div className="text-xs text-text-faint">No tags match</div>
)}
</div>
)
}

View File

@@ -1,46 +1,17 @@
import { useState, useEffect, useRef } from 'react'
import { Search, X, ShoppingBasket } from 'lucide-react'
import { useFilterStore } from '../../store/filterStore'
import { ShoppingBasket } from 'lucide-react'
import { useHeapsQuery } from '../../hooks/useHeapsQuery'
import muliLogo from '../../assets/muli-logo.png'
const SEARCH_DEBOUNCE_MS = 300
/**
* Slim top bar — just the logo and the active-heap pill. The search input
* lives in the FilterBar now (next to the rest of the filter controls).
*/
export function TopBar() {
// Filter store is the source of truth for search; the input has a local
// mirror so typing stays responsive while we debounce store updates.
const storeQ = useFilterStore((s) => s.q)
const setStoreQ = useFilterStore((s) => s.setQ)
const [searchQuery, setSearchQuery] = useState(storeQ)
// Keep local input in sync if the store is changed externally (URL hydrate,
// active-chip removal, clear-all).
useEffect(() => {
setSearchQuery(storeQ)
}, [storeQ])
// Debounce local input -> store.
const debounceRef = useRef<number | null>(null)
useEffect(() => {
if (searchQuery === storeQ) return
if (debounceRef.current) window.clearTimeout(debounceRef.current)
debounceRef.current = window.setTimeout(() => {
setStoreQ(searchQuery)
}, SEARCH_DEBOUNCE_MS)
return () => {
if (debounceRef.current) window.clearTimeout(debounceRef.current)
}
}, [searchQuery, storeQ, setStoreQ])
// Currently active heap. Shown as a pill so the user always knows where
// their next P-press will land.
const { data: heapsList = [] } = useHeapsQuery()
const activeHeap = heapsList.find((h) => h.is_active)
return (
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
{/* Left — logo + active heap pill */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<img src={muliLogo} alt="Mulita" className="h-7 w-7 object-contain" />
@@ -57,41 +28,6 @@ export function TopBar() {
)}
</div>
{/* Center — search */}
<div className="flex max-w-xl flex-1 items-center px-8">
<div className="relative w-full">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-muted" />
<input
id="topbar-search"
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setSearchQuery('')
setStoreQ('')
e.currentTarget.blur()
}
}}
placeholder="Search photos..."
className="w-full rounded-md border border-border bg-bg py-1.5 pl-9 pr-9 text-sm text-text placeholder-text-muted focus:border-primary focus:outline-none"
/>
{searchQuery && (
<button
onClick={() => {
setSearchQuery('')
setStoreQ('')
}}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear search"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
{/* Right — reserved for future actions */}
<div className="flex items-center gap-2" />
</header>
)

View File

@@ -23,6 +23,10 @@ import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
import { toast } from '../ToastContainer'
import {
COLOR_LABEL_OPTIONS,
type ColorLabel,
} from '../../constants/colorLabels'
interface PhotoTagSummary {
id: string
@@ -47,17 +51,6 @@ interface PhotoDetails {
tags?: PhotoTagSummary[]
}
type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
const COLOR_LABEL_OPTIONS: { value: ColorLabel; className: string }[] = [
{ value: 'red', className: 'bg-red-500' },
{ value: 'orange', className: 'bg-orange-500' },
{ value: 'yellow', className: 'bg-yellow-400' },
{ value: 'green', className: 'bg-green-500' },
{ value: 'blue', className: 'bg-blue-500' },
{ value: 'purple', className: 'bg-purple-500' },
]
interface ExifData {
Make?: string
Model?: string

View File

@@ -137,9 +137,7 @@ function buildItems(
photos.forEach((photo, globalIndex) => {
const dateStr =
sortBy === 'taken_at'
? photo.taken_at
: (photo as any).added_at ?? photo.taken_at
sortBy === 'taken_at' ? photo.taken_at : photo.added_at ?? photo.taken_at
let label: string
if (dateStr) {
try {
@@ -259,13 +257,17 @@ export function Timeline() {
return () => el.removeEventListener('scroll', onScroll)
}, [])
// Find the latest header whose start <= scrollTop. That's the label of
// the group containing whatever is currently at the top of the viewport.
// Find the latest header whose BOTTOM is above the viewport top. That's
// the group whose natural in-grid header has scrolled out of view —
// exactly the case where we want to pin the label as a sticky overlay.
// If the natural header is still visible (scrolled but not yet past),
// we return null and let the in-grid label do the work, avoiding the
// duplicate-label flash.
const stickyLabel = useMemo(() => {
if (headerOffsets.length === 0) return null
let current: string | null = null
for (const h of headerOffsets) {
if (h.offset <= scrollTop) current = h.label
if (h.offset + HEADER_HEIGHT <= scrollTop) current = h.label
else break
}
return current
@@ -424,7 +426,7 @@ export function Timeline() {
* positioned children so it isn't affected by translateY transforms.
* Updates as the user scrolls past month boundaries. */}
{stickyLabel && (
<div className="pointer-events-none absolute left-0 right-0 top-0 z-20 border-b border-border bg-bg/90 px-4 py-1 backdrop-blur-sm">
<div className="pointer-events-none absolute left-0 right-0 top-0 z-20 border-b-2 border-border bg-bg/95 px-4 py-1.5 shadow-sm backdrop-blur">
<h3 className="text-sm font-semibold uppercase tracking-wide text-text">
{stickyLabel}
</h3>

View 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' },
]

View File

@@ -28,9 +28,14 @@ const ALLOWED_SORT_FIELDS: SortField[] = [
]
const ALLOWED_SORT_ORDERS: SortOrder[] = ['asc', 'desc']
function parseUrl(): Partial<FilterState> {
// What parseUrl returns: a partial filter state, plus the optional
// section id (which lives on the store but isn't part of FilterState
// itself). The hydrate action accepts this exact shape.
type HydratePayload = Partial<FilterState> & { currentSection?: string }
function parseUrl(): HydratePayload {
const sp = new URLSearchParams(window.location.search)
const out: Partial<FilterState> = {}
const out: HydratePayload = {}
const q = sp.get('q')
if (q) out.q = q
@@ -83,7 +88,7 @@ function parseUrl(): Partial<FilterState> {
if (groupBy === 'date' || groupBy === 'tag') out.groupBy = groupBy
const section = sp.get('section')
if (section) (out as any).currentSection = section
if (section) out.currentSection = section
const sortBy = sp.get('sort')
if (sortBy && ALLOWED_SORT_FIELDS.includes(sortBy as SortField)) {

View File

@@ -5,6 +5,7 @@ import { photos as photosApi, heaps as heapsApi, type Heap } from '../services/a
import { HEAPS_QUERY_KEY } from './useHeapsQuery'
import { toast } from '../components/ToastContainer'
import { registerUndoable, useUndoStore } from '../store/undoStore'
import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery'
interface KeyboardShortcutsProps {
onToggleLeftSidebar: () => void
@@ -58,6 +59,7 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
const invalidatePhotoQueries = () => {
queryClient.invalidateQueries({ queryKey: ['photo'] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
}
const bulkRatingMutation = useMutation({

View 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,
})
}

View File

@@ -1,6 +1,11 @@
import axios from 'axios'
const API_BASE_URL = 'http://localhost:8001/api/v1'
// Relative API base. In production the nginx in front of the SPA proxies
// /api/ to the backend container; in dev the vite server has the same
// proxy in vite.config.ts. Using a relative URL means requests are
// always same-origin, so the app works whether you hit it from
// localhost, a LAN IP, or a reverse proxy without any CORS dance.
const API_BASE_URL = '/api/v1'
const api = axios.create({
baseURL: API_BASE_URL,
@@ -116,6 +121,28 @@ export const photos = {
return response.data
},
/** Add the listed tags to every listed photo. Idempotent — re-adding
* an existing (photo, tag) pair is a no-op. Returns { added: N }. */
bulkAddTags: async (photoIds: string[], tagIds: string[]) => {
const response = await api.post('/photos/bulk', {
ids: photoIds,
action: 'add_tags',
value: tagIds,
})
return response.data as { status: string; added: number }
},
/** Remove the listed tags from every listed photo. Removing a
* non-member is a no-op. Returns { removed: N }. */
bulkRemoveTags: async (photoIds: string[], tagIds: string[]) => {
const response = await api.post('/photos/bulk', {
ids: photoIds,
action: 'remove_tags',
value: tagIds,
})
return response.data as { status: string; removed: number }
},
/** Move photos into a target folder (or source root). Returns
* { moved, errors[] }. */
move: async (photoIds: string[], targetId: string) => {
@@ -163,12 +190,23 @@ export const library = {
return response.data
},
stats: async () => {
stats: async (): Promise<LibraryStats> => {
const response = await api.get('/library/stats')
return response.data
},
}
export interface LibraryStats {
all_photos: number
rated: number
duplicates: number
discarded: number
total_photos: number
total_videos: number
total_size: number
total_size_gb: number
}
// Heaps API
export interface Heap {
id: string
@@ -202,6 +240,13 @@ export const heaps = {
await api.delete(`/heaps/${heapId}`)
},
/** Duplicate a heap, copying its membership but never marking the new
* one as active. The new heap is named "{name} (copy)". */
duplicate: async (heapId: string): Promise<Heap> => {
const response = await api.post(`/heaps/${heapId}/duplicate`)
return response.data
},
/** Lightweight: just the photo ids in a heap, for client-side membership
* lookups (the basket affordance on thumbnails). */
photoIds: async (heapId: string): Promise<string[]> => {

View File

@@ -1,7 +1,8 @@
import { create } from 'zustand'
import type { ColorLabel } from '../constants/colorLabels'
export type MediaType = 'photo' | 'video' | 'raw' | 'heic'
export type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
export type { ColorLabel }
export type FlagFilter = 'any' | 'discarded'
export type SortField =
| 'taken_at'

View File

@@ -16,7 +16,8 @@ export interface Photo {
is_discarded: boolean
is_duplicate: boolean
file_hash: string
folder_id?: string | null
folder_id: string | null
added_at: string | null
thumb_small?: string
thumb_medium?: string
thumb_large?: string