5 Commits

Author SHA1 Message Date
ee6b49952e fix: optimistic membership cache so P toggle is instant and reliable
P already toggled membership in the active heap (remove if every
selected photo is a member, otherwise add the missing ones), but the
mutation only invalidate-then-refetched the heap-photo-ids cache on
success. Pressing P twice in quick succession could read the stale
cache and mis-toggle.

Both heap-toggle mutations (useKeyboardShortcuts P shortcut and the
RightSidebar Pick button) now do an optimistic update in onMutate:
- Read the current ['heap-photo-ids', heapId] cache
- Add or remove the affected ids in a Set
- Write the new array back via setQueryData
- Roll back from the captured `previous` on error
- Re-sync via invalidateQueries in onSettled (heap counts in particular
  still need server truth)

Result: the basket affordance flips the moment you press P, and a
quick second press always reads the new state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:24:29 +02:00
324cc0298b chore: drop duplicate selected count + Discard button from TopBar
Both were redundant:
- "N selected" is already shown by the contextual KeyboardHints pill
  below the FilterBar
- The Discard action is in the RightSidebar Flag section and bound
  to X (and exists as a per-photo button on the thumbnail when
  is_discarded)

Also removes the no-longer-used discardPhotosMutation, the photos
api import, the toast import, the useMutation/useQueryClient imports,
and the usePhotoStore selectedPhotos read — TopBar is leaner now.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:22:30 +02:00
351ccd7bb4 refactor: unify Pick with active heap membership
Pick and add-to-heap were two ways of saying "I want to keep this
one". Merging them: P now toggles the selection's membership in the
active heap. The is_picked flag goes away (orphaned in the DB the
same way is_trashed was).

Backend
- Drop is_picked from PhotoBase / PhotoUpdate / PhotoResponse and
  from the photos list filter param.
- Drop the is_picked Column from the Photo model (DB column stays
  on legacy installs but is no longer read or written).
- Drop the bulk action 'pick' branch.
- New GET /heaps/{id}/photo_ids returns just the flat string list.
  Used by the frontend for fast client-side membership lookups
  without fetching full photo records.

Frontend
- New hooks/useActiveHeapMembersQuery.ts → returns
  { activeHeap, memberIds: Set<string> }. Subscribes once at the
  Timeline level and passes a derived isInActiveHeap bool down to
  each PhotoThumbnail (avoids hundreds of thumbnails subscribing
  to the same query).
- PhotoThumbnail: replaces the old check-icon Pick affordance with
  a clear basket badge in the bottom-right corner — a small filled
  pick-colour pill containing a ShoppingBasket icon — visible only
  when the photo belongs to the active heap.
- P shortcut (useKeyboardShortcuts) now toggles membership: if every
  selected photo is already a member, it removes them; otherwise it
  adds the missing ones. T binding removed (P fully replaces it).
- RightSidebar Pick button is now a Pick / Picked toggle bound to
  the active heap. Disabled with a hint when no heap is active.
  Shows the heap name in its title attr.
- filterStore drops 'picked' and 'unflagged' from FlagFilter.
  FilterBar's flag dropdown is now just Any / Discarded.
- LeftSidebar drops the "Flagged" virtual node (it just set
  flag=picked, which no longer exists).
- KeyboardHints: P → "Pick → heap".
- Photo TS type drops is_picked.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:20:31 +02:00
02fb1cd508 feat: editable title, notes, and color label in RightSidebar
Adds the missing editable fields from spec §6.6 metadata sidebar.
The mutation path already existed (used by the keyboard culling
shortcuts) — this just surfaces the controls.

- Title: text input. Save on Enter or blur. Esc reverts.
- Notes: textarea, 3 rows. Save on blur.
- Color label: 6-dot picker (red/orange/yellow/green/blue/purple)
  with a clear button. Click an active dot to clear, or use the X.
- Local "draft" state for the text fields so typing stays
  responsive and stale refetches don't clobber in-progress edits.
  Drafts re-sync on photo.id change.
- Sends null for empty string so the backend stores NULL instead
  of an empty string (cleaner for FTS5 / future filtering).

api.ts: widens photos.update() signature to match the backend
PhotoUpdate schema — accepts user_title/user_notes/color_label
(nullable) plus is_picked/is_discarded/taken_at, which were
missing despite already being used by other call sites.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:10:01 +02:00
ebae775f3f feat: heaps end-to-end with active heap and T shortcut
Adds the spec §6.10 heaps concept: named photo collections with a
single "active" target for fast keyboard adds. Uses a basket icon
(ShoppingBasket) to visually distinguish heaps from folders.

Backend (routers/heaps.py)
- Replaces the 27-line stub with full CRUD: list (with photo counts
  via a single LEFT JOIN), create, patch (rename + set active), delete.
- Add/remove photos endpoints with idempotent semantics: re-adding an
  existing member is a no-op, removing a non-member is a no-op.
- Setting is_active=true on one heap clears the flag on every other
  heap in a single UPDATE so we maintain the single-active invariant.
- routers/photos.py list endpoint now applies the heap_id filter via
  IN-subquery against heap_photos (it was a declared param but had
  no filter logic).

Frontend
- New hooks/useHeapsQuery.ts and useFilterUrlSync wires heap_id as
  another URL-persisted filter; usePhotosQuery threads it through.
- New components/heaps/HeapsPanel.tsx replaces the LeftSidebar Heaps
  stub. Shows the basket icon, photo counts, lets you create heaps
  inline, click to filter the timeline, set active via the target
  icon, and delete heaps.
- TopBar shows an "active heap" pill (basket + name) so the user
  always knows where the next T-press will land.
- KeyboardHints adds T → Add to heap.

T shortcut (useKeyboardShortcuts)
- Reads the active heap from the heaps query cache and the selection
  from the photo store at fire time. Adds the selected photos (or the
  active photo if nothing is selected) via POST /heaps/{id}/photos.
