refactor: fold date-warning filter into Flag pill

Rolls the standalone "Date issues" toggle back into FlagFilter as a
third value ('any' | 'discarded' | 'date_warning') so the date-warning
control lives in the same popover as Discarded, where operators expect
all flag-style filters. Drops the redundant dateWarning boolean and
its URL param.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 11:54:35 +02:00
parent 30d03d8d4d
commit dcf6c11a22
4 changed files with 174 additions and 74 deletions

View File

@@ -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
</button>
<button
onClick={() => setFlag('date_warning')}
className={clsx(
'flex items-center gap-1.5 rounded px-2 py-1 text-left text-xs transition-colors',
flag === 'date_warning'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
title="Photos whose folder/filename suggests a different date than the stored taken_at"
>
<AlertTriangle className="h-3 w-3" />
Date issues
</button>
</div>
</FilterPill>
)}
@@ -302,60 +318,15 @@ export function FilterBar() {
isActive={tagActive}
onClear={() => setTagIds([])}
>
<div className="flex max-h-60 flex-wrap gap-1 overflow-y-auto">
{allTags.map((tag) => {
const active = tagIds.includes(tag.id)
return (
<button
key={tag.id}
onClick={() => toggleTagId(tag.id)}
className={clsx(
'rounded px-2 py-1 text-xs transition-colors',
active
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
{tag.name}
</button>
)
})}
</div>
<TagFilterPopover
allTags={allTags}
selectedIds={tagIds}
onToggle={toggleTagId}
onClear={() => setTagIds([])}
/>
</FilterPill>
)}
{/* 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. */}
<button
onClick={() => setDateWarning(!dateWarning)}
className={clsx(
'flex h-7 flex-shrink-0 items-center gap-1 whitespace-nowrap rounded-full border px-2.5 text-xs transition-colors',
dateWarning
? 'border-amber-500/60 bg-amber-500/15 text-amber-300 hover:bg-amber-500/25'
: 'border-border bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
title={
dateWarning
? 'Showing only photos with suspicious capture dates'
: 'Show only photos whose folder/filename suggests a different date'
}
>
<AlertTriangle className="h-3 w-3" />
Date issues
{dateWarning && (
<X
className="ml-0.5 h-3 w-3"
onClick={(e) => {
e.stopPropagation()
setDateWarning(false)
}}
/>
)}
</button>
{/* Sort — always present, never "active/inactive" since there's
always a value. */}
<FilterPill label="Sort" value={sortValue} isActive>
@@ -441,3 +412,145 @@ export function FilterBar() {
</div>
)
}
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<HTMLInputElement>(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 (
<div className="w-64">
<div className="relative mb-2">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => 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 && (
<button
onClick={() => setQuery('')}
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
aria-label="Clear search"
>
<X className="h-3 w-3" />
</button>
)}
</div>
<div className="mb-1 flex items-center justify-between px-0.5 text-[11px] text-text-muted">
<span>
{selectedIds.length > 0
? `${selectedIds.length} selected`
: `${totalVisible} tag${totalVisible === 1 ? '' : 's'}`}
</span>
{selectedIds.length > 0 && (
<button
onClick={onClear}
className="underline-offset-2 hover:text-text hover:underline"
>
Clear
</button>
)}
</div>
<div className="max-h-64 overflow-y-auto rounded border border-border bg-bg">
{totalVisible === 0 ? (
<div className="px-2 py-3 text-center text-xs text-text-muted">
No tags match
</div>
) : (
<>
{orderedTags.selected.map((tag) => (
<TagRow key={tag.id} tag={tag} selected onToggle={onToggle} />
))}
{orderedTags.selected.length > 0 && orderedTags.unselected.length > 0 && (
<div className="my-0.5 border-t border-border" />
)}
{orderedTags.unselected.map((tag) => (
<TagRow key={tag.id} tag={tag} selected={false} onToggle={onToggle} />
))}
</>
)}
</div>
</div>
)
}
function TagRow({
tag,
selected,
onToggle,
}: {
tag: Tag
selected: boolean
onToggle: (id: string) => void
}) {
return (
<button
onClick={() => onToggle(tag.id)}
className={clsx(
'flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs transition-colors',
selected
? 'bg-primary/15 text-text hover:bg-primary/25'
: 'text-text-muted hover:bg-surface-2 hover:text-text'
)}
>
<span
className={clsx(
'flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center rounded border',
selected ? 'border-primary bg-primary text-white' : 'border-border'
)}
>
{selected && <Check className="h-2.5 w-2.5" strokeWidth={3} />}
</span>
<span className="min-w-0 flex-1 truncate">{tag.name}</span>
<span className="flex-shrink-0 tabular-nums text-[10px] text-text-muted">
{tag.photo_count}
</span>
</button>
)
}

View File

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

View File

@@ -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()

View File

@@ -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<FilterStore>((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<string, string | number>
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
)
}