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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user