diff --git a/frontend/src/components/filter/FilterBar.tsx b/frontend/src/components/filter/FilterBar.tsx index 8a385df..fd6b107 100644 --- a/frontend/src/components/filter/FilterBar.tsx +++ b/frontend/src/components/filter/FilterBar.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef, useState } from 'react' -import { Star, X, ArrowDown, ArrowUp, Search, AlertTriangle } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { Star, X, ArrowDown, ArrowUp, Search, AlertTriangle, Check } from 'lucide-react' import clsx from 'clsx' import { useFilterStore, @@ -8,6 +8,7 @@ import { type SortField, } from '../../store/filterStore' import { useTagsQuery } from '../../hooks/useTagsQuery' +import type { Tag } from '../../services/api' import { FilterPill } from './FilterPill' import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels' @@ -42,8 +43,6 @@ export function FilterBar() { const ratingMin = useFilterStore((s) => s.ratingMin) const colorLabel = useFilterStore((s) => s.colorLabel) const flag = useFilterStore((s) => s.flag) - const dateWarning = useFilterStore((s) => s.dateWarning) - const setDateWarning = useFilterStore((s) => s.setDateWarning) const sortBy = useFilterStore((s) => s.sortBy) const sortOrder = useFilterStore((s) => s.sortOrder) const tagIds = useFilterStore((s) => s.tagIds) @@ -108,7 +107,11 @@ export function FilterBar() { const colorValue = colorActive ? colorLabel : null const flagActive = flag !== 'any' - const flagValue = flagActive ? flag : null + const flagValue = flagActive + ? flag === 'date_warning' + ? 'date issues' + : flag + : null const tagActive = tagIds.length > 0 const activeTagNames = allTags @@ -290,6 +293,19 @@ export function FilterBar() { > Discarded + )} @@ -302,60 +318,15 @@ export function FilterBar() { isActive={tagActive} onClear={() => setTagIds([])} > -
- {allTags.map((tag) => { - const active = tagIds.includes(tag.id) - return ( - - ) - })} -
+ setTagIds([])} + /> )} - {/* Date issues — toggle-only pill. Restricts the grid to photos - * whose path-based date guess disagrees with the stored taken_at, - * so operators can find and fix a whole library's worth of - * corrupted or missing capture dates in one pass. Backed by the - * `photos.has_date_warning` column set at scan time. */} - - {/* Sort — always present, never "active/inactive" since there's always a value. */} @@ -441,3 +412,145 @@ export function FilterBar() { ) } + +interface TagFilterPopoverProps { + allTags: Tag[] + selectedIds: string[] + onToggle: (id: string) => void + onClear: () => void +} + +function TagFilterPopover({ + allTags, + selectedIds, + onToggle, + onClear, +}: TagFilterPopoverProps) { + const [query, setQuery] = useState('') + const inputRef = useRef(null) + + useEffect(() => { + inputRef.current?.focus() + }, []) + + const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]) + + // Selected tags pinned at top, remaining sorted by photo_count desc + // then name. Filtered by query (case-insensitive substring). + const orderedTags = useMemo(() => { + const q = query.trim().toLowerCase() + const match = (t: Tag) => !q || t.name.toLowerCase().includes(q) + const selected = allTags.filter((t) => selectedSet.has(t.id) && match(t)) + const unselected = allTags + .filter((t) => !selectedSet.has(t.id) && match(t)) + .sort((a, b) => { + if (b.photo_count !== a.photo_count) return b.photo_count - a.photo_count + return a.name.localeCompare(b.name) + }) + return { selected, unselected } + }, [allTags, selectedSet, query]) + + const totalVisible = orderedTags.selected.length + orderedTags.unselected.length + + return ( +
+
+ + setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape' && query) { + e.stopPropagation() + setQuery('') + } + }} + placeholder="Search tags…" + className="h-7 w-full rounded border border-border bg-bg pl-7 pr-6 text-xs text-text placeholder-text-muted focus:border-primary focus:outline-none" + /> + {query && ( + + )} +
+ +
+ + {selectedIds.length > 0 + ? `${selectedIds.length} selected` + : `${totalVisible} tag${totalVisible === 1 ? '' : 's'}`} + + {selectedIds.length > 0 && ( + + )} +
+ +
+ {totalVisible === 0 ? ( +
+ No tags match +
+ ) : ( + <> + {orderedTags.selected.map((tag) => ( + + ))} + {orderedTags.selected.length > 0 && orderedTags.unselected.length > 0 && ( +
+ )} + {orderedTags.unselected.map((tag) => ( + + ))} + + )} +
+
+ ) +} + +function TagRow({ + tag, + selected, + onToggle, +}: { + tag: Tag + selected: boolean + onToggle: (id: string) => void +}) { + return ( + + ) +} diff --git a/frontend/src/hooks/useFilterUrlSync.ts b/frontend/src/hooks/useFilterUrlSync.ts index 1d3194f..db0c206 100644 --- a/frontend/src/hooks/useFilterUrlSync.ts +++ b/frontend/src/hooks/useFilterUrlSync.ts @@ -18,7 +18,7 @@ const ALLOWED_COLORS: ColorLabel[] = [ 'blue', 'purple', ] -const ALLOWED_FLAGS: FlagFilter[] = ['any', 'discarded'] +const ALLOWED_FLAGS: FlagFilter[] = ['any', 'discarded', 'date_warning'] const ALLOWED_SORT_FIELDS: SortField[] = [ 'taken_at', 'added_at', @@ -89,7 +89,6 @@ function parseUrl(): HydratePayload { } if (sp.get('duplicates') === 'true') out.duplicates = true - if (sp.get('date_warning') === 'true') out.dateWarning = true const groupBy = sp.get('group') if (groupBy === 'date' || groupBy === 'tag') out.groupBy = groupBy @@ -124,7 +123,6 @@ function writeUrl(f: FilterState & { currentSection?: string }) { 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.dateWarning) sp.set('date_warning', 'true') if (f.groupBy !== 'date') sp.set('group', f.groupBy) if (f.currentSection && f.currentSection !== 'all-photos') sp.set('section', f.currentSection) diff --git a/frontend/src/hooks/usePhotosQuery.ts b/frontend/src/hooks/usePhotosQuery.ts index 50932b4..d8e831d 100644 --- a/frontend/src/hooks/usePhotosQuery.ts +++ b/frontend/src/hooks/usePhotosQuery.ts @@ -39,7 +39,6 @@ export function usePhotosQuery() { const folderId = useFilterStore((s) => s.folderId) const tagIds = useFilterStore((s) => s.tagIds) const duplicates = useFilterStore((s) => s.duplicates) - const dateWarning = useFilterStore((s) => s.dateWarning) const groupBy = useFilterStore((s) => s.groupBy) const sortBy = useFilterStore((s) => s.sortBy) const sortOrder = useFilterStore((s) => s.sortOrder) @@ -59,12 +58,11 @@ export function usePhotosQuery() { folderId, tagIds, duplicates, - dateWarning, groupBy, sortBy, sortOrder, }), - [q, dateFrom, dateTo, mediaTypes, ratingMin, ratingMax, colorLabel, flag, heapId, folderId, tagIds, duplicates, dateWarning, groupBy, sortBy, sortOrder] + [q, dateFrom, dateTo, mediaTypes, ratingMin, ratingMax, colorLabel, flag, heapId, folderId, tagIds, duplicates, groupBy, sortBy, sortOrder] ) const queryClient = useQueryClient() diff --git a/frontend/src/store/filterStore.ts b/frontend/src/store/filterStore.ts index 0aeca44..13ccce9 100644 --- a/frontend/src/store/filterStore.ts +++ b/frontend/src/store/filterStore.ts @@ -3,7 +3,7 @@ import type { ColorLabel } from '../constants/colorLabels' export type MediaType = 'photo' | 'video' | 'raw' | 'heic' export type { ColorLabel } -export type FlagFilter = 'any' | 'discarded' +export type FlagFilter = 'any' | 'discarded' | 'date_warning' export type SortField = | 'taken_at' | 'added_at' @@ -31,10 +31,6 @@ export interface FilterState { tagIds: string[] /** When true, restrict to photos flagged as duplicates by the scanner. */ duplicates: boolean - /** When true, restrict to photos whose path-based date guess disagrees - * with the stored taken_at (or taken_at is missing). Backed by the - * `photos.has_date_warning` column. */ - dateWarning: 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 @@ -72,7 +68,6 @@ interface FilterStore extends FilterState { setTagIds: (ids: string[]) => void toggleTagId: (id: string) => void setDuplicates: (v: boolean) => void - setDateWarning: (v: boolean) => void setGroupBy: (mode: GroupBy) => void setSortBy: (field: SortField) => void setSortOrder: (order: SortOrder) => void @@ -107,7 +102,6 @@ export const INITIAL_FILTERS: FilterState = { folderId: null, tagIds: [], duplicates: false, - dateWarning: false, groupBy: 'date', sortBy: 'taken_at', sortOrder: 'desc', @@ -130,7 +124,6 @@ function snapshotFilters(s: FilterState): FilterState { folderId: s.folderId, tagIds: [...s.tagIds], duplicates: s.duplicates, - dateWarning: s.dateWarning, groupBy: s.groupBy, sortBy: s.sortBy, sortOrder: s.sortOrder, @@ -166,7 +159,6 @@ export const useFilterStore = create((set) => ({ : [...s.tagIds, id], })), setDuplicates: (duplicates) => set({ duplicates }), - setDateWarning: (dateWarning) => set({ dateWarning }), setGroupBy: (groupBy) => set({ groupBy }), setSortBy: (sortBy) => set({ sortBy }), setSortOrder: (sortOrder) => set({ sortOrder }), @@ -221,11 +213,11 @@ export function filtersToParams(f: FilterState): Record if (f.ratingMax > 0) params.rating_max = f.ratingMax if (f.colorLabel) params.color_label = f.colorLabel if (f.flag === 'discarded') params.is_discarded = 'true' + if (f.flag === 'date_warning') params.has_date_warning = 'true' if (f.heapId) params.heap_id = f.heapId if (f.folderId) params.folder_id = f.folderId if (f.tagIds.length > 0) params.tag_ids = f.tagIds.join(',') if (f.duplicates) params.is_duplicate = 'true' - if (f.dateWarning) params.has_date_warning = 'true' params.sort = f.sortBy params.order = f.sortOrder return params @@ -245,7 +237,6 @@ export function hasActiveFilters(f: FilterState): boolean { f.heapId !== null || f.folderId !== null || f.tagIds.length > 0 || - f.duplicates || - f.dateWarning + f.duplicates ) }