12 Commits

Author SHA1 Message Date
e7d62c29e1 feat: change empty state 2026-04-08 21:27:56 +02:00
8e00dd40f0 chore: rename app from Mulita to Mulimago
User-visible string change only — TopBar header + browser tab title
+ logo alt text. Container names, internal package names, and
directories keep their existing identifiers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:27:07 +02:00
8db4242503 fix: clear-all sits next to filters with a borderless style
Move the Clear-all button inside the pills flex container so it shares
the cluster's gap and reads as the rightmost item of the filter group
instead of floating between filters and search. Drop the bordered
pill styling for a flat text-button (underline on hover) so it doesn't
look like another active filter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:24:52 +02:00
dea19e5c23 fix: filter bar layout — pills left, search right
Previous layout was [search] [centered pills] [clear-all]. Flip to
[pills left-aligned] [clear-all] [search right]. Pills get a flex-1
slot on the left so they fill the available space and overflow-x-
auto kicks in when they don't fit. Clear-all only renders when any
filter is active and sits between the pills and the search input.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:23:04 +02:00
5a0f9ff592 fix: heap convert dialog supports nested subfolders + visiblePhotoIds loop guard
- HeapConvertDialog: switch the target picker from sourceFolders.list
  (top-level source roots only) to useFolderTreeQuery, flattened
  depth-first into a list with depth info. Each option is indented
  with non-breaking spaces so nested subfolders read as a tree in
  the native dropdown. Backend already accepts any Folder id, so no
  server change needed.
- photoStore.setVisiblePhotoIds: short-circuit when the new id list
  matches the existing one element-for-element. Avoids feedback loops
  if a publisher fires from an effect on a render where the contents
  haven't actually changed (which was triggering React error #185).
- Timeline: pull setVisiblePhotoIds via a focused selector instead of
  the wholesale destructure so the publisher subscription doesn't
  re-render Timeline on unrelated photo store changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:17:32 +02:00
a56062d353 fix: preview navigation walks the timeline's visible order
Previously the preview view walked the raw API photos array for arrow
navigation and the filmstrip. In tag-grouped mode (and any future
layout where the visible grid order diverges from the API sort) that
diverged from the order the user actually saw — they'd hit ← / → and
land on a photo that wasn't adjacent in the grid.

Fix: Timeline publishes its flat visible-order id sequence into the
photo store as visiblePhotoIds whenever its layout items change
(including duplicates from tag buckets, which is what the user wants
in tag mode — landing on a photo's second appearance in the next
bucket is the right behavior). PreviewView resolves that sequence
back to Photo objects via the rawPhotos map and uses the result for
both arrow nav and the filmstrip. Falls back to the raw photos list
when the sequence isn't populated yet.

