fix: assorted UI polish from review pass

- FilterPill: drop the inline value text from the active state. Pills
  now stay the same width whether or not a filter is set; the popover
  is the canonical place to read the value, and the title attribute
  surfaces it on hover.
- TopBar: remove the search input — search lives in the filter bar now.
- FilterBar: add a search input on the left, with the pill cluster
  centered between it and a flex-shrink-0 Clear-all on the right.
- LeftSidebar / HeapsPanel: count badges use a fixed-width slot
  (h-5 min-w-[24px], tabular-nums) so counts line up in the same
  visual column across rows. Empty rows reserve the slot.
- LeftSidebar: pull section counts (All Photos, Rated, Duplicates,
  Discarded) from a new useLibraryStatsQuery hook backed by the
  expanded /library/stats endpoint. Tags count was already wired.
- backend/library: stats endpoint returns per-section counts that
  match the filter the sidebar applies on click.
- Stats invalidation hooked into the standard photo-mutation paths.
- RightSidebar header: h-12 to match TopBar height.
- Timeline sticky date overlay: only show once the natural in-grid
  header has scrolled OUT of the viewport. Avoids the duplicate-label
  flash when both labels would be visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 20:45:30 +02:00
parent 696477eefd
commit bd904aca36
13 changed files with 207 additions and 115 deletions

View File

@@ -12,30 +12,68 @@ router = APIRouter()
@router.get("/stats")
async def get_library_stats(db: AsyncSession = Depends(get_db)):
"""Get library statistics"""
# Count total photos
total_photos = await db.execute(
select(func.count(Photo.id)).where(Photo.media_type.in_(['photo', 'heic', 'raw']))
)
photo_count = total_photos.scalar()
# Count total videos
total_videos = await db.execute(
select(func.count(Photo.id)).where(Photo.media_type == 'video')
)
video_count = total_videos.scalar()
# Calculate total size
total_size = await db.execute(
select(func.sum(Photo.file_size))
)
size = total_size.scalar() or 0
"""Get library statistics + per-section counts. Each section count
matches the filter the sidebar applies when you click it, so the
sidebar badges and the timeline below them stay in sync.
- all_photos: non-discarded photos + videos (matches the All
Photos section's default filter)
- rated: non-discarded with rating >= 1
- duplicates: non-discarded with is_duplicate = true
- discarded: is_discarded = true
- total_size: raw bytes across every row, including discarded
"""
not_discarded = Photo.is_discarded.is_(False)
all_photos_count = (
await db.execute(select(func.count(Photo.id)).where(not_discarded))
).scalar() or 0
rated_count = (
await db.execute(
select(func.count(Photo.id)).where(not_discarded, Photo.rating >= 1)
)
).scalar() or 0
duplicates_count = (
await db.execute(
select(func.count(Photo.id)).where(
not_discarded, Photo.is_duplicate.is_(True)
)
)
).scalar() or 0
discarded_count = (
await db.execute(
select(func.count(Photo.id)).where(Photo.is_discarded.is_(True))
)
).scalar() or 0
# Legacy split (kept for the existing /stats consumers).
photo_count = (
await db.execute(
select(func.count(Photo.id)).where(
Photo.media_type.in_(['photo', 'heic', 'raw'])
)
)
).scalar() or 0
video_count = (
await db.execute(
select(func.count(Photo.id)).where(Photo.media_type == 'video')
)
).scalar() or 0
size = (await db.execute(select(func.sum(Photo.file_size)))).scalar() or 0
return {
"all_photos": all_photos_count,
"rated": rated_count,
"duplicates": duplicates_count,
"discarded": discarded_count,
"total_photos": photo_count,
"total_videos": video_count,
"total_size": size,
"total_size_gb": round(size / (1024**3), 2) if size else 0
"total_size_gb": round(size / (1024**3), 2) if size else 0,
}
@router.post("/scan")