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:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user