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
|
- all_photos: non-discarded photos + videos (matches the All
|
||||||
Photos section's default filter)
|
Photos section's default filter)
|
||||||
- rated: non-discarded with rating >= 1
|
- 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
|
- duplicates: non-discarded with is_duplicate = true
|
||||||
- discarded: is_discarded = true
|
- discarded: is_discarded = true
|
||||||
- total_size: raw bytes across every row, including discarded
|
- 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
|
).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 = (
|
duplicates_count = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(func.count(Photo.id)).where(
|
select(func.count(Photo.id)).where(
|
||||||
@@ -85,6 +95,7 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)):
|
|||||||
return {
|
return {
|
||||||
"all_photos": all_photos_count,
|
"all_photos": all_photos_count,
|
||||||
"rated": rated_count,
|
"rated": rated_count,
|
||||||
|
"colored": colored_count,
|
||||||
"duplicates": duplicates_count,
|
"duplicates": duplicates_count,
|
||||||
"discarded": discarded_count,
|
"discarded": discarded_count,
|
||||||
"total_photos": photo_count,
|
"total_photos": photo_count,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
HardDrive,
|
HardDrive,
|
||||||
Copy,
|
Copy,
|
||||||
Tag as TagIcon,
|
Tag as TagIcon,
|
||||||
|
Palette,
|
||||||
Layers2,
|
Layers2,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -244,6 +245,9 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
|
|||||||
case 'tags':
|
case 'tags':
|
||||||
navigateToSection('tags', { groupBy: 'tag' })
|
navigateToSection('tags', { groupBy: 'tag' })
|
||||||
break
|
break
|
||||||
|
case 'colors':
|
||||||
|
navigateToSection('colors', { groupBy: 'color' })
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
if (id.startsWith('folder-')) {
|
if (id.startsWith('folder-')) {
|
||||||
const folderId = id.slice('folder-'.length)
|
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: '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: '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: '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: '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 },
|
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: stats?.discarded ?? 0 },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '../../services/api'
|
} from '../../services/api'
|
||||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||||
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||||
|
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
|
||||||
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
|
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
|
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
|
||||||
@@ -32,6 +33,7 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
|
|||||||
const invalidatePhotoQueries = () => {
|
const invalidatePhotoQueries = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['photo'] })
|
queryClient.invalidateQueries({ queryKey: ['photo'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||||
}
|
}
|
||||||
|
|
||||||
const bulkRatingMutation = useMutation({
|
const bulkRatingMutation = useMutation({
|
||||||
|
|||||||
@@ -109,7 +109,6 @@ export function TopBar({
|
|||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className="text-[10px] text-black/50"
|
className="text-[10px] text-black/50"
|
||||||
style={{ textShadow: '0 1px 2px rgba(0,0,0,0.7)' }}
|
|
||||||
>
|
>
|
||||||
Built with hubris • {toRoman(new Date().getFullYear())}
|
Built with hubris • {toRoman(new Date().getFullYear())}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from '../../services/api'
|
} from '../../services/api'
|
||||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||||
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||||
|
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
|
||||||
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
|
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
import {
|
import {
|
||||||
@@ -147,6 +148,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['photo', photoId] })
|
queryClient.invalidateQueries({ queryKey: ['photo', photoId] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
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: TAGS_QUERY_KEY })
|
||||||
queryClient.invalidateQueries({ queryKey: ['photo', photoId] })
|
queryClient.invalidateQueries({ queryKey: ['photo', photoId] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||||
}
|
}
|
||||||
|
|
||||||
const addTagMutation = useMutation({
|
const addTagMutation = useMutation({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import clsx from 'clsx'
|
|||||||
import { photos as photosApi } from '../../services/api'
|
import { photos as photosApi } from '../../services/api'
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
|
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
||||||
|
|
||||||
/** Custom MIME used by HeapsPanel to recognise our drag payload. */
|
/** Custom MIME used by HeapsPanel to recognise our drag payload. */
|
||||||
export const PHOTO_DRAG_MIME = 'application/x-mulita-photos'
|
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'
|
export const THUMB_BADGE_SQUARE = 'w-5 justify-center !px-0'
|
||||||
/** Standard icon size for any lucide glyph inside a badge. */
|
/** Standard icon size for any lucide glyph inside a badge. */
|
||||||
export const THUMB_BADGE_ICON = 'h-3 w-3'
|
export const THUMB_BADGE_ICON = 'h-3 w-3'
|
||||||
export const THUMB_BADGE_PRIMARY =
|
/** Chiseled-pixel frame: 1px outline + inset top highlight + drop shadow.
|
||||||
'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)]'
|
* 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 =
|
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)]'
|
'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 =
|
export const THUMB_BADGE_PICK = `bg-pick ${THUMB_BADGE_FRAME}`
|
||||||
'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)]'
|
|
||||||
|
/** 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
|
// 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
|
// the first hit on a freshly-scanned library returns 404 "not ready" until
|
||||||
@@ -254,20 +264,32 @@ export function PhotoThumbnail({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* BL — rating */}
|
{/* BL — color label + rating. Color comes first (left of rating)
|
||||||
{photo.rating > 0 && (
|
* 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
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'absolute bottom-1 left-1 gap-0.5',
|
|
||||||
THUMB_BADGE_BASE,
|
THUMB_BADGE_BASE,
|
||||||
THUMB_BADGE_PRIMARY
|
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) => (
|
{Array.from({ length: photo.rating }).map((_, i) => (
|
||||||
<Star key={i} className={clsx(THUMB_BADGE_ICON, 'fill-white')} />
|
<Star key={i} className={clsx(THUMB_BADGE_ICON, 'fill-white')} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* BR — flags stack: heap (primary) · duplicate / discard (neutral).
|
{/* BR — flags stack: heap (primary) · duplicate / discard (neutral).
|
||||||
* Heap is the only user-state flag here so it gets primary; the
|
* Heap is the only user-state flag here so it gets primary; the
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useFilterStore } from '../../store/filterStore'
|
|||||||
import { PhotoThumbnail } from './PhotoThumbnail'
|
import { PhotoThumbnail } from './PhotoThumbnail'
|
||||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||||
|
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
|
|
||||||
// Layout constants for the grid + grouped headers.
|
// Layout constants for the grid + grouped headers.
|
||||||
@@ -26,11 +27,13 @@ type TimelineItem =
|
|||||||
/**
|
/**
|
||||||
* Build the flat header|row item array the virtualizer renders.
|
* 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
|
* - 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.
|
* 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"
|
* - groupBy='rating': one bucket per star rating 5..1 (plus "Unrated"
|
||||||
* for rating 0). Each photo lands in exactly one bucket.
|
* 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).
|
* - groupBy='date' AND sortBy is a date field: month buckets (existing).
|
||||||
* - otherwise: one un-headered stream.
|
* - otherwise: one un-headered stream.
|
||||||
*/
|
*/
|
||||||
@@ -39,7 +42,7 @@ function buildItems(
|
|||||||
columns: number,
|
columns: number,
|
||||||
rowHeight: number,
|
rowHeight: number,
|
||||||
sortBy: string,
|
sortBy: string,
|
||||||
groupBy: 'date' | 'tag' | 'rating'
|
groupBy: 'date' | 'tag' | 'rating' | 'color'
|
||||||
): TimelineItem[] {
|
): TimelineItem[] {
|
||||||
if (photos.length === 0) return []
|
if (photos.length === 0) return []
|
||||||
|
|
||||||
@@ -154,6 +157,55 @@ function buildItems(
|
|||||||
return items
|
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) ──────────────────────────────────────────
|
// ── Date grouping (existing) ──────────────────────────────────────────
|
||||||
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'
|
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,
|
* Per-section counts for the LeftSidebar badges (All Photos, Rated,
|
||||||
* Duplicates, Discarded). Cached briefly so navigating around doesn't
|
* Duplicates, Discarded). Cached briefly so navigating around doesn't
|
||||||
* re-fetch on every click; invalidated on photo mutations through the
|
* re-fetch on every click. Mutations that change a photo's rating,
|
||||||
* standard ['photos'] invalidation in the mutation onSuccess paths.
|
* discard flag, tags, etc. must invalidate LIBRARY_STATS_QUERY_KEY in
|
||||||
|
* their onSuccess so the badges stay live.
|
||||||
*/
|
*/
|
||||||
export function useLibraryStatsQuery() {
|
export function useLibraryStatsQuery() {
|
||||||
return useQuery<LibraryStats>({
|
return useQuery<LibraryStats>({
|
||||||
|
|||||||
@@ -409,6 +409,7 @@ export interface DuplicateGroupsResponse {
|
|||||||
export interface LibraryStats {
|
export interface LibraryStats {
|
||||||
all_photos: number
|
all_photos: number
|
||||||
rated: number
|
rated: number
|
||||||
|
colored: number
|
||||||
duplicates: number
|
duplicates: number
|
||||||
discarded: number
|
discarded: number
|
||||||
total_photos: number
|
total_photos: number
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export type SortField =
|
|||||||
| 'file_size'
|
| 'file_size'
|
||||||
| 'rating'
|
| 'rating'
|
||||||
export type SortOrder = 'asc' | 'desc'
|
export type SortOrder = 'asc' | 'desc'
|
||||||
export type GroupBy = 'date' | 'tag' | 'rating'
|
export type GroupBy = 'date' | 'tag' | 'rating' | 'color'
|
||||||
|
|
||||||
export interface FilterState {
|
export interface FilterState {
|
||||||
q: string
|
q: string
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface Photo {
|
|||||||
height: number | null
|
height: number | null
|
||||||
taken_at: string | null
|
taken_at: string | null
|
||||||
rating: number
|
rating: number
|
||||||
|
color_label?: string | null
|
||||||
is_discarded: boolean
|
is_discarded: boolean
|
||||||
is_duplicate: boolean
|
is_duplicate: boolean
|
||||||
file_hash: string
|
file_hash: string
|
||||||
|
|||||||
Reference in New Issue
Block a user