Compare commits
4 Commits
30cbcb8cd1
...
9c9f5bd899
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c9f5bd899 | |||
| 1c6383e5a0 | |||
| c973ca0443 | |||
| 870e7dd3d3 |
@@ -36,6 +36,8 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)):
|
||||
- all_photos: non-discarded photos + videos (matches the All
|
||||
Photos section's default filter)
|
||||
- rated: non-discarded with rating >= 1
|
||||
- colored: non-discarded with a color_label set (matches the
|
||||
Colors grouped view's labeled buckets)
|
||||
- duplicates: non-discarded with is_duplicate = true
|
||||
- discarded: is_discarded = true
|
||||
- total_size: raw bytes across every row, including discarded
|
||||
@@ -52,6 +54,14 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)):
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
colored_count = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(
|
||||
not_discarded, Photo.color_label.is_not(None)
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
duplicates_count = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(
|
||||
@@ -85,6 +95,7 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)):
|
||||
return {
|
||||
"all_photos": all_photos_count,
|
||||
"rated": rated_count,
|
||||
"colored": colored_count,
|
||||
"duplicates": duplicates_count,
|
||||
"discarded": discarded_count,
|
||||
"total_photos": photo_count,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
HardDrive,
|
||||
Copy,
|
||||
Tag as TagIcon,
|
||||
Palette,
|
||||
Layers2,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
@@ -244,6 +245,9 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
|
||||
case 'tags':
|
||||
navigateToSection('tags', { groupBy: 'tag' })
|
||||
break
|
||||
case 'colors':
|
||||
navigateToSection('colors', { groupBy: 'color' })
|
||||
break
|
||||
default:
|
||||
if (id.startsWith('folder-')) {
|
||||
const folderId = id.slice('folder-'.length)
|
||||
@@ -344,6 +348,7 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
|
||||
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: stats?.all_photos ?? 0 },
|
||||
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: stats?.rated ?? 0 },
|
||||
{ id: 'tags', label: 'Tags', icon: <TagIcon className="h-4 w-4" />, count: tagsTotalCount },
|
||||
{ id: 'colors', label: 'Colors', icon: <Palette className="h-4 w-4" />, count: stats?.colored ?? 0 },
|
||||
{ id: 'duplicates', label: 'Duplicates', icon: <Copy className="h-4 w-4" />, count: stats?.duplicates ?? 0 },
|
||||
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: stats?.discarded ?? 0 },
|
||||
],
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '../../services/api'
|
||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
|
||||
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
|
||||
import { toast } from '../ToastContainer'
|
||||
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
|
||||
@@ -32,6 +33,7 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
|
||||
const invalidatePhotoQueries = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['photo'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||
}
|
||||
|
||||
const bulkRatingMutation = useMutation({
|
||||
|
||||
@@ -109,7 +109,6 @@ export function TopBar({
|
||||
)}
|
||||
<span
|
||||
className="text-[10px] text-black/50"
|
||||
style={{ textShadow: '0 1px 2px rgba(0,0,0,0.7)' }}
|
||||
>
|
||||
Built with hubris • {toRoman(new Date().getFullYear())}
|
||||
</span>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '../../services/api'
|
||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
|
||||
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
|
||||
import { toast } from '../ToastContainer'
|
||||
import {
|
||||
@@ -147,6 +148,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['photo', photoId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -194,6 +196,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
||||
queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY })
|
||||
queryClient.invalidateQueries({ queryKey: ['photo', photoId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||
}
|
||||
|
||||
const addTagMutation = useMutation({
|
||||
|
||||
@@ -4,6 +4,7 @@ import clsx from 'clsx'
|
||||
import { photos as photosApi } from '../../services/api'
|
||||
import type { Photo } from '../../types/photo'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
||||
|
||||
/** Custom MIME used by HeapsPanel to recognise our drag payload. */
|
||||
export const PHOTO_DRAG_MIME = 'application/x-mulita-photos'
|
||||
@@ -28,12 +29,21 @@ export const THUMB_BADGE_BASE =
|
||||
export const THUMB_BADGE_SQUARE = 'w-5 justify-center !px-0'
|
||||
/** Standard icon size for any lucide glyph inside a badge. */
|
||||
export const THUMB_BADGE_ICON = 'h-3 w-3'
|
||||
export const THUMB_BADGE_PRIMARY =
|
||||
'bg-primary shadow-[0_0_0_1px_rgba(0,0,0,0.55),inset_0_1px_0_rgba(255,255,255,0.28),0_1px_2px_rgba(0,0,0,0.5)]'
|
||||
/** Chiseled-pixel frame: 1px outline + inset top highlight + drop shadow.
|
||||
* Shared by every "user-affirmed" variant so any swatch (primary, pick,
|
||||
* color label) reads as part of the same badge family. */
|
||||
const THUMB_BADGE_FRAME =
|
||||
'shadow-[0_0_0_1px_rgba(0,0,0,0.55),inset_0_1px_0_rgba(255,255,255,0.28),0_1px_2px_rgba(0,0,0,0.5)]'
|
||||
export const THUMB_BADGE_PRIMARY = `bg-primary ${THUMB_BADGE_FRAME}`
|
||||
export const THUMB_BADGE_NEUTRAL =
|
||||
'bg-black/75 backdrop-blur-sm shadow-[0_0_0_1px_rgba(255,255,255,0.12),inset_0_1px_0_rgba(255,255,255,0.08),0_1px_2px_rgba(0,0,0,0.55)]'
|
||||
export const THUMB_BADGE_PICK =
|
||||
'bg-pick shadow-[0_0_0_1px_rgba(0,0,0,0.55),inset_0_1px_0_rgba(255,255,255,0.28),0_1px_2px_rgba(0,0,0,0.5)]'
|
||||
export const THUMB_BADGE_PICK = `bg-pick ${THUMB_BADGE_FRAME}`
|
||||
|
||||
/** Tailwind bg-class for each color label, looked up at render time so the
|
||||
* classnames are statically present in the source for the JIT to scan. */
|
||||
const COLOR_LABEL_BG: Record<string, string> = Object.fromEntries(
|
||||
COLOR_LABEL_OPTIONS.map((o) => [o.value, o.className])
|
||||
)
|
||||
|
||||
// Auto-retry schedule (ms). Backend generates thumbs on-demand via Celery, so
|
||||
// the first hit on a freshly-scanned library returns 404 "not ready" until
|
||||
@@ -254,18 +264,30 @@ export function PhotoThumbnail({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* BL — rating */}
|
||||
{photo.rating > 0 && (
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute bottom-1 left-1 gap-0.5',
|
||||
THUMB_BADGE_BASE,
|
||||
THUMB_BADGE_PRIMARY
|
||||
{/* BL — color label + rating. Color comes first (left of rating)
|
||||
* so the swatch reads as a "category dot" prefixing the stars. */}
|
||||
{(photo.color_label || photo.rating > 0) && (
|
||||
<div className="absolute bottom-1 left-1 flex items-center gap-1">
|
||||
{photo.color_label && COLOR_LABEL_BG[photo.color_label] && (
|
||||
<div
|
||||
className={clsx(
|
||||
THUMB_BADGE_BASE,
|
||||
THUMB_BADGE_SQUARE,
|
||||
COLOR_LABEL_BG[photo.color_label],
|
||||
THUMB_BADGE_FRAME
|
||||
)}
|
||||
title={`Color label: ${photo.color_label}`}
|
||||
/>
|
||||
)}
|
||||
{photo.rating > 0 && (
|
||||
<div
|
||||
className={clsx('gap-0.5', THUMB_BADGE_BASE, THUMB_BADGE_PRIMARY)}
|
||||
>
|
||||
{Array.from({ length: photo.rating }).map((_, i) => (
|
||||
<Star key={i} className={clsx(THUMB_BADGE_ICON, 'fill-white')} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{Array.from({ length: photo.rating }).map((_, i) => (
|
||||
<Star key={i} className={clsx(THUMB_BADGE_ICON, 'fill-white')} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useFilterStore } from '../../store/filterStore'
|
||||
import { PhotoThumbnail } from './PhotoThumbnail'
|
||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
||||
import type { Photo } from '../../types/photo'
|
||||
|
||||
// Layout constants for the grid + grouped headers.
|
||||
@@ -26,11 +27,13 @@ type TimelineItem =
|
||||
/**
|
||||
* Build the flat header|row item array the virtualizer renders.
|
||||
*
|
||||
* Four modes:
|
||||
* Five 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='rating': one bucket per star rating 5..1 (plus "Unrated"
|
||||
* for rating 0). Each photo lands in exactly one bucket.
|
||||
* - groupBy='color': one bucket per color label, in canonical order
|
||||
* (plus an "Uncolored" bucket for photos with no label).
|
||||
* - groupBy='date' AND sortBy is a date field: month buckets (existing).
|
||||
* - otherwise: one un-headered stream.
|
||||
*/
|
||||
@@ -39,7 +42,7 @@ function buildItems(
|
||||
columns: number,
|
||||
rowHeight: number,
|
||||
sortBy: string,
|
||||
groupBy: 'date' | 'tag' | 'rating'
|
||||
groupBy: 'date' | 'tag' | 'rating' | 'color'
|
||||
): TimelineItem[] {
|
||||
if (photos.length === 0) return []
|
||||
|
||||
@@ -154,6 +157,55 @@ function buildItems(
|
||||
return items
|
||||
}
|
||||
|
||||
// ── Color label grouping ──────────────────────────────────────────────
|
||||
if (groupBy === 'color') {
|
||||
// Bucket by color_label. Each photo lands in exactly one bucket;
|
||||
// photos with no label go into "Uncolored".
|
||||
const colorBuckets = new Map<string, PhotoCell[]>()
|
||||
const uncolored: PhotoCell[] = []
|
||||
|
||||
photos.forEach((photo, globalIndex) => {
|
||||
const cell: PhotoCell = { photo, globalIndex }
|
||||
const label = photo.color_label
|
||||
if (label) {
|
||||
const arr = colorBuckets.get(label) ?? []
|
||||
arr.push(cell)
|
||||
colorBuckets.set(label, arr)
|
||||
} else {
|
||||
uncolored.push(cell)
|
||||
}
|
||||
})
|
||||
|
||||
// Walk the canonical color order so headers always read R-O-Y-G-B-P,
|
||||
// matching every other color UI in the app. Skip empty buckets and
|
||||
// ignore any unexpected label values that aren't in the canonical
|
||||
// list (they'd be invalid backend state).
|
||||
let bucketIndex = 0
|
||||
for (const { value } of COLOR_LABEL_OPTIONS) {
|
||||
const cells = colorBuckets.get(value)
|
||||
if (!cells || cells.length === 0) continue
|
||||
const label = value.charAt(0).toUpperCase() + value.slice(1)
|
||||
items.push({
|
||||
type: 'header',
|
||||
key: `color::${bucketIndex}::${value}`,
|
||||
label,
|
||||
height: HEADER_HEIGHT,
|
||||
})
|
||||
pushRowsForGroup(`color::${bucketIndex}::${value}`, cells)
|
||||
bucketIndex++
|
||||
}
|
||||
if (uncolored.length > 0) {
|
||||
items.push({
|
||||
type: 'header',
|
||||
key: `color::${bucketIndex}::__uncolored`,
|
||||
label: 'Uncolored',
|
||||
height: HEADER_HEIGHT,
|
||||
})
|
||||
pushRowsForGroup(`color::${bucketIndex}::uncolored`, uncolored)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// ── Date grouping (existing) ──────────────────────────────────────────
|
||||
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@ export const LIBRARY_STATS_QUERY_KEY = ['library', 'stats'] as const
|
||||
/**
|
||||
* Per-section counts for the LeftSidebar badges (All Photos, Rated,
|
||||
* Duplicates, Discarded). Cached briefly so navigating around doesn't
|
||||
* re-fetch on every click; invalidated on photo mutations through the
|
||||
* standard ['photos'] invalidation in the mutation onSuccess paths.
|
||||
* re-fetch on every click. Mutations that change a photo's rating,
|
||||
* discard flag, tags, etc. must invalidate LIBRARY_STATS_QUERY_KEY in
|
||||
* their onSuccess so the badges stay live.
|
||||
*/
|
||||
export function useLibraryStatsQuery() {
|
||||
return useQuery<LibraryStats>({
|
||||
|
||||
@@ -409,6 +409,7 @@ export interface DuplicateGroupsResponse {
|
||||
export interface LibraryStats {
|
||||
all_photos: number
|
||||
rated: number
|
||||
colored: number
|
||||
duplicates: number
|
||||
discarded: number
|
||||
total_photos: number
|
||||
|
||||
@@ -11,7 +11,7 @@ export type SortField =
|
||||
| 'file_size'
|
||||
| 'rating'
|
||||
export type SortOrder = 'asc' | 'desc'
|
||||
export type GroupBy = 'date' | 'tag' | 'rating'
|
||||
export type GroupBy = 'date' | 'tag' | 'rating' | 'color'
|
||||
|
||||
export interface FilterState {
|
||||
q: string
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface Photo {
|
||||
height: number | null
|
||||
taken_at: string | null
|
||||
rating: number
|
||||
color_label?: string | null
|
||||
is_discarded: boolean
|
||||
is_duplicate: boolean
|
||||
file_hash: string
|
||||
|
||||
Reference in New Issue
Block a user