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
)
}