diff --git a/backend/app/routers/heaps.py b/backend/app/routers/heaps.py index 85e10cc..7ce7fa5 100644 --- a/backend/app/routers/heaps.py +++ b/backend/app/routers/heaps.py @@ -1,27 +1,191 @@ """ 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 \ No newline at end of file + 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.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} diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py index 0b963b7..bb17f15 100644 --- a/backend/app/routers/photos.py +++ b/backend/app/routers/photos.py @@ -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 @@ -95,7 +96,15 @@ async def list_photos( # 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)) diff --git a/frontend/src/components/KeyboardHints.tsx b/frontend/src/components/KeyboardHints.tsx index d182398..90a98f5 100644 --- a/frontend/src/components/KeyboardHints.tsx +++ b/frontend/src/components/KeyboardHints.tsx @@ -13,6 +13,7 @@ export function KeyboardHints() { { key: '1-5', action: 'Rate' }, { key: 'P', action: 'Pick' }, { key: 'X', action: 'Discard' }, + { key: 'T', action: 'Add to heap' }, { key: 'Space', action: 'Preview' }, { key: 'Esc', action: 'Deselect' }, ] diff --git a/frontend/src/components/heaps/HeapsPanel.tsx b/frontend/src/components/heaps/HeapsPanel.tsx new file mode 100644 index 0000000..664b575 --- /dev/null +++ b/frontend/src/components/heaps/HeapsPanel.tsx @@ -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 ( +
+ {/* Section header */} +
setExpanded((v) => !v)} + > + + + Heaps + +
+ + {expanded && ( +
+ {/* Inline create form */} + {creating && ( +
+ 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" + /> + +
+ )} + + {heaps.length === 0 && !creating && ( +
+ No heaps yet +
+ )} + + {heaps.map((heap) => { + const isFiltered = filterHeapId === heap.id + const isActive = heap.is_active + return ( +
setFilterHeapId(heap.id)} + > + + + {heap.name} + + {isActive && ( + + )} + {heap.photo_count > 0 && ( + + {heap.photo_count} + + )} + + +
+ ) + })} +
+ )} +
+ ) +} diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index 15eaa74..84ec8ef 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -19,6 +19,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 @@ -153,12 +154,6 @@ export function LeftSidebar() { type: 'folder', })) || [], }, - { - id: 'heaps', - label: 'Heaps', - icon: , - children: [], // Will be populated from API - }, ] const renderTreeItem = (item: TreeItem, depth: number = 0) => { @@ -257,6 +252,7 @@ export function LeftSidebar() { {/* Tree View */}
{libraryTree.map((item) => renderTreeItem(item))} +
{/* Bottom Actions */} diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx index fc73b25..41ddaac 100644 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/components/layout/TopBar.tsx @@ -10,6 +10,7 @@ import { Menu, Trash2, X, + ShoppingBasket, } from 'lucide-react' import clsx from 'clsx' import { usePhotoStore } from '../../store/photoStore' @@ -17,6 +18,7 @@ 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 @@ -57,6 +59,11 @@ export function TopBar() { const clearSelection = usePhotoStore((state) => state.clearSelection) const selectedCount = selectedPhotos.length const queryClient = useQueryClient() + + // Currently active heap (for the T shortcut). Shown as a pill so the user + // always knows where their next T-press will land. + const { data: heapsList = [] } = useHeapsQuery() + const activeHeap = heapsList.find((h) => h.is_active) // Mutation for discarding selected photos const discardPhotosMutation = useMutation({ @@ -90,6 +97,15 @@ export function TopBar() { Mulita

Mulita

+ {activeHeap && ( + + + {activeHeap.name} + + )} {selectedCount > 0 && ( <> diff --git a/frontend/src/hooks/useFilterUrlSync.ts b/frontend/src/hooks/useFilterUrlSync.ts index 978ac37..c165999 100644 --- a/frontend/src/hooks/useFilterUrlSync.ts +++ b/frontend/src/hooks/useFilterUrlSync.ts @@ -55,6 +55,9 @@ function parseUrl(): Partial { 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 diff --git a/frontend/src/hooks/useHeapsQuery.ts b/frontend/src/hooks/useHeapsQuery.ts new file mode 100644 index 0000000..18f76de --- /dev/null +++ b/frontend/src/hooks/useHeapsQuery.ts @@ -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({ + queryKey: HEAPS_QUERY_KEY, + queryFn: heapsApi.list, + staleTime: 30_000, + }) +} diff --git a/frontend/src/hooks/useKeyboardShortcuts.ts b/frontend/src/hooks/useKeyboardShortcuts.ts index a043186..81c306b 100644 --- a/frontend/src/hooks/useKeyboardShortcuts.ts +++ b/frontend/src/hooks/useKeyboardShortcuts.ts @@ -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 @@ -60,6 +62,52 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) { updateMutation.mutate({ id, data }) } + // T key: add the current selection to the active heap. If no heap is + // active or no photos are selected, it's a no-op with a toast hint. + const addToHeapMutation = useMutation({ + mutationFn: ({ heapId, photoIds }: { heapId: string; photoIds: string[] }) => + heapsApi.addPhotos(heapId, photoIds), + onSuccess: (data, vars) => { + const added = data?.added ?? 0 + const already = data?.already_present ?? 0 + const heap = (queryClient.getQueryData(HEAPS_QUERY_KEY) ?? []).find( + (h) => h.id === vars.heapId + ) + const heapName = heap?.name ?? 'heap' + if (added > 0) { + toast.success( + `Added to ${heapName}`, + `${added} photo${added > 1 ? 's' : ''}${already > 0 ? ` (${already} already present)` : ''}` + ) + } else if (already > 0) { + toast.info(`Already in ${heapName}`, `${already} photo${already > 1 ? 's' : ''}`) + } + queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY }) + queryClient.invalidateQueries({ queryKey: ['photos'] }) + }, + onError: (e: any) => toast.error('Failed to add to heap', e.message || 'Unknown error'), + }) + + const addSelectionToActiveHeap = () => { + 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 T') + return + } + const heapsList = queryClient.getQueryData(HEAPS_QUERY_KEY) ?? [] + const active = heapsList.find((h) => h.is_active) + if (!active) { + toast.info('No active heap', 'Click the target icon next to a heap to set it as active') + return + } + addToHeapMutation.mutate({ heapId: active.id, photoIds: ids }) + } + // Toggle sidebars useHotkeys('tab', onToggleLeftSidebar, HK_OPTS) useHotkeys('i', onToggleRightSidebar, HK_OPTS) @@ -133,4 +181,7 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) { }, HK_OPTS ) + + // T: add selection (or active photo) to the active heap. + useHotkeys('t', addSelectionToActiveHeap, HK_OPTS) } diff --git a/frontend/src/hooks/usePhotosQuery.ts b/frontend/src/hooks/usePhotosQuery.ts index 996a049..580d3cf 100644 --- a/frontend/src/hooks/usePhotosQuery.ts +++ b/frontend/src/hooks/usePhotosQuery.ts @@ -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({ diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index be7d72e..89c8df7 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -112,30 +112,49 @@ 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 => { 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 => { + 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 => { 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 => { + await api.delete(`/heaps/${heapId}`) + }, + + 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 }, } diff --git a/frontend/src/store/filterStore.ts b/frontend/src/store/filterStore.ts index d295bdf..a447352 100644 --- a/frontend/src/store/filterStore.ts +++ b/frontend/src/store/filterStore.ts @@ -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((set) => ({ @@ -58,6 +63,7 @@ export const useFilterStore = create((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 })), @@ -79,6 +85,7 @@ export function filtersToParams(f: FilterState): Record 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.heapId) params.heap_id = f.heapId return params } @@ -91,6 +98,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 ) }