Also clean up the lingering hardcoded http://localhost:8001 in
usePhotosQuery — switched to the shared axios instance with the
relative /api/v1 baseURL so the hook works cross-machine through the
nginx / vite proxy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:13:08 +02:00
7a0f738aa8 fix: keep Rating + Tags pills in their sections, only hide Flag in Discarded
Rated and Tags sections still benefit from their respective pill —
Rating because the user can refine the section's ratingMin >= 1 to a
higher floor, Tags because they can intersect the tag-grouped view
with a specific tag id list. Flag in Discarded is the only pill where
the section locks the only useful value, so it stays hidden there.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:07:26 +02:00
d7f953d0a9 fix: hide section-locked filter pills in their own sections
Each section's preset locks one filter dimension that defines the
section: Rated → ratingMin, Discarded → flag, Tags → groupBy=tag.
Showing the matching pill in the toolbar while you're inside that
section is either redundant (it's already on) or actively breaks the
view (toggling it would either become a no-op or filter the section
into one bucket).

Hide the corresponding pill in each section: Rating in Rated, Flag in
Discarded, Tags in Tags. The user navigates away to a different
section to change the locked dimension.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:04:45 +02:00
30e0900e49 fix: lock filter bar height + freeze pill positions
- FilterPill: fixed h-7 + py-0 on the button so neither the X clear icon
  nor the chevron can stretch the pill vertically when active state
  swaps them in. The chevron is now wrapped in the same h-4 w-4 slot as
  the clear X so swapping doesn't change footprint horizontally either.
- FilterBar: fixed h-11 on the bar itself so any future per-pill drift
  can't grow the row.
- Clear-all: wrapped in a fixed w-56 right slot that mirrors the search
  input on the left. The pill cluster sits in the centered flex-1
  middle slot, so it stays perfectly centered whether or not Clear-all
  is rendered. The button itself is right-aligned within the slot.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:01:01 +02:00
c01c3b02ce chore: use the Mulita logo as the browser favicon
Drop the leftover Vite default and point the favicon + apple-touch-icon
at a copy of the existing muli-logo.png served from /public. Also
trim the page title to "Mulita" and add a dark theme-color meta tag
so mobile browsers paint the chrome to match the app.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:58:13 +02:00
4e5b2cabf6 fix: align sidebar counts in a single right column
The previous count-alignment fix used invisible group-hover:visible for
hover-only buttons, but invisible still reserves layout space. Folder
rows had a permanent kebab slot that non-folder rows didn't, and active
heap rows had a Target indicator before the count — both shifted their
counts left of the rest. The result was visually misaligned counts.

- LeftSidebar folder kebab + HeapsPanel kebab/set-active: switch to
  hidden group-hover:block so the slot occupies zero width in the
  resting state. Counts now sit at the same right edge across folder,
  non-folder, and heap rows.
- HeapsPanel: drop the standalone Target indicator from active heap
  rows. Active state is signaled by the bold name (font-semibold)
  already, and removing the indicator lets the heap count column line
  up with everything else.
- Both kebab wrappers also use hidden group-hover:block on the wrapper
  div so the menu trigger truly takes 0 width when not hovered.

On hover the kebab appears to the right of the count and pushes it
slightly left, as the user requested ("on hover we can push them to
make space for the burger").

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:57:11 +02:00
f05ae77ef0 feat: folder CRUD with discard-or-delete dialog
The left sidebar can now create, rename, and delete folders. Each
operation is mirrored to disk through the backend.

Backend (folders router):
- POST /folders { name, parent_id } — create a sub-folder under an
  existing Folder row, mkdir on disk, insert the row, return it. Names
  are validated (no separators, no traversal).
- PATCH /folders/{id} extended — still does the display-only rename for
  SourceRoot ids, but for Folder ids it now actually moves the directory
  on disk and rewrites every descendant Folder.path + Photo.filepath
  that lived under the old prefix in a single transaction. Refuses to
  rename the source-root mount itself.
- DELETE /folders/{id}?mode=discard|permanent —
    discard: set is_discarded on every photo whose filepath lives under
             this folder. The folder, descendants, and on-disk dir are
             left intact. Recoverable from the discard pile.
    permanent: unlink each file, remove rows, rmtree the directory.
- Refuses to delete the source-root mount in either mode.

Frontend:
- New DeleteFolderDialog: two-card mode picker (Move to discard pile /
  Permanently delete) with destructive accent on the latter. Esc and
  backdrop click cancel.
- LeftSidebar: hover-revealed kebab menu on every folder row with
  New sub-folder, Rename, and Delete folder…  Inline create input
  appears below the parent row when "New sub-folder" is picked.
  All mutations invalidate ['folders'], ['photos'], and the library
  stats query so the sidebar counts stay live.
- api.ts: sourceFolders.create + sourceFolders.delete wrappers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:51:23 +02:00
16 changed files with 988 additions and 191 deletions

View File

@@ -1,24 +1,48 @@
"""
Folders API router. Source roots themselves are config-driven (PHOTO_DIRS
in .env → backend bootstrap on startup) — adding or removing one is a
docker-compose change. The UI can read the list, trigger a manual rescan,
and rename the display label, but it can't change the on-disk path.
docker-compose change. Sub-folders inside a source root can be created,
renamed, and deleted from the UI; those changes are mirrored to disk.
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
import logging
import os
import shutil
from typing import Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select, func, update as sql_update, delete as sql_delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Folder, SourceRoot, Photo
logger = logging.getLogger(__name__)
router = APIRouter()
class FolderRename(BaseModel):
name: str
class FolderCreate(BaseModel):
name: str
parent_id: str # Folder.id (NOT a SourceRoot id)
def _validate_folder_name(name: str) -> str:
"""Trim + sanity-check a folder name. Rejects names that contain a
path separator or that resolve to a parent traversal — those would
let the user escape the parent directory through this endpoint.
"""
name = (name or '').strip()
if not name:
raise HTTPException(status_code=400, detail="Name cannot be empty")
if '/' in name or '\\' in name or name in ('.', '..'):
raise HTTPException(status_code=400, detail="Invalid folder name")
return name
@router.get("")
async def get_folders(db: AsyncSession = Depends(get_db)):
"""Get all source folders"""
@@ -161,20 +185,242 @@ async def rename_folder(
body: FolderRename,
db: AsyncSession = Depends(get_db),
):
"""Rename a source root's display label. Does NOT touch the on-disk
path — that's controlled by the docker mount."""
name = (body.name or '').strip()
if not name:
raise HTTPException(status_code=400, detail="Name cannot be empty")
"""Rename a folder. Two cases:
result = await db.execute(select(SourceRoot).where(SourceRoot.id == folder_id))
source_root = result.scalar_one_or_none()
if not source_root:
raise HTTPException(status_code=404, detail="Source folder not found")
- SourceRoot id → just change the display label. The on-disk path
is owned by the docker mount and never moves.
- Folder id → rename the directory on disk AND update every
descendant Folder.path + Photo.filepath that
lived under the old prefix. Refuses to rename
the source-root folder itself (= the row that
matches the SourceRoot.path) because that would
require renaming the docker mount.
"""
name = _validate_folder_name(body.name)
# Try SourceRoot first (display-only rename).
sr_result = await db.execute(
select(SourceRoot).where(SourceRoot.id == folder_id)
)
source_root = sr_result.scalar_one_or_none()
if source_root:
source_root.name = name
await db.commit()
return {
"id": source_root.id,
"name": source_root.name,
"path": source_root.path,
}
# Otherwise it's a Folder row.
folder_result = await db.execute(select(Folder).where(Folder.id == folder_id))
folder = folder_result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
# Refuse to rename the bare source root mount through here.
sr_check = await db.execute(
select(SourceRoot).where(SourceRoot.id == folder.source_root_id)
)
sr = sr_check.scalar_one_or_none()
if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path):
raise HTTPException(
status_code=400,
detail="Cannot rename the source root mount; rename the docker mount instead.",
)
old_path = os.path.normpath(folder.path).rstrip(os.sep)
parent_dir = os.path.dirname(old_path)
new_path = os.path.join(parent_dir, name)
if os.path.exists(new_path):
raise HTTPException(
status_code=400,
detail=f"A folder named '{name}' already exists here",
)
try:
shutil.move(old_path, new_path)
except OSError as e:
raise HTTPException(status_code=500, detail=f"Rename failed: {e}")
# Update folder paths: this row + every descendant. SQLite REPLACE
# rewrites the prefix; we use the trailing separator on the LIKE
# pattern so a folder named "foo" doesn't accidentally match "foobar".
await db.execute(
sql_update(Folder)
.where(Folder.id == folder.id)
.values(path=new_path, name=name)
)
descendant_prefix = old_path + os.sep
descendants = await db.execute(
select(Folder).where(Folder.path.like(descendant_prefix + '%'))
)
for d in descendants.scalars().all():
d.path = new_path + d.path[len(old_path):]
# Update every photo whose filepath lives under the old prefix.
photos_result = await db.execute(
select(Photo).where(Photo.filepath.like(descendant_prefix + '%'))
)
for p in photos_result.scalars().all():
p.filepath = new_path + p.filepath[len(old_path):]
# Photos directly inside this folder (not in a subdir) won't match
# the descendant_prefix LIKE if their old path was old_path + '/file'
# — actually they DO match, since 'oldpath/file' starts with
# 'oldpath/'. So the loop above already covers them.
source_root.name = name
await db.commit()
return {"id": source_root.id, "name": source_root.name, "path": source_root.path}
return {
"id": folder.id,
"name": folder.name,
"path": folder.path,
}
@router.post("", status_code=201)
async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db)):
"""Create a new sub-folder under an existing Folder. Mirrors the
create to disk so the next scan sees it. Body: { name, parent_id }.
parent_id MUST be an existing Folder row id (any descendant of a
source root); creating a brand-new top-level mount is a docker
operation, not a UI one.
"""
name = _validate_folder_name(body.name)
parent_result = await db.execute(
select(Folder).where(Folder.id == body.parent_id)
)
parent = parent_result.scalar_one_or_none()
if not parent:
raise HTTPException(status_code=404, detail="Parent folder not found")
new_path = os.path.join(parent.path, name)
if os.path.exists(new_path):
raise HTTPException(
status_code=400,
detail=f"A folder named '{name}' already exists here",
)
try:
os.makedirs(new_path, exist_ok=False)
except OSError as e:
raise HTTPException(status_code=500, detail=f"Create failed: {e}")
new_folder = Folder(
name=name,
path=new_path,
source_root_id=parent.source_root_id,
photo_count=0,
)
db.add(new_folder)
await db.commit()
await db.refresh(new_folder)
return {
"id": new_folder.id,
"name": new_folder.name,
"path": new_folder.path,
"parent_id": parent.id,
}
@router.delete("/{folder_id}")
async def delete_folder(
folder_id: str,
mode: Literal['discard', 'permanent'] = Query('discard'),
db: AsyncSession = Depends(get_db),
):
"""Delete a folder. Behavior depends on mode:
- mode=discard (default): mark every photo whose filepath lives
under this folder as is_discarded=true. The folder row, its
descendant rows, and the on-disk directory are LEFT INTACT —
the user can still recover photos from the discard pile, and
a re-scan won't double-import them.
- mode=permanent: unlink every photo file under this folder,
remove the photo + folder rows from the DB, and rmtree the
on-disk directory. Irreversible.
Refuses to delete the bare source-root mount in either mode (deleting
the docker mount through the UI would be a footgun).
"""
folder_result = await db.execute(select(Folder).where(Folder.id == folder_id))
folder = folder_result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
sr_check = await db.execute(
select(SourceRoot).where(SourceRoot.id == folder.source_root_id)
)
sr = sr_check.scalar_one_or_none()
if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path):
raise HTTPException(
status_code=400,
detail="Cannot delete the source root mount through the UI",
)
folder_path = os.path.normpath(folder.path).rstrip(os.sep)
descendant_prefix = folder_path + os.sep
# Collect every photo under this folder OR any descendant. We match
# by filepath prefix instead of folder_id because that catches photos
# in nested subfolders without a recursive folder walk.
photos_result = await db.execute(
select(Photo).where(
(Photo.filepath == folder_path)
| (Photo.filepath.like(descendant_prefix + '%'))
)
)
photos = photos_result.scalars().all()
if mode == 'discard':
from datetime import datetime
now = datetime.utcnow()
for p in photos:
p.is_discarded = True
p.discarded_at = now
await db.commit()
return {
"status": "success",
"mode": "discard",
"discarded": len(photos),
}
# mode == 'permanent'
file_errors = 0
for p in photos:
try:
if p.filepath and os.path.exists(p.filepath):
os.unlink(p.filepath)
except OSError as e:
file_errors += 1
logger.error(f"Failed to unlink {p.filepath}: {e}")
await db.delete(p)
# Delete this folder + every descendant Folder row.
await db.execute(
sql_delete(Folder).where(
(Folder.id == folder.id)
| (Folder.path.like(descendant_prefix + '%'))
)
)
try:
if os.path.isdir(folder_path):
shutil.rmtree(folder_path)
except OSError as e:
logger.error(f"Failed to rmtree {folder_path}: {e}")
# Don't raise — DB rows are already gone, leaving an orphan
# directory is the lesser evil.
await db.commit()
return {
"status": "success",
"mode": "permanent",
"deleted_photos": len(photos),
"file_errors": file_errors,
}
@router.post("/{folder_id}/scan")