- Toasts:
  - "Added to {heap}: N photos (M already present)" on success
  - "No active heap" hint when none is set
  - "Nothing selected" hint when there's no selection or active photo

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:06:52 +02:00
20 changed files with 863 additions and 154 deletions

View File

@@ -55,10 +55,10 @@ class Photo(Base):
user_notes = Column(Text)
rating = Column(Integer, default=0) # 0-5 stars
color_label = Column(String) # 'red'|'orange'|'yellow'|'green'|'blue'|'purple'|NULL
is_picked = Column(Boolean, default=False)
# Note: is_rejected was merged into is_discarded (a single soft "discarded"
# concept). The DB column may still exist on legacy installs but is no
# longer read or written.
# concept). is_picked was unified with active-heap membership — picking a
# photo just means adding it to the active heap. Both DB columns may still
# exist on legacy installs but are no longer read or written.
# Duplicate detection
is_duplicate = Column(Boolean, default=False)

View File

@@ -1,27 +1,203 @@
"""
Heaps API router
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from pydantic import BaseModel
from sqlalchemy import select, func, update, insert, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Heap
from app.models.heaps import heap_photos
router = APIRouter()
# ── Schemas ───────────────────────────────────────────────────────────────
class HeapCreate(BaseModel):
name: str
class HeapUpdate(BaseModel):
name: Optional[str] = None
is_active: Optional[bool] = None
class HeapPhotosBody(BaseModel):
photo_ids: list[str]
# ── Endpoints ─────────────────────────────────────────────────────────────
@router.get("")
async def list_heaps(db: AsyncSession = Depends(get_db)):
"""List all heaps"""
result = await db.execute(select(Heap))
heaps = result.scalars().all()
return heaps
"""List all heaps with photo counts."""
# LEFT JOIN heap_photos and group so we can return counts in one query.
count_subq = (
select(
heap_photos.c.heap_id,
func.count(heap_photos.c.photo_id).label("photo_count"),
)
.group_by(heap_photos.c.heap_id)
.subquery()
)
@router.post("")
async def create_heap(name: str, db: AsyncSession = Depends(get_db)):
"""Create a new heap"""
stmt = (
select(Heap, count_subq.c.photo_count)
.outerjoin(count_subq, Heap.id == count_subq.c.heap_id)
.order_by(Heap.created_at.asc())
)
result = await db.execute(stmt)
rows = result.all()
return [
{
"id": h.id,
"name": h.name,
"is_active": bool(h.is_active),
"created_at": h.created_at,
"updated_at": h.updated_at,
"photo_count": int(count or 0),
}
for h, count in rows
]
@router.post("", status_code=201)
async def create_heap(body: HeapCreate, db: AsyncSession = Depends(get_db)):
"""Create a new heap."""
name = (body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Heap name is required")
heap = Heap(name=name)
db.add(heap)
await db.commit()
await db.refresh(heap)
return heap
return {
"id": heap.id,
"name": heap.name,
"is_active": bool(heap.is_active),
"created_at": heap.created_at,
"updated_at": heap.updated_at,
"photo_count": 0,
}
@router.patch("/{heap_id}")
async def update_heap(
heap_id: str, body: HeapUpdate, db: AsyncSession = Depends(get_db)
):
"""Rename a heap and/or toggle active state. Setting is_active=true on
one heap deactivates all others (single-active invariant)."""
result = await db.execute(select(Heap).where(Heap.id == heap_id))
heap = result.scalar_one_or_none()
if not heap:
raise HTTPException(status_code=404, detail="Heap not found")
if body.name is not None:
name = body.name.strip()
if not name:
raise HTTPException(status_code=400, detail="Heap name is required")
heap.name = name
if body.is_active is not None:
if body.is_active:
# Clear active flag on all other heaps in one statement
await db.execute(update(Heap).values(is_active=False))
heap.is_active = True
else:
heap.is_active = False
await db.commit()
await db.refresh(heap)
return {
"id": heap.id,
"name": heap.name,
"is_active": bool(heap.is_active),
"created_at": heap.created_at,
"updated_at": 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
rows in heap_photos cascade-delete."""
result = await db.execute(select(Heap).where(Heap.id == heap_id))
heap = result.scalar_one_or_none()
if not heap:
raise HTTPException(status_code=404, detail="Heap not found")
await db.delete(heap)
await db.commit()
return None
@router.get("/{heap_id}/photo_ids")
async def get_heap_photo_ids(heap_id: str, db: AsyncSession = Depends(get_db)):
"""Return just the photo ids belonging to a heap. Used by the frontend
to maintain a fast client-side membership lookup for the active heap
(for the basket affordance on thumbnails) without fetching full photo
records."""
result = await db.execute(
select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id)
)
return [row[0] for row in result.all()]
@router.post("/{heap_id}/photos")
async def add_photos_to_heap(
heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db)
):
"""Add photos to a heap. Idempotent: re-adding existing members is a
no-op (handled by an INSERT OR IGNORE-style filter on duplicates)."""
result = await db.execute(select(Heap).where(Heap.id == heap_id))
heap = result.scalar_one_or_none()
if not heap:
raise HTTPException(status_code=404, detail="Heap not found")
if not body.photo_ids:
return {"status": "success", "added": 0}
# Find which ids are already members so we don't violate the PK.
existing = await db.execute(
select(heap_photos.c.photo_id).where(
heap_photos.c.heap_id == heap_id,
heap_photos.c.photo_id.in_(body.photo_ids),
)
)
existing_ids = {row[0] for row in existing.all()}
new_ids = [pid for pid in body.photo_ids if pid not in existing_ids]
if new_ids:
await db.execute(
insert(heap_photos),
[{"heap_id": heap_id, "photo_id": pid} for pid in new_ids],
)
await db.commit()
return {"status": "success", "added": len(new_ids), "already_present": len(existing_ids)}
@router.delete("/{heap_id}/photos")
async def remove_photos_from_heap(
heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db)
):
"""Remove photos from a heap. Removing a non-member is a no-op."""
result = await db.execute(select(Heap).where(Heap.id == heap_id))
heap = result.scalar_one_or_none()
if not heap:
raise HTTPException(status_code=404, detail="Heap not found")
if not body.photo_ids:
return {"status": "success", "removed": 0}
res = await db.execute(
delete(heap_photos).where(
heap_photos.c.heap_id == heap_id,
heap_photos.c.photo_id.in_(body.photo_ids),
)
)
await db.commit()
return {"status": "success", "removed": res.rowcount or 0}

