feat(topbar): drop search box to reclaim filter-bar space
The search box on the right edge of the filter bar wasn't pulling its weight — kills it entirely along with the supporting plumbing: - FilterBar: remove input + Search icon import + local/debounced state - filterStore: drop `q`, `setQ`, plus all references in INITIAL_FILTERS, filtersToParams, hasActiveFilters, snapshotFilters - usePhotosQuery: stop passing q through filtersToParams - useFilterUrlSync: drop the `q` URL param read/write - PhotoThumbnail + PreviewView: remove the search-match banner/chip and findSearchMatch helper imports - Timeline + MemoriesView: stop subscribing to / forwarding the prop - useKeyboardShortcuts: drop the `/` and Cmd+F focus hotkeys - KeyboardHints: drop the `/` hint and the now-stale `?` collision note - delete hooks/useSearchQuery.ts (no callers) and lib/searchMatch.ts Backend /photos/search endpoint left untouched — no UI reaches it now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -65,7 +65,6 @@ function getHints(opts: {
|
||||
{ key: 'Space', action: 'Preview' },
|
||||
{ key: 'Tab', action: 'Library panel' },
|
||||
{ key: 'I', action: 'Info panel' },
|
||||
{ key: '/', action: 'Search' },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -81,8 +80,7 @@ export function KeyboardHints() {
|
||||
localStorage.setItem(STORAGE_KEY, collapsed ? '1' : '0')
|
||||
}, [collapsed])
|
||||
|
||||
// `H` toggles the panel. `?` (shift+/) collides with the global `/`
|
||||
// search shortcut, so we use a plain letter instead.
|
||||
// `H` toggles the panel.
|
||||
useHotkeys('h', () => setCollapsed((c) => !c), { preventDefault: true })
|
||||
|
||||
const hints = getHints({ selectedCount, currentSection, viewMode })
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Star,
|
||||
X,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Search,
|
||||
PanelLeftOpen,
|
||||
PanelLeftClose,
|
||||
PanelRightOpen,
|
||||
@@ -26,7 +24,6 @@ import {
|
||||
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
||||
import { FilterPill } from './FilterPill'
|
||||
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
@@ -37,8 +34,6 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { MultiSelect } from '@/components/ui/multi-select'
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
|
||||
const MEDIA_TYPES: { value: MediaType; label: string }[] = [
|
||||
{ value: 'photo', label: 'Photo' },
|
||||
{ value: 'video', label: 'Video' },
|
||||
@@ -112,26 +107,6 @@ export function FilterBar({
|
||||
|
||||
const { data: allTags = [] } = useTagsQuery()
|
||||
|
||||
// Search box. Local state mirrors the store so typing stays responsive
|
||||
// while we debounce store writes (each store write triggers a re-fetch).
|
||||
const storeQ = useFilterStore((s) => s.q)
|
||||
const setStoreQ = useFilterStore((s) => s.setQ)
|
||||
const [searchQuery, setSearchQuery] = useState(storeQ)
|
||||
useEffect(() => {
|
||||
setSearchQuery(storeQ)
|
||||
}, [storeQ])
|
||||
const debounceRef = useRef<number | null>(null)
|
||||
useEffect(() => {
|
||||
if (searchQuery === storeQ) return
|
||||
if (debounceRef.current) window.clearTimeout(debounceRef.current)
|
||||
debounceRef.current = window.setTimeout(() => {
|
||||
setStoreQ(searchQuery)
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
return () => {
|
||||
if (debounceRef.current) window.clearTimeout(debounceRef.current)
|
||||
}
|
||||
}, [searchQuery, storeQ, setStoreQ])
|
||||
|
||||
// Pre-compute pill values + active flags so the JSX stays terse.
|
||||
const typeActive = mediaTypes.length > 0
|
||||
const typeValue = typeActive
|
||||
@@ -527,40 +502,6 @@ export function FilterBar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search — pinned to the right edge of the bar. Same id as before
|
||||
* so the global "/" focus shortcut still finds it. */}
|
||||
<div className="relative w-56 flex-shrink-0">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted" />
|
||||
<Input
|
||||
id="topbar-search"
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setSearchQuery('')
|
||||
setStoreQ('')
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
placeholder="Search photos…"
|
||||
className="h-7 w-full rounded-full bg-surface-2 pl-8 pr-7 text-xs"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('')
|
||||
setStoreQ('')
|
||||
}}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-full p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
|
||||
title="Clear search (Esc)"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right sidebar toggle — pinned to the far-right edge. */}
|
||||
<button
|
||||
onClick={onToggleRightSidebar}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from '../../services/api'
|
||||
import type { Photo } from '../../types/photo'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
import { useViewSettingsStore } from '../../store/viewSettingsStore'
|
||||
import { PhotoThumbnail } from '../timeline/PhotoThumbnail'
|
||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||
@@ -53,7 +52,6 @@ export function MemoriesView() {
|
||||
const selectRange = usePhotoStore((s) => s.selectRange)
|
||||
const openPreview = usePhotoStore((s) => s.openPreview)
|
||||
const viewMode = usePhotoStore((s) => s.viewMode)
|
||||
const searchQuery = useFilterStore((s) => s.q)
|
||||
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
|
||||
|
||||
const visibleSequenceRef = useRef<string[]>([])
|
||||
@@ -216,7 +214,6 @@ export function MemoriesView() {
|
||||
fill
|
||||
isSelected={selectedPhotos.includes(m.id)}
|
||||
isInActiveHeap={activeHeapMembers.has(m.id)}
|
||||
searchQuery={searchQuery}
|
||||
onClick={handleCellClick}
|
||||
onDoubleClick={handleCellDoubleClick}
|
||||
/>
|
||||
|
||||
@@ -3,10 +3,8 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { X, Info } from 'lucide-react'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||
import { photos as photosApi } from '../../services/api'
|
||||
import { findSearchMatch } from '../../lib/searchMatch'
|
||||
import type { Photo } from '../../types/photo'
|
||||
import { PreviewImage } from './PreviewImage'
|
||||
import { PreviewFilmstrip } from './PreviewFilmstrip'
|
||||
@@ -86,15 +84,6 @@ export function PreviewView() {
|
||||
const currentPhoto: Photo | undefined =
|
||||
photoInListById ?? photos[safeIndex] ?? standalonePhoto
|
||||
|
||||
// Carry the timeline's search-match chip into preview so the user
|
||||
// doesn't lose the "why did this photo come back" context when they
|
||||
// zoom in. Pure recompute — same helper the thumbnail uses.
|
||||
const searchQuery = useFilterStore((s) => s.q)
|
||||
const searchMatch =
|
||||
currentPhoto && searchQuery.trim()
|
||||
? findSearchMatch(currentPhoto, searchQuery)
|
||||
: null
|
||||
|
||||
// Keep the latest photos array + active id in a ref so the keyboard
|
||||
// handlers ALWAYS read the freshest state. Without this, react-hotkeys-
|
||||
// hook can fire a closure that captured an older photos array (e.g.
|
||||
@@ -237,34 +226,6 @@ export function PreviewView() {
|
||||
<div className="text-text-muted">
|
||||
{safeIndex + 1} / {photos.length}
|
||||
</div>
|
||||
{searchMatch && (
|
||||
<div
|
||||
className="mt-1 flex items-center gap-1.5 text-[11px]"
|
||||
title={`Matched on ${searchMatch.label.toLowerCase()}: ${searchMatch.excerpt}`}
|
||||
>
|
||||
<span className="shrink-0 rounded-sm bg-primary/80 px-1 text-[9px] font-semibold uppercase tracking-wider">
|
||||
{searchMatch.label}
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{searchMatch.matchLength > 0 ? (
|
||||
<>
|
||||
{searchMatch.excerpt.slice(0, searchMatch.matchStart)}
|
||||
<mark className="rounded-sm bg-amber-400/90 px-0.5 font-semibold text-black">
|
||||
{searchMatch.excerpt.slice(
|
||||
searchMatch.matchStart,
|
||||
searchMatch.matchStart + searchMatch.matchLength,
|
||||
)}
|
||||
</mark>
|
||||
{searchMatch.excerpt.slice(
|
||||
searchMatch.matchStart + searchMatch.matchLength,
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
searchMatch.excerpt
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top-right action buttons */}
|
||||
|
||||
@@ -13,7 +13,6 @@ import { photos as photosApi } from '../../services/api'
|
||||
import type { Photo } from '../../types/photo'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
||||
import { findSearchMatch } from '../../lib/searchMatch'
|
||||
|
||||
/** Custom MIME used by HeapsPanel to recognise our drag payload. */
|
||||
export const PHOTO_DRAG_MIME = 'application/x-mulita-photos'
|
||||
@@ -82,10 +81,6 @@ interface PhotoThumbnailProps {
|
||||
* view is filtered to the active heap, where every cell would
|
||||
* otherwise be wash-green. */
|
||||
hideActiveHeapTint?: boolean
|
||||
/** Active text-search query, passed in from Timeline rather than
|
||||
* subscribed-to here so we don't have N thumbnails each running a
|
||||
* per-keystroke selector. Empty string disables the highlight. */
|
||||
searchQuery?: string
|
||||
/** Called with the photo + the native event. Pass a stable handler
|
||||
* (useCallback with store-action deps) so React.memo can actually
|
||||
* elide re-renders on unrelated store updates. */
|
||||
@@ -101,7 +96,6 @@ function PhotoThumbnailImpl({
|
||||
isInActiveHeap = false,
|
||||
hideDiscardedTint = false,
|
||||
hideActiveHeapTint = false,
|
||||
searchQuery = '',
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
}: PhotoThumbnailProps) {
|
||||
@@ -127,16 +121,6 @@ function PhotoThumbnailImpl({
|
||||
// thumbnail badge all read from one source of truth.
|
||||
const dateWarning = photo.has_date_warning === true
|
||||
|
||||
// When a query is active we compute which field on this photo
|
||||
// matched so the user can see *why* the photo came back from the
|
||||
// search instead of guessing. The query itself is passed down as a
|
||||
// prop (subscribed once at the Timeline level) — having every
|
||||
// thumbnail subscribe individually multiplied keystroke renders by
|
||||
// the row count.
|
||||
const searchMatch = searchQuery.trim()
|
||||
? findSearchMatch(photo, searchQuery)
|
||||
: null
|
||||
|
||||
// Square cells (Lightroom-style grid). Variable-aspect cells previously
|
||||
// overflowed their row because TanStack Virtual estimates row height as a
|
||||
// single fixed value — portraits in a landscape row would overlap the row
|
||||
@@ -347,42 +331,6 @@ function PhotoThumbnailImpl({
|
||||
* Corner ownership is fixed: TL=selection, TR=file-type,
|
||||
* BL=rating, BR=flags. This keeps badges from stacking or colliding. */}
|
||||
|
||||
{/* Top banner — shown only when a text search is active, explains
|
||||
* which metadata field of this photo matched the user's query so
|
||||
* they don't have to guess why the photo came back. The matched
|
||||
* substring is wrapped in an amber highlight span so the user
|
||||
* can see *exactly* what hit — e.g. "**Mul**ti-segment" when
|
||||
* searching "mul". Sits above the TL/TR corner badges (lower
|
||||
* z-index) so they still read on top of long excerpts. */}
|
||||
{searchMatch && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-0 z-[1] flex items-center gap-1 bg-black/75 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm"
|
||||
title={`Matched on ${searchMatch.label.toLowerCase()}: ${searchMatch.excerpt}`}
|
||||
>
|
||||
<span className="shrink-0 rounded-sm bg-primary/80 px-1 text-[9px] font-semibold uppercase tracking-wider">
|
||||
{searchMatch.label}
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{searchMatch.matchLength > 0 ? (
|
||||
<>
|
||||
{searchMatch.excerpt.slice(0, searchMatch.matchStart)}
|
||||
<mark className="rounded-sm bg-amber-400/90 px-0.5 font-semibold text-black">
|
||||
{searchMatch.excerpt.slice(
|
||||
searchMatch.matchStart,
|
||||
searchMatch.matchStart + searchMatch.matchLength
|
||||
)}
|
||||
</mark>
|
||||
{searchMatch.excerpt.slice(
|
||||
searchMatch.matchStart + searchMatch.matchLength
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
searchMatch.excerpt
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TL — owner badge for shared photos. Selection itself is
|
||||
* conveyed by the ring/outline on the wrapper, no badge needed. */}
|
||||
{photo.owner_username && (
|
||||
|
||||
@@ -177,10 +177,6 @@ export function Timeline() {
|
||||
const currentSection = useFilterStore((s) => s.currentSection)
|
||||
const flag = useFilterStore((s) => s.flag)
|
||||
const filterHeapId = useFilterStore((s) => s.heapId)
|
||||
// Subscribed once at this level and passed down to each
|
||||
// PhotoThumbnail as a prop. Previously every thumbnail had its own
|
||||
// subscription, multiplying keystroke renders by the row count.
|
||||
const searchQuery = useFilterStore((s) => s.q)
|
||||
const viewMode = usePhotoStore((s) => s.viewMode)
|
||||
const thumbnailSize = useViewSettingsStore((s) => s.thumbnailSize)
|
||||
|
||||
@@ -856,7 +852,6 @@ export function Timeline() {
|
||||
isInActiveHeap={activeHeapMembers.has(photo.id)}
|
||||
hideDiscardedTint={hideDiscardedTint}
|
||||
hideActiveHeapTint={hideActiveHeapTint}
|
||||
searchQuery={searchQuery}
|
||||
onClick={handleCellClick}
|
||||
onDoubleClick={handleCellDoubleClick}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user