View File

@@ -2,9 +2,11 @@
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mulita - Photo Management</title>
<meta name="theme-color" content="#0f0f0f" />
<title>Mulimago</title>
</head>
<body>
<div id="root"></div>

BIN
frontend/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

View File

@@ -0,0 +1,164 @@
import { useEffect, useState } from 'react'
import clsx from 'clsx'
import { Trash2, Archive } from 'lucide-react'
interface DeleteFolderDialogProps {
isOpen: boolean
folderName: string
/** Number of photos under this folder, including descendants. Surfaced
* in the dialog copy so the user understands the blast radius. */
photoCount?: number
onClose: () => void
/** Called with the chosen mode when the user confirms. */
onConfirm: (mode: 'discard' | 'permanent') => void
}
/**
* Two-mode folder delete dialog:
*
* - Move to discard pile (default, soft, recoverable)
* - Permanently delete (destructive, irreversible)
*
* The user picks a mode via the radio cards then clicks Delete. Esc /
* backdrop click cancels.
*/
export function DeleteFolderDialog({
isOpen,
folderName,
photoCount,
onClose,
onConfirm,
}: DeleteFolderDialogProps) {
const [mode, setMode] = useState<'discard' | 'permanent'>('discard')
// Reset mode when re-opening so the safe option is always the default.
useEffect(() => {
if (isOpen) setMode('discard')
}, [isOpen])
// Esc to close.
useEffect(() => {
if (!isOpen) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [isOpen, onClose])
if (!isOpen) return null
const photoBlurb =
photoCount === undefined
? 'photos in this folder'
: photoCount === 0
? 'this empty folder'
: `${photoCount} photo${photoCount === 1 ? '' : 's'} in this folder`
return (
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="relative z-10 w-[420px] rounded-lg border border-border bg-surface p-5 shadow-2xl">
<h2 className="mb-1 text-base font-semibold text-text">
Delete folder "{folderName}"?
</h2>
<p className="mb-4 text-sm text-text-muted">
What should happen to {photoBlurb}?
</p>
<div className="space-y-2">
<ModeCard
icon={<Archive className="h-4 w-4" />}
title="Move photos to discard pile"
description="Photos can be restored later from Discarded. The folder and files stay on disk."
selected={mode === 'discard'}
onClick={() => setMode('discard')}
/>
<ModeCard
icon={<Trash2 className="h-4 w-4" />}
title="Permanently delete folder and photos"
description="Removes the folder, every photo inside it, and the directory from disk. This cannot be undone."
selected={mode === 'permanent'}
destructive
onClick={() => setMode('permanent')}
/>
</div>
<div className="mt-5 flex justify-end gap-2">
<button
onClick={onClose}
className="rounded border border-border px-3 py-1.5 text-sm text-text hover:bg-surface-2"
>
Cancel
</button>
<button
onClick={() => onConfirm(mode)}
className={clsx(
'rounded px-3 py-1.5 text-sm font-medium text-white',
mode === 'permanent'
? 'bg-reject hover:bg-reject/80'
: 'bg-primary hover:bg-primary/80'
)}
>
{mode === 'permanent' ? 'Delete forever' : 'Move to discard pile'}
</button>
</div>
</div>
</div>
</div>
)
}
function ModeCard({
icon,
title,
description,
selected,
destructive = false,
onClick,
}: {
icon: React.ReactNode
title: string
description: string
selected: boolean
destructive?: boolean
onClick: () => void
}) {
return (
<button
onClick={onClick}
className={clsx(
'flex w-full gap-3 rounded-lg border p-3 text-left transition-colors',
selected
? destructive
? 'border-reject/60 bg-reject/10'
: 'border-primary/60 bg-primary/10'
: 'border-border bg-surface-2 hover:bg-surface-offset'
)}
>
<div
className={clsx(
'mt-0.5 flex-shrink-0',
selected ? (destructive ? 'text-reject' : 'text-primary') : 'text-text-muted'
)}
>
{icon}
</div>
<div className="flex-1">
<div
className={clsx(
'text-sm font-medium',
selected ? (destructive ? 'text-reject' : 'text-primary') : 'text-text'
)}
>
{title}
</div>
<div className="mt-0.5 text-xs text-text-muted">{description}</div>
</div>
</button>
)
}

View File

@@ -45,6 +45,14 @@ export function FilterBar() {
const sortBy = useFilterStore((s) => s.sortBy)
const sortOrder = useFilterStore((s) => s.sortOrder)
const tagIds = useFilterStore((s) => s.tagIds)
const currentSection = useFilterStore((s) => s.currentSection)
// Only the Flag pill is hidden inside the Discarded section. Flag has
// exactly two values and the section locks one of them, so the pill
// would only ever toggle the section off — useless. Rating + Tags
// pills stay visible in their sections because the user can refine
// them further (ratingMin >= 3, restrict to specific tag ids).
const hideFlagPill = currentSection === 'discarded'
const setDateFrom = useFilterStore((s) => s.setDateFrom)
const setDateTo = useFilterStore((s) => s.setDateTo)
@@ -116,43 +124,12 @@ export function FilterBar() {
const anyActive = hasActiveFilters(filterState)
return (
<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">
// Fixed bar height + py-0 so neither the active filter pills nor the
// clear-all button can stretch the bar vertically. The fixed h-11
// matches the h-7 pills + 8px symmetric vertical padding.
<div className="flex h-11 items-center gap-3 border-b border-border bg-surface px-3 py-0">
{/* Pills — left side, scroll horizontally if they overflow. */}
<div className="flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto">
{/* Date */}
<FilterPill
label="Date"
@@ -279,38 +256,41 @@ export function FilterBar() {
</div>
</FilterPill>
{/* Flag — discarded toggle */}
<FilterPill
label="Flag"
value={flagValue}
isActive={flagActive}
onClear={() => setFlag('any')}
>
<div className="flex flex-col gap-1">
<button
onClick={() => setFlag('any')}
className={clsx(
'rounded px-2 py-1 text-left text-xs transition-colors',
flag === 'any'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
Any
</button>
<button
onClick={() => setFlag('discarded')}
className={clsx(
'rounded px-2 py-1 text-left text-xs transition-colors',
flag === 'discarded'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
Discarded
</button>
</div>
</FilterPill>
{/* Flag — hidden in the Discarded section, where the flag is
* pinned to "discarded" by the section preset. */}
{!hideFlagPill && (
<FilterPill
label="Flag"
value={flagValue}
isActive={flagActive}
onClear={() => setFlag('any')}
>
<div className="flex flex-col gap-1">
<button
onClick={() => setFlag('any')}
className={clsx(
'rounded px-2 py-1 text-left text-xs transition-colors',
flag === 'any'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
Any
</button>
<button
onClick={() => setFlag('discarded')}
className={clsx(
'rounded px-2 py-1 text-left text-xs transition-colors',
flag === 'discarded'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
Discarded
</button>
</div>
</FilterPill>
)}
{/* Tags */}
{allTags.length > 0 && (
@@ -375,18 +355,55 @@ export function FilterBar() {
</button>
</div>
</FilterPill>
</div>
{/* Clear-all — pinned right of the pill cluster. */}
{/* Clear-all — borderless text affordance pinned next to the pill
* cluster on the right. Lives inside the pills container so it
* shares the same flex group and gap and reads as "another
* pill". Only renders when any filter is active. */}
{anyActive && (
<button
onClick={clearAll}
className="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"
className="ml-1 flex h-7 flex-shrink-0 items-center whitespace-nowrap px-1 text-xs text-text-muted underline-offset-2 hover:text-text hover:underline"
title="Clear all filters in this section"
>
Clear all
</button>
)}
</div>
{/* Search — pinned to the right edge of the bar. Same id as before
* so the global "/" focus shortcut still finds it. */}
<div className="relative w-56 flex-shrink-0">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted" />
<input
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>
</div>
)
}

View File

@@ -100,7 +100,10 @@ export function FilterPill({
// 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',
// Fixed height + py-0 so neither the X clear icon nor the
// chevron can stretch the pill vertically when the active
// state swaps them in.
'flex h-7 items-center gap-1 rounded-full border px-2.5 py-0 text-xs transition-colors',
isActive
? 'border-primary/40 bg-primary/15 text-primary'
: 'border-border bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
@@ -122,14 +125,18 @@ export function FilterPill({
onClear()
}
}}
className="ml-1 inline-flex h-5 w-5 cursor-pointer items-center justify-center rounded-full hover:bg-primary/30"
// Same h-4 w-4 as the chevron slot below so swapping the
// two doesn't change the pill's footprint.
className="ml-0.5 inline-flex h-4 w-4 cursor-pointer items-center justify-center rounded-full hover:bg-primary/30"
title={`Clear ${label}`}
aria-label={`Clear ${label}`}
>
<X className="h-3 w-3" />
</span>
) : (
<ChevronDown className="h-3 w-3 opacity-60" />
<span className="inline-flex h-4 w-4 items-center justify-center">
<ChevronDown className="h-3 w-3 opacity-60" />
</span>
)}
</button>

View File

@@ -1,11 +1,37 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState, useEffect, useMemo } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { X, Folder, AlertCircle } from 'lucide-react'
import clsx from 'clsx'
import { sourceFolders, heaps as heapsApi, type Heap } from '../../services/api'
import {
heaps as heapsApi,
type Heap,
type FolderTreeNode,
} from '../../services/api'
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
import { toast } from '../ToastContainer'
interface FlatFolder {
id: string
name: string
path: string
depth: number
}
/** Walk the folder tree depth-first into a flat list with depth info so
* the picker can render every node — including nested subfolders — as
* one indented option. */
function flattenTree(nodes: FolderTreeNode[], depth = 0): FlatFolder[] {
const out: FlatFolder[] = []
for (const n of nodes) {
out.push({ id: n.id, name: n.name, path: n.path, depth })
if (n.children.length > 0) {
out.push(...flattenTree(n.children, depth + 1))
}
}
return out
}
interface HeapConvertDialogProps {
heap: Heap | null
onClose: () => void
@@ -23,12 +49,10 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
const [deleteHeap, setDeleteHeap] = useState(false)
const [subfolderName, setSubfolderName] = useState('')
const { data: foldersData } = useQuery({
queryKey: ['folders'],
queryFn: sourceFolders.list,
enabled: !!heap,
})
const folders = foldersData?.folders ?? []
// Use the recursive folder tree, not the flat source-root list, so the
// user can pick a sub-folder at any depth as the target.
const { data: tree = [] } = useFolderTreeQuery()
const folders = useMemo<FlatFolder[]>(() => flattenTree(tree), [tree])
// Default to the first folder when the dialog opens or folders load.
useEffect(() => {
@@ -77,7 +101,7 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
if (!heap) return null
const targetFolder = folders.find((f: any) => f.id === targetId)
const targetFolder = folders.find((f) => f.id === targetId)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
@@ -110,9 +134,11 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
onChange={(e) => setTargetId(e.target.value)}
className="w-full rounded border border-border bg-bg px-2 py-1.5 text-sm text-text focus:border-primary focus:outline-none"
>
{folders.map((f: any) => (
{folders.map((f) => (
<option key={f.id} value={f.id}>
{f.name}
{/* Two non-breaking spaces per depth so nested
* subfolders read as a tree in the native dropdown. */}
{'\u00A0\u00A0'.repeat(f.depth) + f.name}
</option>
))}
</select>

View File

@@ -338,17 +338,13 @@ export function HeapsPanel() {
</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 flex-shrink-0 text-primary"
aria-label="Active heap (T target)"
/>
)}
{/* Right cluster. Count is the rightmost element in the
* resting state — set-active and kebab use display:none
* (not invisible) so they reserve no width until hover,
* keeping the count column aligned with the rest of the
* sidebar. The active heap is signaled by font-semibold
* on the name above; the standalone Target indicator
* was making heap counts sit left of the others. */}
{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}
@@ -362,7 +358,7 @@ export function HeapsPanel() {
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"
className="hidden flex-shrink-0 rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:block"
title="Set as active heap (T target)"
aria-label="Set as active heap"
>
@@ -372,16 +368,18 @@ export function HeapsPanel() {
{/* Kebab menu — collects rename / duplicate / convert /
* delete so the row stays compact. */}
<div className="relative flex-shrink-0">
<div
className={clsx(
'relative flex-shrink-0',
isMenuOpen ? 'block' : 'hidden group-hover:block'
)}
>
<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'
)}
className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
title="More actions"
aria-label="More heap actions"
aria-haspopup="menu"

View File

@@ -1,8 +1,9 @@
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import {
ChevronRight,
ChevronDown,
Folder,
FolderPlus,
Image,
Star,
Trash2,
@@ -11,6 +12,8 @@ import {
Copy,
Tag as TagIcon,
Layers2,
MoreHorizontal,
Pencil,
} from 'lucide-react'
import clsx from 'clsx'
import { sourceFolders, library, photos as photosApi, type FolderTreeNode } from '../../services/api'
@@ -27,6 +30,7 @@ import {
} from '../../hooks/useLibraryStatsQuery'
import { registerUndoable } from '../../store/undoStore'
import type { Photo } from '../../types/photo'
import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog'
interface TreeItem {
id: string
@@ -52,6 +56,40 @@ export function LeftSidebar() {
const { data: stats } = useLibraryStatsQuery()
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
// Per-folder kebab menu open state. Stores the tree-item id ("folder-..."
// or "folders" for the section header). Outside-click + Escape close.
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!openMenuId) return
const onDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpenMenuId(null)
}
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpenMenuId(null)
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [openMenuId])
// "Create new folder under {parent}" inline state. parentId is the
// Folder.id (no "folder-" prefix).
const [creatingUnder, setCreatingUnder] = useState<string | null>(null)
const [createDraft, setCreateDraft] = useState('')
// Folder being deleted, drives the DeleteFolderDialog mounted below.
const [deletingFolder, setDeletingFolder] = useState<{
id: string
name: string
photoCount?: number
} | null>(null)
// Bulk discard mutation for the drag-onto-Discarded interaction.
const discardDropMutation = useMutation({
mutationFn: (photoIds: string[]) => photosApi.bulkDiscard(photoIds),
@@ -217,11 +255,55 @@ export function LeftSidebar() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['folders'] })
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
},
onError: (e: any) =>
toast.error('Rename failed', e?.response?.data?.detail || e.message || 'Unknown error'),
})
const createFolderMutation = useMutation({
mutationFn: ({ parentId, name }: { parentId: string; name: string }) =>
sourceFolders.create(parentId, name),
onSuccess: (data) => {
toast.success('Folder created', data.name)
queryClient.invalidateQueries({ queryKey: ['folders'] })
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
setCreatingUnder(null)
setCreateDraft('')
},
onError: (e: any) =>
toast.error('Create failed', e?.response?.data?.detail || e.message || 'Unknown error'),
})
const deleteFolderMutation = useMutation({
mutationFn: ({ id, mode }: { id: string; mode: 'discard' | 'permanent' }) =>
sourceFolders.delete(id, mode),
onSuccess: (data) => {
if (data.mode === 'discard') {
toast.success(
'Folder photos discarded',
`${data.discarded ?? 0} moved to discard pile`
)
} else {
toast.success(
'Folder deleted',
`${data.deleted_photos ?? 0} photos removed from disk`
)
}
queryClient.invalidateQueries({ queryKey: ['folders'] })
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
// If we were viewing the deleted folder, snap back to all-photos.
if (deletingFolder && currentSection === `folder-${deletingFolder.id}`) {
navigateToSection('all-photos', {})
}
setDeletingFolder(null)
},
onError: (e: any) =>
toast.error('Delete failed', e?.response?.data?.detail || e.message || 'Unknown error'),
})
// Mutation for scanning all folders
const scanLibraryMutation = useMutation({
mutationFn: library.scan,
@@ -455,8 +537,131 @@ export function LeftSidebar() {
<span className="h-5 min-w-[24px] flex-shrink-0" aria-hidden="true" />
)}
{/* Folder kebab menu — only on folder rows. Hidden (display:none)
* until hover so it reserves NO width in the resting state and
* the count column stays aligned across folder + non-folder
* rows. On hover it appears to the right, pushing the count
* left to make room. */}
{item.id.startsWith('folder-') &&
(() => {
const folderId = item.id.slice('folder-'.length)
const isMenuOpen = openMenuId === item.id
return (
<div
className={clsx(
'relative flex-shrink-0',
isMenuOpen ? 'block' : 'hidden group-hover:block'
)}
>
<button
onClick={(e) => {
e.stopPropagation()
setOpenMenuId(isMenuOpen ? null : item.id)
}}
className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
title="More actions"
aria-label="More folder actions"
aria-haspopup="menu"
aria-expanded={isMenuOpen}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
{isMenuOpen && (
<div
ref={menuRef}
role="menu"
onClick={(e) => e.stopPropagation()}
className="absolute right-0 top-full z-30 mt-1 min-w-[180px] overflow-hidden rounded-lg border border-border bg-surface py-1 text-sm shadow-xl"
>
<FolderMenuItem
icon={<FolderPlus className="h-3.5 w-3.5" />}
label="New sub-folder"
onClick={() => {
setOpenMenuId(null)
setCreatingUnder(folderId)
setCreateDraft('')
// Make sure the parent is expanded so the new
// input is visible.
if (!expandedItems.has(item.id)) {
toggleExpanded(item.id)
}
}}
/>
<FolderMenuItem
icon={<Pencil className="h-3.5 w-3.5" />}
label="Rename"
onClick={() => {
setOpenMenuId(null)
setRenamingId(item.id)
setRenameDraft(item.label)
}}
/>
<div className="my-1 h-px bg-border" />
<FolderMenuItem
icon={<Trash2 className="h-3.5 w-3.5" />}
label="Delete folder…"
destructive
onClick={() => {
setOpenMenuId(null)
setDeletingFolder({
id: folderId,
name: item.label,
photoCount: item.count,
})
}}
/>
</div>
)}
</div>
)
})()}
</div>
{/* Inline "create new sub-folder" input. Renders just below the
* parent row when its create state is active. */}
{item.id.startsWith('folder-') &&
creatingUnder === item.id.slice('folder-'.length) && (
<div
className="flex items-center gap-1 px-2 py-1"
style={{ paddingLeft: `${8 + (depth + 1) * 16 + 4}px` }}
>
<FolderPlus className="h-3 w-3 flex-shrink-0 text-text-muted" />
<input
autoFocus
type="text"
value={createDraft}
placeholder="New folder name"
onChange={(e) => setCreateDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const name = createDraft.trim()
if (name) {
createFolderMutation.mutate({
parentId: item.id.slice('folder-'.length),
name,
})
}
} else if (e.key === 'Escape') {
setCreatingUnder(null)
setCreateDraft('')
}
}}
onBlur={() => {
// Don't auto-commit on blur — empty/escaped renames
// close the input but don't fire the request.
if (!createFolderMutation.isPending) {
setCreatingUnder(null)
setCreateDraft('')
}
}}
className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none"
/>
</div>
)}
{/* Render Children */}
{hasChildren && isExpanded && (
<div>
@@ -488,6 +693,46 @@ export function LeftSidebar() {
</button>
</div>
)}
<DeleteFolderDialog
isOpen={!!deletingFolder}
folderName={deletingFolder?.name ?? ''}
photoCount={deletingFolder?.photoCount}
onClose={() => setDeletingFolder(null)}
onConfirm={(mode) => {
if (deletingFolder) {
deleteFolderMutation.mutate({ id: deletingFolder.id, mode })
}
}}
/>
</div>
)
}
function FolderMenuItem({
icon,
label,
onClick,
destructive = false,
}: {
icon: React.ReactNode
label: string
onClick: () => void
destructive?: boolean
}) {
return (
<button
role="menuitem"
onClick={onClick}
className={clsx(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors',
destructive
? 'text-reject hover:bg-reject/10'
: 'text-text hover:bg-surface-2'
)}
>
<span className="text-text-muted">{icon}</span>
{label}
</button>
)
}

View File

@@ -150,8 +150,8 @@ export function RightSidebar() {
const id = activePhotoId ?? selectedPhotos[0]
return (
<div className="flex h-full flex-col bg-surface">
<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>
<div className="flex h-11 flex-shrink-0 items-center justify-between border-b border-border px-4">
<h2 className="text-sm font-semibold text-text">Metadata</h2>
<button
onClick={clearSelection}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"

View File

@@ -14,8 +14,8 @@ export function TopBar() {
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
<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" />
<h1 className="text-lg font-semibold text-text">Mulita</h1>
<img src={muliLogo} alt="Mulimago" className="h-7 w-7 object-contain" />
<h1 className="text-lg font-semibold text-text">Mulimago</h1>
</div>
{activeHeap && (
<span

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { X, Info } from 'lucide-react'
import { usePhotoStore } from '../../store/photoStore'
@@ -13,6 +13,7 @@ export function PreviewView() {
const activePhotoId = usePhotoStore((s) => s.activePhotoId)
const setActivePhoto = usePhotoStore((s) => s.setActivePhoto)
const closePreview = usePhotoStore((s) => s.closePreview)
const visiblePhotoIds = usePhotoStore((s) => s.visiblePhotoIds)
const containerRef = useRef<HTMLDivElement>(null)
const previouslyFocusedRef = useRef<HTMLElement | null>(null)
@@ -20,7 +21,23 @@ export function PreviewView() {
// Same hook Timeline uses, so we share one cache entry rather than looking
// it up by key (which broke when the key gained the filter params).
const { data: photos = [] } = usePhotosQuery()
const { data: rawPhotos = [] } = usePhotosQuery()
// Walk the timeline's visible-order sequence (published by Timeline
// into the photo store), which respects tag-grouping and any other
// grid-layout rearrangement. Falls back to the raw photos list when
// the sequence isn't populated yet — relevant on a fresh page load
// where the user opened preview before the timeline mounted.
const photos: Photo[] = useMemo(() => {
if (visiblePhotoIds.length === 0) return rawPhotos
const byId = new Map(rawPhotos.map((p) => [p.id, p]))
const out: Photo[] = []
for (const id of visiblePhotoIds) {
const p = byId.get(id)
if (p) out.push(p)
}
return out
}, [visiblePhotoIds, rawPhotos])
const currentIndex = activePhotoId
? photos.findIndex((p) => p.id === activePhotoId)

View File

@@ -173,6 +173,9 @@ export function Timeline() {
clearSelection,
openPreview,
} = usePhotoStore()
// Pulled via a focused selector so the publisher subscription doesn't
// re-render Timeline on every unrelated photo store change.
const setVisiblePhotoIds = usePhotoStore((s) => s.setVisiblePhotoIds)
const sortBy = useFilterStore((s) => s.sortBy)
const groupBy = useFilterStore((s) => s.groupBy)
@@ -294,6 +297,21 @@ export function Timeline() {
[items]
)
// Publish the flat visible-order id sequence to the photo store so
// PreviewView arrow nav (and the filmstrip) walks the same order the
// user sees in the grid. Includes duplicates from tag grouping —
// landing on the same photo's "second" appearance in the next tag
// bucket is the right behavior in tag mode.
useEffect(() => {
const ids: string[] = []
for (const row of photoRows) {
for (const cell of row.cells) {
ids.push(cell.photo.id)
}
}
setVisiblePhotoIds(ids)
}, [photoRows, setVisiblePhotoIds])
// Locate the active photo in the visual grid. Returns the FIRST
// (rowIndex, colIndex) where its id appears, since a tag-grouped view
// can repeat a photo across groups. Returns null when there's no
@@ -415,7 +433,7 @@ export function Timeline() {
if (photos.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-text-muted">No photos found. Add a source folder to get started.</div>
<div className="text-text-muted">(°° </div>
</div>
)
}
@@ -438,18 +456,39 @@ export function Timeline() {
className="h-full overflow-auto bg-bg"
style={{ padding: `${PADDING}px` }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const item = items[virtualItem.index]
if (!item) return null
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const item = items[virtualItem.index]
if (!item) return null
if (item.type === 'header') {
if (item.type === 'header') {
return (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
className="flex items-end pb-1"
>
<h3 className="text-sm font-semibold uppercase tracking-wide text-text-muted">
{item.label}
</h3>
</div>
)
}
// row
return (
<div
key={virtualItem.key}
@@ -461,53 +500,32 @@ export function Timeline() {
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
className="flex items-end pb-1"
>
<h3 className="text-sm font-semibold uppercase tracking-wide text-text-muted">
{item.label}
</h3>
<div className="flex" style={{ gap: `${GAP}px` }}>
{item.cells.map(({ photo, globalIndex }) => (
<PhotoThumbnail
key={photo.id}
photo={photo}
size={THUMBNAIL_SIZE}
isSelected={selectedPhotos.includes(photo.id)}
isInActiveHeap={activeHeapMembers.has(photo.id)}
onClick={(e) => {
if (e.shiftKey && lastSelectedIndex !== null) {
selectRange(globalIndex)
} else if (e.ctrlKey || e.metaKey) {
togglePhotoSelection(photo.id, globalIndex)
} else {
selectPhoto(photo.id, globalIndex)
}
}}
onDoubleClick={() => openPreview(photo.id)}
/>
))}
</div>
</div>
)
}
// row
return (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<div className="flex" style={{ gap: `${GAP}px` }}>
{item.cells.map(({ photo, globalIndex }) => (
<PhotoThumbnail
key={photo.id}
photo={photo}
size={THUMBNAIL_SIZE}
isSelected={selectedPhotos.includes(photo.id)}
isInActiveHeap={activeHeapMembers.has(photo.id)}
onClick={(e) => {
if (e.shiftKey && lastSelectedIndex !== null) {
selectRange(globalIndex)
} else if (e.ctrlKey || e.metaKey) {
togglePhotoSelection(photo.id, globalIndex)
} else {
selectPhoto(photo.id, globalIndex)
}
}}
onDoubleClick={() => openPreview(photo.id)}
/>
))}
</div>
</div>
)
})}
</div>
})}
</div>
</div>
</div>
)

View File

@@ -1,7 +1,7 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import axios from 'axios'
import { useFilterStore, filtersToParams } from '../store/filterStore'
import api from '../services/api'
import type { Photo } from '../types/photo'
/**
@@ -50,8 +50,11 @@ export function usePhotosQuery() {
return useQuery({
queryKey: ['photos', filterParams],
queryFn: async () => {
const response = await axios.get<{ photos: Photo[]; total: number }>(
'http://localhost:8001/api/v1/photos',
// Goes through the shared axios instance so it inherits the
// relative /api/v1 baseURL — same-origin behind the nginx / vite
// proxy, no CORS dance required from another machine.
const response = await api.get<{ photos: Photo[]; total: number }>(
'/photos',
{
params: {
page: 1,

View File

@@ -42,12 +42,40 @@ export const sourceFolders = {
return response.data
},
/** Rename the display label only — the on-disk path is controlled by
* the docker mount and cannot be changed from the UI. */
/** Rename a folder. SourceRoot ids only update the display label;
* Folder ids actually move the directory on disk and update every
* descendant photo's filepath. */
rename: async (folderId: string, name: string) => {
const response = await api.patch(`/folders/${folderId}`, { name })
return response.data
},
/** Create a new sub-folder under an existing Folder. parent_id MUST
* be a Folder row id (not a SourceRoot id). */
create: async (parentId: string, name: string) => {
const response = await api.post('/folders', {
name,
parent_id: parentId,
})
return response.data as { id: string; name: string; path: string; parent_id: string }
},
/** Delete a folder. mode=discard moves all photos under it to the
* discard pile (recoverable) and leaves the folder + on-disk dir
* alone. mode=permanent unlinks files, removes folder rows, and
* rmtrees the directory — irreversible. */
delete: async (folderId: string, mode: 'discard' | 'permanent') => {
const response = await api.delete(`/folders/${folderId}`, {
params: { mode },
})
return response.data as {
status: string
mode: string
discarded?: number
deleted_photos?: number
file_errors?: number
}
},
}
// Photos API

View File

@@ -10,6 +10,12 @@ interface PhotoStore {
lastSelectedIndex: number | null
rangeStartIndex: number | null
viewMode: ViewMode
/** Flat sequence of photo ids in the order they currently appear in
* the timeline grid (including duplicates from tag-grouping). The
* preview view walks this sequence so arrow nav matches the order
* the user actually sees. Owned by the Timeline component, which
* rewrites it whenever its layout items change. */
visiblePhotoIds: string[]
setPhotos: (photos: Photo[]) => void
selectPhoto: (id: string, index: number) => void
@@ -19,6 +25,7 @@ interface PhotoStore {
clearSelection: () => void
setActivePhoto: (id: string | null) => void
setViewMode: (mode: ViewMode) => void
setVisiblePhotoIds: (ids: string[]) => void
openPreview: (id: string) => void
closePreview: () => void
}
@@ -30,6 +37,7 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
lastSelectedIndex: null,
rangeStartIndex: null,
viewMode: 'grid',
visiblePhotoIds: [],
setPhotos: (photos) => set({ photos }),
@@ -73,6 +81,24 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
setViewMode: (mode) => set({ viewMode: mode }),
// No-op when the content is identical so callers can fire from an
// effect without risking a re-render loop.
setVisiblePhotoIds: (visiblePhotoIds) =>
set((s) => {
const prev = s.visiblePhotoIds
if (prev.length === visiblePhotoIds.length) {
let same = true
for (let i = 0; i < prev.length; i++) {
if (prev[i] !== visiblePhotoIds[i]) {
same = false
break
}
}
if (same) return s
}
return { visiblePhotoIds }
}),
openPreview: (id) => set({ viewMode: 'preview', activePhotoId: id }),
closePreview: () => set({ viewMode: 'grid' }),