View File

@@ -16,6 +16,7 @@ logger = logging.getLogger(__name__)
from app.database import get_db
from app.models import Photo, Folder, Tag, PhotoTag
from app.models.heaps import heap_photos
from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction
from app.config import settings
@@ -32,7 +33,6 @@ async def list_photos(
rating_min: Optional[int] = Query(None, ge=0, le=5),
rating_max: Optional[int] = Query(None, ge=0, le=5),
color_label: Optional[str] = None,
is_picked: Optional[bool] = None,
is_discarded: Optional[bool] = False,
heap_id: Optional[str] = None,
sort: str = "taken_at",
@@ -89,13 +89,17 @@ async def list_photos(
else:
filters.append(Photo.color_label == color_label)
# Flag filters
if is_picked is not None:
filters.append(Photo.is_picked == is_picked)
# Discard filter — defaults to hiding discarded photos
filters.append(Photo.is_discarded == is_discarded)
# Heap membership filter — restrict to photos that belong to the heap.
if heap_id:
filters.append(
Photo.id.in_(
select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id)
)
)
# Apply all filters
if filters:
query = query.where(and_(*filters))
@@ -459,10 +463,6 @@ async def bulk_action(
elif action.action == 'set_color':
for photo in photos:
photo.color_label = action.value
elif action.action == 'pick':
for photo in photos:
photo.is_picked = True
photo.is_discarded = False
else:
raise HTTPException(status_code=400, detail="Invalid action")

View File

@@ -19,7 +19,6 @@ class PhotoBase(BaseModel):
user_notes: Optional[str] = None
rating: int = 0
color_label: Optional[str] = None
is_picked: bool = False
class PhotoResponse(PhotoBase):
"""Photo response schema"""
@@ -51,7 +50,6 @@ class PhotoUpdate(BaseModel):
user_notes: Optional[str] = None
rating: Optional[int] = Field(None, ge=0, le=5)
color_label: Optional[str] = None
is_picked: Optional[bool] = None
is_discarded: Optional[bool] = None
taken_at: Optional[datetime] = None
@@ -66,5 +64,5 @@ class PhotoListResponse(BaseModel):
class BulkAction(BaseModel):
"""Bulk action on photos"""
ids: List[str]
action: str # 'discard', 'restore', 'delete_permanent', 'move', 'copy', 'add_tag', 'remove_tag', 'set_rating', 'set_color', 'pick'
action: str # 'discard', 'restore', 'delete_permanent', 'move', 'copy', 'add_tag', 'remove_tag', 'set_rating', 'set_color'
value: Optional[Any] = None # For actions that need a value (rating, color, tag_id, folder_id)

View File

@@ -11,7 +11,7 @@ export function KeyboardHints() {
const hints = selectedCount > 0
? [
{ key: '1-5', action: 'Rate' },
{ key: 'P', action: 'Pick' },
{ key: 'P', action: 'Pick → heap' },
{ key: 'X', action: 'Discard' },
{ key: 'Space', action: 'Preview' },
{ key: 'Esc', action: 'Deselect' },

View File

@@ -25,9 +25,7 @@ const COLOR_LABELS: { value: ColorLabel; className: string }[] = [
const FLAG_OPTIONS: { value: FlagFilter; label: string }[] = [
{ value: 'any', label: 'Any' },
{ value: 'picked', label: 'Picked' },
{ value: 'discarded', label: 'Discarded' },
{ value: 'unflagged', label: 'Unflagged' },
]
export function FilterBar() {

View File

@@ -0,0 +1,224 @@
import { useState } from 'react'
import {
ShoppingBasket,
Plus,
Target,
X,
ChevronDown,
ChevronRight,
} from 'lucide-react'
import clsx from 'clsx'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { heaps as heapsApi } from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
import { toast } from '../ToastContainer'
/**
* Heaps panel for the left sidebar. Renders the list of heaps with the
* basket icon, lets the user create a new heap, click one to filter the
* timeline to its contents, set one as the "active" target for the T
* shortcut, and delete heaps.
*
* Heap state:
* - filter heapId: which heap is currently filtered to (visual)
* - heap.is_active: which heap T adds to (server-side, single per row)
*/
export function HeapsPanel() {
const { data: heaps = [] } = useHeapsQuery()
const filterHeapId = useFilterStore((s) => s.heapId)
const setFilterHeapId = useFilterStore((s) => s.setHeapId)
const queryClient = useQueryClient()
const [expanded, setExpanded] = useState(true)
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
}
const createMutation = useMutation({
mutationFn: (name: string) => heapsApi.create(name),
onSuccess: () => {
invalidate()
setNewName('')
setCreating(false)
},
onError: (e: any) =>
toast.error('Failed to create heap', e.message || 'Unknown error'),
})
const setActiveMutation = useMutation({
mutationFn: (heapId: string) =>
heapsApi.update(heapId, { is_active: true }),
onSuccess: (heap) => {
invalidate()
toast.success('Active heap', `Now adding to "${heap.name}" with T`)
},
onError: (e: any) =>
toast.error('Failed to set active', e.message || 'Unknown error'),
})
const deleteMutation = useMutation({
mutationFn: (heapId: string) => heapsApi.delete(heapId),
onSuccess: (_, heapId) => {
invalidate()
// If we were filtering by this heap, clear the filter
if (filterHeapId === heapId) setFilterHeapId(null)
},
onError: (e: any) =>
toast.error('Failed to delete heap', e.message || 'Unknown error'),
})
const handleCreate = () => {
const name = newName.trim()
if (!name) return
createMutation.mutate(name)
}
return (
<div>
{/* Section header */}
<div
className="group flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-sm text-text hover:bg-surface-2"
onClick={() => setExpanded((v) => !v)}
>
<button className="rounded p-0.5 hover:bg-surface-offset">
{expanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</button>
<ShoppingBasket className="h-4 w-4 text-text-muted" />
<span className="flex-1 truncate">Heaps</span>
<button
onClick={(e) => {
e.stopPropagation()
setCreating(true)
setExpanded(true)
}}
className="invisible rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:visible"
title="New heap"
>
<Plus className="h-3 w-3" />
</button>
</div>
{expanded && (
<div>
{/* Inline create form */}
{creating && (
<div
className="flex items-center gap-1 px-2 py-1"
style={{ paddingLeft: '32px' }}
>
<input
autoFocus
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreate()
if (e.key === 'Escape') {
setCreating(false)
setNewName('')
}
}}
placeholder="Heap name"
className="flex-1 rounded border border-border bg-bg px-2 py-0.5 text-xs text-text focus:border-primary focus:outline-none"
/>
<button
onClick={handleCreate}
disabled={!newName.trim() || createMutation.isPending}
className="rounded bg-primary px-2 py-0.5 text-xs text-white hover:bg-primary/80 disabled:opacity-50"
>
Add
</button>
</div>
)}
{heaps.length === 0 && !creating && (
<div
className="px-2 py-1 text-xs text-text-faint"
style={{ paddingLeft: '32px' }}
>
No heaps yet
</div>
)}
{heaps.map((heap) => {
const isFiltered = filterHeapId === heap.id
const isActive = heap.is_active
return (
<div
key={heap.id}
className={clsx(
'group 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'
)}
style={{ paddingLeft: '32px' }}
onClick={() => setFilterHeapId(heap.id)}
>
<ShoppingBasket
className={clsx(
'h-4 w-4 flex-shrink-0',
isFiltered ? 'text-primary' : 'text-text-muted'
)}
/>
<span
className={clsx(
'flex-1 truncate',
isActive && 'font-semibold'
)}
title={heap.name}
>
{heap.name}
</span>
{isActive && (
<Target
className="h-3 w-3 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}
</span>
)}
<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'
)}
title="Set as active heap (T target)"
>
<Target 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>
)}
</div>
)
}

View File

@@ -6,12 +6,11 @@ import {
Image,
Calendar,
Star,
Flag,
Trash2,
Plus,
MoreHorizontal,
HardDrive,
RefreshCw
RefreshCw,
} from 'lucide-react'
import clsx from 'clsx'
import { AddSourceFolderDialog } from '../dialogs/AddSourceFolderDialog'
@@ -19,6 +18,7 @@ import { sourceFolders, library } from '../../services/api'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from '../ToastContainer'
import { useFilterStore } from '../../store/filterStore'
import { HeapsPanel } from '../heaps/HeapsPanel'
interface TreeItem {
id: string
@@ -51,10 +51,6 @@ export function LeftSidebar() {
clearAllFilters()
setRatingMin(1)
break
case 'flagged':
clearAllFilters()
setFlag('picked')
break
case 'discarded':
clearAllFilters()
setFlag('discarded')
@@ -137,7 +133,6 @@ export function LeftSidebar() {
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: 0 },
{ id: 'by-date', label: 'By Date', icon: <Calendar className="h-4 w-4" /> },
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 },
{ id: 'flagged', label: 'Flagged', icon: <Flag className="h-4 w-4" />, count: 0 },
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: 0 },
],
},
@@ -153,12 +148,6 @@ export function LeftSidebar() {
type: 'folder',
})) || [],
},
{
id: 'heaps',
label: 'Heaps',
icon: <Folder className="h-4 w-4" />,
children: [], // Will be populated from API
},
]
const renderTreeItem = (item: TreeItem, depth: number = 0) => {
@@ -257,6 +246,7 @@ export function LeftSidebar() {
{/* Tree View */}
<div className="flex-1 overflow-y-auto py-2">
{libraryTree.map((item) => renderTreeItem(item))}
<HeapsPanel />
</div>
{/* Bottom Actions */}

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react'
import { useState, useMemo, useEffect } from 'react'
import {
X,
Star,
@@ -8,14 +8,16 @@ import {
Info,
ChevronDown,
ChevronRight,
Check,
ShoppingBasket,
Trash2,
} from 'lucide-react'
import clsx from 'clsx'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { format } from 'date-fns'
import { usePhotoStore } from '../../store/photoStore'
import { photos as photosApi } from '../../services/api'
import { photos as photosApi, heaps as heapsApi } from '../../services/api'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
interface PhotoDetails {
id: string
@@ -26,11 +28,24 @@ interface PhotoDetails {
file_size: number | null
taken_at: string | null
rating: number
is_picked: boolean
is_discarded: boolean
user_title: string | null
user_notes: string | null
color_label: string | null
exif_json: string | null
}
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
@@ -102,13 +117,16 @@ export function RightSidebar() {
staleTime: 60_000,
})
// Mutations for rating / pick / reject. Optimistic-ish: invalidate the
// photo query and the timeline list query so the grid re-renders too.
// Mutation for any patchable field on the active photo. Invalidates both
// the photo detail cache and the timeline list so the grid reflects the
// change too.
const updateMutation = useMutation({
mutationFn: (data: {
rating?: number
is_picked?: boolean
is_discarded?: boolean
user_title?: string | null
user_notes?: string | null
color_label?: string | null
}) => photosApi.update(activePhotoId!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['photo', activePhotoId] })
@@ -116,6 +134,73 @@ export function RightSidebar() {
},
})
// Membership in the active heap (for the Pick toggle button).
const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
const isInActiveHeap =
!!activePhotoId && activeHeapMembers.has(activePhotoId)
const heapMutation = useMutation({
mutationFn: ({ remove }: { remove: boolean }) => {
if (!activeHeap || !activePhotoId) return Promise.resolve(null)
return remove
? heapsApi.removePhotos(activeHeap.id, [activePhotoId])
: heapsApi.addPhotos(activeHeap.id, [activePhotoId])
},
// Optimistic flip so the badge / button label update instantly.
onMutate: ({ remove }) => {
if (!activeHeap || !activePhotoId) return { previous: undefined }
const key = ['heap-photo-ids', activeHeap.id] as const
const previous = queryClient.getQueryData<string[]>(key)
const set = new Set(previous ?? [])
if (remove) set.delete(activePhotoId)
else set.add(activePhotoId)
queryClient.setQueryData<string[]>(key, Array.from(set))
return { previous }
},
onError: (_e, _vars, ctx) => {
if (activeHeap && ctx?.previous) {
queryClient.setQueryData(['heap-photo-ids', activeHeap.id], ctx.previous)
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
if (activeHeap) {
queryClient.invalidateQueries({
queryKey: ['heap-photo-ids', activeHeap.id],
})
}
},
})
// Local drafts for the editable text fields. These mirror the server value
// but stay independent while the user is typing, so we don't fight focus or
// clobber edits with stale refetches.
const [titleDraft, setTitleDraft] = useState('')
const [notesDraft, setNotesDraft] = useState('')
useEffect(() => {
setTitleDraft(photo?.user_title ?? '')
setNotesDraft(photo?.user_notes ?? '')
}, [photo?.id, photo?.user_title, photo?.user_notes])
const commitTitle = () => {
const next = titleDraft.trim()
const current = photo?.user_title ?? ''
if (next === current) return
updateMutation.mutate({ user_title: next || null })
}
const commitNotes = () => {
const next = notesDraft
const current = photo?.user_notes ?? ''
if (next === current) return
updateMutation.mutate({ user_notes: next || null })
}
const setColor = (label: ColorLabel | null) => {
updateMutation.mutate({ color_label: label })
}
const exif = useMemo(() => parseExif(photo?.exif_json ?? null), [photo?.exif_json])
if (selectedPhotos.length === 0) {
@@ -131,8 +216,8 @@ export function RightSidebar() {
const multipleSelected = selectedPhotos.length > 1
const rating = photo?.rating ?? 0
const isPicked = photo?.is_picked ?? false
const isDiscarded = photo?.is_discarded ?? false
const colorLabel = (photo?.color_label ?? null) as ColorLabel | null
return (
<div className="flex h-full flex-col bg-surface">
@@ -154,8 +239,43 @@ export function RightSidebar() {
{/* Quick Actions — operate on the active photo */}
{photo && !multipleSelected && (
<div className="border-b border-border p-4">
<div className="mb-3">
<div className="space-y-3 border-b border-border p-4">
{/* Title (editable) */}
<div>
<label className="mb-1 block text-xs text-text-muted">Title</label>
<input
type="text"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
} else if (e.key === 'Escape') {
setTitleDraft(photo.user_title ?? '')
e.currentTarget.blur()
}
}}
placeholder="No title"
className="w-full rounded border border-border bg-bg px-2 py-1 text-sm text-text placeholder-text-faint focus:border-primary focus:outline-none"
/>
</div>
{/* Notes (editable) */}
<div>
<label className="mb-1 block text-xs text-text-muted">Notes</label>
<textarea
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
onBlur={commitNotes}
placeholder="Add notes…"
rows={3}
className="w-full resize-none rounded border border-border bg-bg px-2 py-1 text-sm text-text placeholder-text-faint focus:border-primary focus:outline-none"
/>
</div>
{/* Rating */}
<div>
<label className="mb-1 block text-xs text-text-muted">Rating</label>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((value) => (
@@ -180,32 +300,64 @@ export function RightSidebar() {
</div>
</div>
{/* Color label */}
<div>
<label className="mb-1 block text-xs text-text-muted">Color label</label>
<div className="flex items-center gap-1.5">
{COLOR_LABEL_OPTIONS.map(({ value, className }) => {
const active = colorLabel === value
return (
<button
key={value}
onClick={() => setColor(active ? null : value)}
className={clsx(
'h-5 w-5 rounded-full ring-offset-2 ring-offset-surface transition-all',
className,
active ? 'ring-2 ring-primary' : 'opacity-60 hover:opacity-100'
)}
title={value}
/>
)
})}
{colorLabel && (
<button
onClick={() => setColor(null)}
className="ml-1 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear color label"
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
{/* Flag */}
<div>
<label className="mb-1 block text-xs text-text-muted">Flag</label>
<div className="flex gap-2">
<button
onClick={() =>
updateMutation.mutate({
is_picked: !isPicked,
is_discarded: false,
})
}
onClick={() => heapMutation.mutate({ remove: isInActiveHeap })}
disabled={!activeHeap || heapMutation.isPending}
className={clsx(
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
isPicked
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
isInActiveHeap
? 'bg-pick/20 text-pick'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
)}
title={
activeHeap
? isInActiveHeap
? `Remove from "${activeHeap.name}"`
: `Add to "${activeHeap.name}"`
: 'Set an active heap first'
}
>
<Check className="h-3 w-3" />
Pick
<ShoppingBasket className="h-3 w-3" />
{isInActiveHeap ? 'Picked' : 'Pick'}
</button>
<button
onClick={() =>
updateMutation.mutate({
is_discarded: !isDiscarded,
is_picked: false,
})
updateMutation.mutate({ is_discarded: !isDiscarded })
}
className={clsx(
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',

View File

@@ -8,15 +8,12 @@ import {
Upload,
Settings,
Menu,
Trash2,
X,
ShoppingBasket,
} from 'lucide-react'
import clsx from 'clsx'
import { usePhotoStore } from '../../store/photoStore'
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
import { photos } from '../../services/api'
import { toast } from '../ToastContainer'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useHeapsQuery } from '../../hooks/useHeapsQuery'
import muliLogo from '../../assets/muli-logo.png'
const SEARCH_DEBOUNCE_MS = 300
@@ -53,25 +50,11 @@ export function TopBar() {
}, [searchQuery, storeQ, setStoreQ])
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const selectedPhotos = usePhotoStore((state) => state.selectedPhotos)
const clearSelection = usePhotoStore((state) => state.clearSelection)
const selectedCount = selectedPhotos.length
const queryClient = useQueryClient()
// Mutation for discarding selected photos
const discardPhotosMutation = useMutation({
mutationFn: async () => {
await photos.bulkUpdate(selectedPhotos, { discard: true })
},
onSuccess: () => {
toast.success('Discarded', `${selectedCount} photo${selectedCount > 1 ? 's' : ''} discarded`)
clearSelection()
queryClient.invalidateQueries({ queryKey: ['photos'] })
},
onError: (error: any) => {
toast.error('Failed to discard', error.message || 'An error occurred')
},
})
// 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">
@@ -90,21 +73,14 @@ export function TopBar() {
<img src={muliLogo} alt="Mulita" className="h-7 w-7 object-contain" />
<h1 className="text-lg font-semibold text-text">Mulita</h1>
</div>
{selectedCount > 0 && (
<>
<span className="rounded bg-primary/20 px-2 py-0.5 text-sm text-primary">
{selectedCount} selected
</span>
<button
onClick={() => discardPhotosMutation.mutate()}
disabled={discardPhotosMutation.isPending}
className="flex items-center gap-1 rounded bg-reject/20 px-2 py-0.5 text-sm text-reject hover:bg-reject/30 disabled:opacity-50"
title="Discard"
{activeHeap && (
<span
className="flex items-center gap-1 rounded bg-primary/20 px-2 py-0.5 text-xs text-primary"
title="Active heap — press P to add selected photos here"
>
<Trash2 className="h-3.5 w-3.5" />
Discard
</button>
</>
<ShoppingBasket className="h-3 w-3" />
{activeHeap.name}
</span>
)}
</div>

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { Star, Check, Trash2, RefreshCw } from 'lucide-react'
import { Star, ShoppingBasket, Trash2, RefreshCw, Check } from 'lucide-react'
import clsx from 'clsx'
import { photos as photosApi } from '../../services/api'
import type { Photo } from '../../types/photo'
@@ -12,11 +12,20 @@ interface PhotoThumbnailProps {
photo: Photo
size: number
isSelected: boolean
/** True when the photo belongs to the currently active heap. */
isInActiveHeap?: boolean
onClick: (e: React.MouseEvent) => void
onDoubleClick?: (e: React.MouseEvent) => void
}
export function PhotoThumbnail({ photo, size, isSelected, onClick, onDoubleClick }: PhotoThumbnailProps) {
export function PhotoThumbnail({
photo,
size,
isSelected,
isInActiveHeap = false,
onClick,
onDoubleClick,
}: PhotoThumbnailProps) {
const [imageError, setImageError] = useState(false)
const [imageLoaded, setImageLoaded] = useState(false)
const [retryCount, setRetryCount] = useState(0)
@@ -167,9 +176,14 @@ export function PhotoThumbnail({ photo, size, isSelected, onClick, onDoubleClick
)}
{/* Flag Indicators */}
<div className="absolute bottom-1 right-1">
{photo.is_picked && (
<Check className="h-4 w-4 text-pick" />
<div className="absolute bottom-1 right-1 flex items-center gap-1">
{isInActiveHeap && (
<div
className="flex h-5 w-5 items-center justify-center rounded-full bg-pick text-white shadow-md"
title="In active heap"
>
<ShoppingBasket className="h-3 w-3" />
</div>
)}
{photo.is_discarded && (
<Trash2 className="h-4 w-4 text-reject" />

View File

@@ -3,6 +3,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'
import { usePhotoStore } from '../../store/photoStore'
import { PhotoThumbnail } from './PhotoThumbnail'
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import type { Photo } from '../../types/photo'
export function Timeline() {
@@ -48,6 +49,11 @@ export function Timeline() {
// they share one cache entry, regardless of filter state.
const { data: photos = [], isLoading } = usePhotosQuery()
// Membership in the active heap (for the basket affordance). Subscribed
// once at this level so we don't have hundreds of thumbnails each
// subscribing to the same query.
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
// Group photos into rows for grid layout
const rows = useMemo(() => {
const result: Photo[][] = []
@@ -212,6 +218,7 @@ export function Timeline() {
photo={photo}
size={thumbnailSize}
isSelected={selectedPhotos.includes(photo.id)}
isInActiveHeap={activeHeapMembers.has(photo.id)}
onClick={(e) => {
if (e.shiftKey && lastSelectedIndex !== null) {
selectRange(globalIndex)

View File

@@ -0,0 +1,39 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useHeapsQuery } from './useHeapsQuery'
import { heaps as heapsApi } from '../services/api'
const EMPTY_SET: ReadonlySet<string> = new Set()
/**
* Returns the photo ids that belong to the active heap as a Set, plus the
* active heap itself. Used by PhotoThumbnail to render the basket affordance
* and by the P shortcut to decide between add vs remove.
*
* If no heap is active, the Set is empty (and shared across renders).
*/
export function useActiveHeapMembers(): {
activeHeap: ReturnType<typeof useHeapsQuery>['data'] extends (infer T)[] | undefined
? T | null
: never
memberIds: ReadonlySet<string>
} {
const { data: heaps } = useHeapsQuery()
const activeHeap = heaps?.find((h) => h.is_active) ?? null
const { data: ids } = useQuery({
queryKey: ['heap-photo-ids', activeHeap?.id],
queryFn: () => heapsApi.photoIds(activeHeap!.id),
enabled: !!activeHeap,
staleTime: 30_000,
})
const memberIds = useMemo(
() => (ids ? new Set(ids) : EMPTY_SET),
[ids]
)
return { activeHeap: activeHeap as any, memberIds }
}
export const ACTIVE_HEAP_MEMBERS_QUERY_KEY_PREFIX = ['heap-photo-ids'] as const

View File

@@ -16,7 +16,7 @@ const ALLOWED_COLORS: ColorLabel[] = [
'blue',
'purple',
]
const ALLOWED_FLAGS: FlagFilter[] = ['any', 'picked', 'discarded', 'unflagged']
const ALLOWED_FLAGS: FlagFilter[] = ['any', 'discarded']
function parseUrl(): Partial<FilterState> {
const sp = new URLSearchParams(window.location.search)
@@ -55,6 +55,9 @@ function parseUrl(): Partial<FilterState> {
out.flag = flag as FlagFilter
}
const heapId = sp.get('heap_id')
if (heapId) out.heapId = heapId
return out
}
@@ -67,6 +70,7 @@ function writeUrl(f: FilterState) {
if (f.ratingMin > 0) sp.set('rating_min', String(f.ratingMin))
if (f.colorLabel) sp.set('color_label', f.colorLabel)
if (f.flag !== 'any') sp.set('flag', f.flag)
if (f.heapId) sp.set('heap_id', f.heapId)
const search = sp.toString()
const next = search ? `?${search}` : window.location.pathname

View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query'
import { heaps as heapsApi, type Heap } from '../services/api'
export const HEAPS_QUERY_KEY = ['heaps'] as const
export function useHeapsQuery() {
return useQuery<Heap[]>({
queryKey: HEAPS_QUERY_KEY,
queryFn: heapsApi.list,
staleTime: 30_000,
})
}

View File

@@ -2,7 +2,9 @@ import { useHotkeys } from 'react-hotkeys-hook'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { usePhotoStore } from '../store/photoStore'
import { useFilterStore } from '../store/filterStore'
import { photos as photosApi } from '../services/api'
import { photos as photosApi, heaps as heapsApi, type Heap } from '../services/api'
import { HEAPS_QUERY_KEY } from './useHeapsQuery'
import { toast } from '../components/ToastContainer'
interface KeyboardShortcutsProps {
onToggleLeftSidebar: () => void
@@ -13,7 +15,6 @@ interface KeyboardShortcutsProps {
interface PhotoUpdate {
rating?: number
is_picked?: boolean
is_discarded?: boolean
color_label?: string | null
}
@@ -60,6 +61,99 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
updateMutation.mutate({ id, data })
}
// P key (Pick): toggle the current selection's membership in the active
// heap. If every selected photo is already a member, remove them; otherwise
// add the missing ones. No active heap → toast hint.
const heapMutation = useMutation({
mutationFn: ({
heapId,
photoIds,
remove,
}: {
heapId: string
photoIds: string[]
remove: boolean
}) =>
remove
? heapsApi.removePhotos(heapId, photoIds)
: heapsApi.addPhotos(heapId, photoIds),
// Optimistically flip the membership cache so the basket affordance
// updates instantly and a quick second P press reads the new state
// (otherwise invalidate-then-refetch leaves a brief stale window).
onMutate: ({ heapId, photoIds, remove }) => {
const key = ['heap-photo-ids', heapId] as const
const previous = queryClient.getQueryData<string[]>(key)
const set = new Set(previous ?? [])
if (remove) photoIds.forEach((id) => set.delete(id))
else photoIds.forEach((id) => set.add(id))
queryClient.setQueryData<string[]>(key, Array.from(set))
return { previous }
},
onError: (e: any, _vars, ctx) => {
// Roll back the optimistic update on failure.
if (ctx?.previous) {
queryClient.setQueryData(['heap-photo-ids', _vars.heapId], ctx.previous)
}
toast.error('Heap update failed', e.message || 'Unknown error')
},
onSuccess: (data, vars) => {
const heap = (queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []).find(
(h) => h.id === vars.heapId
)
const heapName = heap?.name ?? 'heap'
if (vars.remove) {
const removed = data?.removed ?? 0
toast.success(`Removed from ${heapName}`, `${removed} photo${removed === 1 ? '' : 's'}`)
} else {
const added = data?.added ?? 0
const already = data?.already_present ?? 0
if (added > 0) {
toast.success(
`Added to ${heapName}`,
`${added} photo${added > 1 ? 's' : ''}${already > 0 ? ` (${already} already present)` : ''}`
)
}
}
},
onSettled: (_data, _err, vars) => {
// Re-sync with server truth (heap counts in particular need this).
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
queryClient.invalidateQueries({ queryKey: ['heap-photo-ids', vars.heapId] })
},
})
const togglePickOnSelection = () => {
const state = usePhotoStore.getState()
const ids =
state.selectedPhotos.length > 0
? state.selectedPhotos
: state.activePhotoId
? [state.activePhotoId]
: []
if (ids.length === 0) {
toast.info('Nothing selected', 'Select photos first, then press P')
return
}
const heapsList = queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []
const active = heapsList.find((h) => h.is_active)
if (!active) {
toast.info('No active heap', 'Set an active heap (target icon next to a heap)')
return
}
// Determine direction: if every selected photo is already a member, this
// press REMOVES them; otherwise it ADDS the missing ones. Mirrors how
// Lightroom's flag-toggle works.
const memberIds =
queryClient.getQueryData<string[]>(['heap-photo-ids', active.id]) ?? []
const memberSet = new Set(memberIds)
const allMembers = ids.every((id) => memberSet.has(id))
heapMutation.mutate({
heapId: active.id,
photoIds: ids,
remove: allMembers,
})
}
// Toggle sidebars
useHotkeys('tab', onToggleLeftSidebar, HK_OPTS)
useHotkeys('i', onToggleRightSidebar, HK_OPTS)
@@ -103,26 +197,14 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
useHotkeys('0', () => updateActive({ rating: 0 }), HK_OPTS)
// Pick / discard / unflag. Discard is a soft flag that hides the photo
// from the default timeline view; restore via the Discarded view (or the
// U shortcut).
useHotkeys(
'p',
() => updateActive({ is_picked: true, is_discarded: false }),
HK_OPTS
)
// P (Pick) is unified with "add to active heap" — Pick a photo and you're
// adding it to the heap you set as active. Toggling on already-picked
// photos removes them from the heap.
useHotkeys('p', togglePickOnSelection, HK_OPTS)
useHotkeys(
'x',
() => updateActive({ is_discarded: true, is_picked: false }),
HK_OPTS
)
useHotkeys('x', () => updateActive({ is_discarded: true }), HK_OPTS)
useHotkeys(
'u',
() => updateActive({ is_picked: false, is_discarded: false }),
HK_OPTS
)
useHotkeys('u', () => updateActive({ is_discarded: false }), HK_OPTS)
// Color labels 6-9 (red/orange/yellow/green per spec §6.4).
useHotkeys(
@@ -133,4 +215,5 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
},
HK_OPTS
)
}

View File

@@ -18,6 +18,7 @@ export function usePhotosQuery() {
const ratingMin = useFilterStore((s) => s.ratingMin)
const colorLabel = useFilterStore((s) => s.colorLabel)
const flag = useFilterStore((s) => s.flag)
const heapId = useFilterStore((s) => s.heapId)
const filterParams = useMemo(
() =>
@@ -29,8 +30,9 @@ export function usePhotosQuery() {
ratingMin,
colorLabel,
flag,
heapId,
}),
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag]
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId]
)
return useQuery({

View File

@@ -57,9 +57,12 @@ export const photos = {
update: async (photoId: string, data: {
rating?: number
flag?: string
user_title?: string
user_notes?: string
user_title?: string | null
user_notes?: string | null
color_label?: string | null
is_picked?: boolean
is_discarded?: boolean
taken_at?: string
}) => {
const response = await api.patch(`/photos/${photoId}`, data)
return response.data
@@ -112,30 +115,56 @@ export const library = {
}
// Heaps API
export interface Heap {
id: string
name: string
is_active: boolean
created_at: string
updated_at: string | null
photo_count: number
}
export const heaps = {
list: async () => {
list: async (): Promise<Heap[]> => {
const response = await api.get('/heaps')
return response.data
},
create: async (name: string, description?: string) => {
const response = await api.post('/heaps', {
name,
description,
})
create: async (name: string): Promise<Heap> => {
const response = await api.post('/heaps', { name })
return response.data
},
update: async (heapId: string, data: {
name?: string
description?: string
}) => {
update: async (
heapId: string,
data: { name?: string; is_active?: boolean }
): Promise<Heap> => {
const response = await api.patch(`/heaps/${heapId}`, data)
return response.data
},
delete: async (heapId: string) => {
const response = await api.delete(`/heaps/${heapId}`)
delete: async (heapId: string): Promise<void> => {
await api.delete(`/heaps/${heapId}`)
},
/** Lightweight: just the photo ids in a heap, for client-side membership
* lookups (the basket affordance on thumbnails). */
photoIds: async (heapId: string): Promise<string[]> => {
const response = await api.get(`/heaps/${heapId}/photo_ids`)
return response.data
},
addPhotos: async (heapId: string, photoIds: string[]) => {
const response = await api.post(`/heaps/${heapId}/photos`, {
photo_ids: photoIds,
})
return response.data
},
removePhotos: async (heapId: string, photoIds: string[]) => {
const response = await api.delete(`/heaps/${heapId}/photos`, {
data: { photo_ids: photoIds },
})
return response.data
},
}

View File

@@ -2,7 +2,7 @@ import { create } from 'zustand'
export type MediaType = 'photo' | 'video' | 'raw' | 'heic'
export type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
export type FlagFilter = 'any' | 'picked' | 'discarded' | 'unflagged'
export type FlagFilter = 'any' | 'discarded'
export interface FilterState {
q: string
@@ -12,6 +12,9 @@ export interface FilterState {
ratingMin: number // 0-5; 0 means no filter
colorLabel: ColorLabel | null
flag: FlagFilter
/** When set, restrict to photos in this heap. Independent of `activeHeapId`
* on the heap store — that's the target for the T shortcut. */
heapId: string | null
}
interface FilterStore extends FilterState {
@@ -24,6 +27,7 @@ interface FilterStore extends FilterState {
setRatingMin: (rating: number) => void
setColorLabel: (label: ColorLabel | null) => void
setFlag: (flag: FlagFilter) => void
setHeapId: (id: string | null) => void
setFilterBarOpen: (open: boolean) => void
toggleFilterBar: () => void
@@ -40,6 +44,7 @@ export const INITIAL_FILTERS: FilterState = {
ratingMin: 0,
colorLabel: null,
flag: 'any',
heapId: null,
}
export const useFilterStore = create<FilterStore>((set) => ({
@@ -58,6 +63,7 @@ export const useFilterStore = create<FilterStore>((set) => ({
setRatingMin: (ratingMin) => set({ ratingMin }),
setColorLabel: (colorLabel) => set({ colorLabel }),
setFlag: (flag) => set({ flag }),
setHeapId: (heapId) => set({ heapId }),
setFilterBarOpen: (filterBarOpen) => set({ filterBarOpen }),
toggleFilterBar: () => set((s) => ({ filterBarOpen: !s.filterBarOpen })),
@@ -76,9 +82,8 @@ export function filtersToParams(f: FilterState): Record<string, string | number>
if (f.mediaTypes.length > 0) params.media_type = f.mediaTypes.join(',')
if (f.ratingMin > 0) params.rating_min = f.ratingMin
if (f.colorLabel) params.color_label = f.colorLabel
if (f.flag === 'picked') params.is_picked = 'true'
else if (f.flag === 'discarded') params.is_discarded = 'true'
else if (f.flag === 'unflagged') params.is_picked = 'false'
if (f.flag === 'discarded') params.is_discarded = 'true'
if (f.heapId) params.heap_id = f.heapId
return params
}
@@ -91,6 +96,7 @@ export function hasActiveFilters(f: FilterState): boolean {
f.mediaTypes.length > 0 ||
f.ratingMin > 0 ||
f.colorLabel !== null ||
f.flag !== 'any'
f.flag !== 'any' ||
f.heapId !== null
)
}

View File

@@ -7,7 +7,6 @@ export interface Photo {
height: number | null
taken_at: string | null
rating: number
is_picked: boolean
is_discarded: boolean
file_hash: string
thumb_small?: string