feat: tag-grouped timeline view
Reworks the Tags sidebar entry from an expandable list of tags into a
single leaf entry. Clicking it switches the timeline grouping mode to
"tag" — every tag becomes a sticky-headered group, with an "Untagged"
group at the bottom for photos with no tags. A photo with N tags
appears in N groups. Existing filters and sort still apply within each
group.
Backend
- list_photos eagerly loads Photo.tags via selectinload to avoid an
N+1 round-trip.
- Each photo in the list response now carries a `tags: [{id, name,
color}]` array. The route stops using PhotoListResponse strict
validation (returns a plain dict with the same shape plus the new
field) so we don't have to extend the pydantic schema.
Frontend
- Photo TS type gains an optional tags field plus a PhotoTagSummary
alias.
- filterStore: new groupBy: 'date' | 'tag' field, default 'date',
with setGroupBy + URL sync via ?group=tag. clearAll resets it.
- usePhotosQuery threads groupBy through filtersToParams (it's
client-side only but kept in the params for cache key
consistency).
- LeftSidebar Tags entry is now a leaf node (no children), shows the
total tag photo count as the badge, and is highlighted when
groupBy === 'tag'. Click → setGroupBy('tag') without touching
other filters. Selecting "All Photos" resets groupBy back to
'date' via clearAll.
- Timeline.buildItems gets a third "tag" branch:
- Iterates photos × tags into per-tag buckets
- Photos with no tags go into an "Untagged" bucket
- Tag groups sorted alphabetically; Untagged pinned to the end
- Headers + rows pushed in the same shape the date branch uses,
so the existing sticky-header overlay works for free
- Selection state is by photo id, so a photo appearing in multiple
groups stays consistently selected/highlighted across instances.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, and_, or_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
@@ -25,7 +26,7 @@ from app.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("", response_model=PhotoListResponse)
|
||||
@router.get("")
|
||||
async def list_photos(
|
||||
q: Optional[str] = None,
|
||||
date_from: Optional[datetime] = None,
|
||||
@@ -47,8 +48,9 @@ async def list_photos(
|
||||
):
|
||||
"""List photos with filters and pagination"""
|
||||
|
||||
# Build query
|
||||
query = select(Photo)
|
||||
# Build query — eager-load tags so the response can include them
|
||||
# without an N+1 round-trip per photo.
|
||||
query = select(Photo).options(selectinload(Photo.tags))
|
||||
|
||||
# Apply filters
|
||||
filters = []
|
||||
@@ -180,14 +182,24 @@ async def list_photos(
|
||||
result = await db.execute(query)
|
||||
photos = result.scalars().all()
|
||||
|
||||
# Convert to response
|
||||
return PhotoListResponse(
|
||||
photos=[PhotoResponse.from_orm(photo) for photo in photos],
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
pages=(total + per_page - 1) // per_page
|
||||
)
|
||||
# Convert to response, attaching tags inline so the frontend can group
|
||||
# client-side without a second round-trip.
|
||||
photo_dicts = []
|
||||
for photo in photos:
|
||||
d = PhotoResponse.from_orm(photo).dict()
|
||||
d["tags"] = [
|
||||
{"id": t.id, "name": t.name, "color": t.color}
|
||||
for t in (photo.tags or [])
|
||||
]
|
||||
photo_dicts.append(d)
|
||||
|
||||
return {
|
||||
"photos": photo_dicts,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page if total else 0,
|
||||
}
|
||||
|
||||
@router.get("/{photo_id}")
|
||||
async def get_photo(
|
||||
|
||||
@@ -46,10 +46,10 @@ export function LeftSidebar() {
|
||||
const setFlag = useFilterStore((s) => s.setFlag)
|
||||
const setFolderId = useFilterStore((s) => s.setFolderId)
|
||||
const setDuplicates = useFilterStore((s) => s.setDuplicates)
|
||||
const setTagIds = useFilterStore((s) => s.setTagIds)
|
||||
const setGroupBy = useFilterStore((s) => s.setGroupBy)
|
||||
const filterFolderId = useFilterStore((s) => s.folderId)
|
||||
const filterDuplicates = useFilterStore((s) => s.duplicates)
|
||||
const filterTagIds = useFilterStore((s) => s.tagIds)
|
||||
const filterGroupBy = useFilterStore((s) => s.groupBy)
|
||||
const { data: allTags = [] } = useTagsQuery()
|
||||
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
||||
|
||||
@@ -142,15 +142,12 @@ export function LeftSidebar() {
|
||||
clearAllFilters()
|
||||
setDuplicates(true)
|
||||
break
|
||||
case 'tags':
|
||||
// Tags is a leaf entry, not expandable. Clicking switches the
|
||||
// timeline to grouped-by-tag mode without touching other filters.
|
||||
setGroupBy('tag')
|
||||
break
|
||||
default:
|
||||
if (id.startsWith('tag-')) {
|
||||
// Tag rows: filter to that single tag. Multi-tag filtering is
|
||||
// available via the FilterBar.
|
||||
const tagId = id.slice('tag-'.length)
|
||||
clearAllFilters()
|
||||
setTagIds([tagId])
|
||||
return
|
||||
}
|
||||
if (id.startsWith('folder-')) {
|
||||
// Folder rows: filter to that folder, clear other filters that
|
||||
// would compete (heap, discarded, etc.) so the user sees what they
|
||||
@@ -222,6 +219,9 @@ export function LeftSidebar() {
|
||||
: undefined,
|
||||
})
|
||||
|
||||
// Total tag count for the badge on the Tags entry.
|
||||
const tagsTotalCount = allTags.reduce((sum, t) => sum + (t.photo_count || 0), 0)
|
||||
|
||||
const libraryTree: TreeItem[] = [
|
||||
{
|
||||
id: 'library',
|
||||
@@ -230,19 +230,9 @@ export function LeftSidebar() {
|
||||
children: [
|
||||
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: 0 },
|
||||
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 },
|
||||
{ id: 'tags', label: 'Tags', icon: <TagIcon className="h-4 w-4" />, count: tagsTotalCount },
|
||||
{ id: 'duplicates', label: 'Duplicates', icon: <Copy className="h-4 w-4" />, count: 0 },
|
||||
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: 0 },
|
||||
{
|
||||
id: 'tags',
|
||||
label: 'Tags',
|
||||
icon: <TagIcon className="h-4 w-4" />,
|
||||
children: allTags.map((tag) => ({
|
||||
id: `tag-${tag.id}`,
|
||||
label: tag.name,
|
||||
icon: <TagIcon className="h-4 w-4" />,
|
||||
count: tag.photo_count,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -261,9 +251,8 @@ export function LeftSidebar() {
|
||||
if (id.startsWith('folder-')) {
|
||||
return filterFolderId === id.slice('folder-'.length)
|
||||
}
|
||||
if (id.startsWith('tag-')) {
|
||||
const tagId = id.slice('tag-'.length)
|
||||
return filterTagIds.length === 1 && filterTagIds[0] === tagId
|
||||
if (id === 'tags') {
|
||||
return filterGroupBy === 'tag'
|
||||
}
|
||||
if (id === 'all-photos') {
|
||||
return filterFolderId === null && selectedItem === 'all-photos'
|
||||
|
||||
@@ -24,17 +24,22 @@ type TimelineItem =
|
||||
| { type: 'row'; key: string; cells: PhotoCell[]; height: number }
|
||||
|
||||
/**
|
||||
* Build groups by month label when sorted by a date field. For non-temporal
|
||||
* sorts (filename / file_size / rating) we return a single un-headered group.
|
||||
* Build the flat header|row item array the virtualizer renders.
|
||||
*
|
||||
* Three modes:
|
||||
* - groupBy='tag': one bucket per unique tag (plus an "Untagged" bucket
|
||||
* for photos with no tags). A photo with N tags appears in N buckets.
|
||||
* - groupBy='date' AND sortBy is a date field: month buckets (existing).
|
||||
* - otherwise: one un-headered stream.
|
||||
*/
|
||||
function buildItems(
|
||||
photos: Photo[],
|
||||
columns: number,
|
||||
sortBy: string
|
||||
sortBy: string,
|
||||
groupBy: 'date' | 'tag'
|
||||
): TimelineItem[] {
|
||||
if (photos.length === 0) return []
|
||||
|
||||
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'
|
||||
const items: TimelineItem[] = []
|
||||
|
||||
// Helper: split a flat array of cells into rows of `columns` cells.
|
||||
@@ -50,6 +55,58 @@ function buildItems(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tag grouping ──────────────────────────────────────────────────────
|
||||
if (groupBy === 'tag') {
|
||||
// Bucket by tag name. A photo with multiple tags lands in multiple
|
||||
// buckets. Photos with no tags go into "Untagged".
|
||||
const tagBuckets = new Map<string, PhotoCell[]>()
|
||||
const untagged: PhotoCell[] = []
|
||||
|
||||
photos.forEach((photo, globalIndex) => {
|
||||
const cell: PhotoCell = { photo, globalIndex }
|
||||
const tags = photo.tags ?? []
|
||||
if (tags.length === 0) {
|
||||
untagged.push(cell)
|
||||
} else {
|
||||
for (const t of tags) {
|
||||
const arr = tagBuckets.get(t.name) ?? []
|
||||
arr.push(cell)
|
||||
tagBuckets.set(t.name, arr)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Sort tag groups alphabetically; Untagged goes at the end.
|
||||
const sortedTagNames = Array.from(tagBuckets.keys()).sort((a, b) =>
|
||||
a.localeCompare(b)
|
||||
)
|
||||
|
||||
let bucketIndex = 0
|
||||
for (const name of sortedTagNames) {
|
||||
items.push({
|
||||
type: 'header',
|
||||
key: `tag::${bucketIndex}::${name}`,
|
||||
label: name,
|
||||
height: HEADER_HEIGHT,
|
||||
})
|
||||
pushRowsForGroup(`tag::${bucketIndex}::${name}`, tagBuckets.get(name)!)
|
||||
bucketIndex++
|
||||
}
|
||||
if (untagged.length > 0) {
|
||||
items.push({
|
||||
type: 'header',
|
||||
key: `tag::${bucketIndex}::__untagged`,
|
||||
label: 'Untagged',
|
||||
height: HEADER_HEIGHT,
|
||||
})
|
||||
pushRowsForGroup(`tag::${bucketIndex}::untagged`, untagged)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// ── Date grouping (existing) ──────────────────────────────────────────
|
||||
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'
|
||||
|
||||
if (!isDateSort) {
|
||||
// No grouping — one row stream.
|
||||
const cells: PhotoCell[] = photos.map((photo, globalIndex) => ({
|
||||
@@ -119,6 +176,7 @@ export function Timeline() {
|
||||
} = usePhotoStore()
|
||||
|
||||
const sortBy = useFilterStore((s) => s.sortBy)
|
||||
const groupBy = useFilterStore((s) => s.groupBy)
|
||||
|
||||
// Calculate number of columns based on container width.
|
||||
const columns = useMemo(() => {
|
||||
@@ -138,11 +196,12 @@ export function Timeline() {
|
||||
// subscribing to the same query.
|
||||
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
|
||||
|
||||
// Build the flat virtualizer items: a mix of date-group headers and rows
|
||||
// of photos. Headers only appear when sorted by a date field.
|
||||
// Build the flat virtualizer items: a mix of group headers and rows of
|
||||
// photos. Date headers appear when sorted by a date field; tag headers
|
||||
// appear when groupBy === 'tag' (overrides date grouping).
|
||||
const items = useMemo(
|
||||
() => buildItems(photos, columns, sortBy),
|
||||
[photos, columns, sortBy]
|
||||
() => buildItems(photos, columns, sortBy, groupBy),
|
||||
[photos, columns, sortBy, groupBy]
|
||||
)
|
||||
|
||||
// Pre-computed offset of every header in the virtualizer's coordinate
|
||||
|
||||
@@ -79,6 +79,9 @@ function parseUrl(): Partial<FilterState> {
|
||||
|
||||
if (sp.get('duplicates') === 'true') out.duplicates = true
|
||||
|
||||
const groupBy = sp.get('group')
|
||||
if (groupBy === 'date' || groupBy === 'tag') out.groupBy = groupBy
|
||||
|
||||
const sortBy = sp.get('sort')
|
||||
if (sortBy && ALLOWED_SORT_FIELDS.includes(sortBy as SortField)) {
|
||||
out.sortBy = sortBy as SortField
|
||||
@@ -105,6 +108,7 @@ function writeUrl(f: FilterState) {
|
||||
if (f.folderId) sp.set('folder_id', f.folderId)
|
||||
if (f.tagIds.length > 0) sp.set('tag_ids', f.tagIds.join(','))
|
||||
if (f.duplicates) sp.set('duplicates', 'true')
|
||||
if (f.groupBy !== 'date') sp.set('group', f.groupBy)
|
||||
if (f.sortBy !== 'taken_at') sp.set('sort', f.sortBy)
|
||||
if (f.sortOrder !== 'desc') sp.set('order', f.sortOrder)
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export function usePhotosQuery() {
|
||||
const folderId = useFilterStore((s) => s.folderId)
|
||||
const tagIds = useFilterStore((s) => s.tagIds)
|
||||
const duplicates = useFilterStore((s) => s.duplicates)
|
||||
const groupBy = useFilterStore((s) => s.groupBy)
|
||||
const sortBy = useFilterStore((s) => s.sortBy)
|
||||
const sortOrder = useFilterStore((s) => s.sortOrder)
|
||||
|
||||
@@ -39,10 +40,11 @@ export function usePhotosQuery() {
|
||||
folderId,
|
||||
tagIds,
|
||||
duplicates,
|
||||
groupBy,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
}),
|
||||
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, folderId, tagIds, duplicates, sortBy, sortOrder]
|
||||
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, folderId, tagIds, duplicates, groupBy, sortBy, sortOrder]
|
||||
)
|
||||
|
||||
return useQuery({
|
||||
|
||||
@@ -10,6 +10,7 @@ export type SortField =
|
||||
| 'file_size'
|
||||
| 'rating'
|
||||
export type SortOrder = 'asc' | 'desc'
|
||||
export type GroupBy = 'date' | 'tag'
|
||||
|
||||
export interface FilterState {
|
||||
q: string
|
||||
@@ -28,6 +29,9 @@ export interface FilterState {
|
||||
tagIds: string[]
|
||||
/** When true, restrict to photos flagged as duplicates by the scanner. */
|
||||
duplicates: boolean
|
||||
/** Visual grouping mode. 'date' groups by month when sortBy is a date
|
||||
* field; 'tag' groups by photo tag membership. Independent of filters. */
|
||||
groupBy: GroupBy
|
||||
sortBy: SortField
|
||||
sortOrder: SortOrder
|
||||
}
|
||||
@@ -47,6 +51,7 @@ interface FilterStore extends FilterState {
|
||||
setTagIds: (ids: string[]) => void
|
||||
toggleTagId: (id: string) => void
|
||||
setDuplicates: (v: boolean) => void
|
||||
setGroupBy: (mode: GroupBy) => void
|
||||
setSortBy: (field: SortField) => void
|
||||
setSortOrder: (order: SortOrder) => void
|
||||
toggleSortOrder: () => void
|
||||
@@ -70,6 +75,7 @@ export const INITIAL_FILTERS: FilterState = {
|
||||
folderId: null,
|
||||
tagIds: [],
|
||||
duplicates: false,
|
||||
groupBy: 'date',
|
||||
sortBy: 'taken_at',
|
||||
sortOrder: 'desc',
|
||||
}
|
||||
@@ -100,6 +106,7 @@ export const useFilterStore = create<FilterStore>((set) => ({
|
||||
: [...s.tagIds, id],
|
||||
})),
|
||||
setDuplicates: (duplicates) => set({ duplicates }),
|
||||
setGroupBy: (groupBy) => set({ groupBy }),
|
||||
setSortBy: (sortBy) => set({ sortBy }),
|
||||
setSortOrder: (sortOrder) => set({ sortOrder }),
|
||||
toggleSortOrder: () =>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
export interface PhotoTagSummary {
|
||||
id: string
|
||||
name: string
|
||||
color: string | null
|
||||
}
|
||||
|
||||
export interface Photo {
|
||||
id: string
|
||||
filepath: string
|
||||
@@ -13,4 +19,5 @@ export interface Photo {
|
||||
thumb_small?: string
|
||||
thumb_medium?: string
|
||||
thumb_large?: string
|
||||
tags?: PhotoTagSummary[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user