You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

355 lines
9.9 KiB

import SearchInput from '@/components/SearchInput'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { normalizeToDTag } from '@/lib/search-parser'
import type { LibraryPublicationRelaySearchAxis } from '@/lib/library-publication-index'
import { cn } from '@/lib/utils'
import { FileText, Loader2, Search, User, Wifi } from 'lucide-react'
import {
HTMLAttributes,
useCallback,
useMemo,
useRef,
useState
} from 'react'
import { useTranslation } from 'react-i18next'
type LibrarySearchOption = {
axis: LibraryPublicationRelaySearchAxis | null
search: string
input?: string
}
export default function LibrarySearchBar({
searchQuery,
onSearchQueryChange,
committedSearch,
searchAxis,
onCommitSearch,
showOnlyMine,
onShowOnlyMineChange,
mineFilterLoading,
onSearchRelays,
relaySearchLoading,
disabled
}: {
searchQuery: string
onSearchQueryChange: (value: string) => void
committedSearch: string
searchAxis: LibraryPublicationRelaySearchAxis | null
onCommitSearch: (query: string, axis: LibraryPublicationRelaySearchAxis | null) => void
showOnlyMine: boolean
onShowOnlyMineChange: (value: boolean) => void
mineFilterLoading?: boolean
onSearchRelays?: () => void
relaySearchLoading?: boolean
disabled?: boolean
}) {
const { t } = useTranslation()
const [searching, setSearching] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const searchInputRef = useRef<HTMLInputElement>(null)
const canSearchRelays = searchQuery.trim().length > 0 && !relaySearchLoading
const selectableOptions = useMemo((): LibrarySearchOption[] => {
const search = searchQuery.trim()
if (!search) return []
const normalizedDTag = normalizeToDTag(search)
return [
{ axis: null, search },
{ axis: 'title', search },
{ axis: 'author', search },
...(normalizedDTag
? [{ axis: 'd-tag' as const, search: normalizedDTag, input: search }]
: [])
]
}, [searchQuery])
const displayList = searching && selectableOptions.length > 0
const blur = () => {
setSearching(false)
setSelectedIndex(-1)
searchInputRef.current?.blur()
}
const applyOption = useCallback(
(option: LibrarySearchOption) => {
onCommitSearch(option.input ?? option.search, option.axis)
blur()
},
[onCommitSearch]
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.stopPropagation()
if (selectableOptions.length <= 0) return
applyOption(selectableOptions[selectedIndex >= 0 ? selectedIndex : 0])
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
if (selectableOptions.length <= 0) return
setSelectedIndex((prev) => (prev + 1) % selectableOptions.length)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
if (selectableOptions.length <= 0) return
setSelectedIndex((prev) => (prev - 1 + selectableOptions.length) % selectableOptions.length)
return
}
if (e.key === 'Escape') {
blur()
}
},
[applyOption, selectableOptions, selectedIndex]
)
const list = useMemo(() => {
if (selectableOptions.length <= 0) return null
return (
<>
{selectableOptions.map((option, index) => {
if (option.axis === null) {
return (
<AllFieldsItem
key="all"
search={option.search}
selected={selectedIndex === index}
onClick={() => applyOption(option)}
/>
)
}
if (option.axis === 'title') {
return (
<TitleItem
key="title"
search={option.search}
selected={selectedIndex === index}
onClick={() => applyOption(option)}
/>
)
}
if (option.axis === 'author') {
return (
<AuthorItem
key="author"
search={option.search}
selected={selectedIndex === index}
onClick={() => applyOption(option)}
/>
)
}
return (
<DTagItem
key="dtag"
dtag={option.search}
selected={selectedIndex === index}
onClick={() => applyOption(option)}
/>
)
})}
</>
)
}, [applyOption, selectableOptions, selectedIndex])
const isCommitted = committedSearch.trim().length > 0 && committedSearch.trim() === searchQuery.trim()
const scopeLabel =
isCommitted && searchAxis === 'title'
? t('Library search scope title')
: isCommitted && searchAxis === 'author'
? t('Library search scope author')
: isCommitted && searchAxis === 'd-tag'
? t('Library search scope dtag')
: null
return (
<div className="space-y-3">
<div className="relative">
{displayList && list ? (
<div
className="absolute top-full z-50 -translate-y-1 inset-x-0 rounded-b-lg border border-border/80 bg-surface-background pt-1 shadow-lg"
onMouseDown={(e) => e.preventDefault()}
>
<div className="h-fit">{list}</div>
</div>
) : null}
<SearchInput
ref={searchInputRef}
type="search"
value={searchQuery}
onChange={(e) => {
setSearching(true)
setSelectedIndex(-1)
onSearchQueryChange(e.target.value)
}}
onPaste={() => setSearching(true)}
onKeyDown={handleKeyDown}
onFocus={() => setSearching(true)}
onBlur={() => setSearching(false)}
placeholder={t('Library search placeholder')}
className={cn('bg-surface-background pl-3', displayList && 'z-50')}
disabled={disabled}
aria-label={t('Library search placeholder')}
/>
</div>
{scopeLabel ? (
<p className="text-xs text-muted-foreground">{scopeLabel}</p>
) : searchQuery.trim() && !isCommitted ? (
<p className="text-xs text-muted-foreground">{t('Library search commit hint')}</p>
) : null}
{onSearchRelays ? (
<Button
type="button"
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={disabled || !canSearchRelays}
onClick={onSearchRelays}
>
{relaySearchLoading ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<Wifi className="size-4" aria-hidden />
)}
{t('Library search relays')}
</Button>
) : null}
<div className="flex items-center gap-2">
<Switch
id="library-show-mine"
checked={showOnlyMine}
onCheckedChange={onShowOnlyMineChange}
disabled={disabled}
/>
<Label htmlFor="library-show-mine" className="text-sm text-muted-foreground cursor-pointer">
{t('Library show only my publications')}
</Label>
{mineFilterLoading ? (
<Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" aria-hidden />
) : null}
</div>
</div>
)
}
function Item({
className,
children,
selected,
...props
}: HTMLAttributes<HTMLDivElement> & { selected?: boolean }) {
return (
<div
className={cn(
'flex gap-2 items-center px-2 py-3 hover:bg-accent rounded-md cursor-pointer',
selected ? 'bg-accent' : '',
className
)}
{...props}
>
{children}
</div>
)
}
function AllFieldsItem({
search,
onClick,
selected
}: {
search: string
onClick?: () => void
selected?: boolean
}) {
const { t } = useTranslation()
return (
<Item onClick={onClick} selected={selected}>
<div className="flex flex-col items-center gap-0.5">
<Search className="text-muted-foreground" />
<span className="text-[10px] text-muted-foreground/70 uppercase leading-none">
{t('Library search dropdown all')}
</span>
</div>
<div className="font-semibold truncate">{search}</div>
</Item>
)
}
function TitleItem({
search,
onClick,
selected
}: {
search: string
onClick?: () => void
selected?: boolean
}) {
const { t } = useTranslation()
return (
<Item onClick={onClick} selected={selected}>
<div className="flex flex-col items-center gap-0.5">
<FileText className="text-muted-foreground" />
<span className="text-[10px] text-muted-foreground/70 uppercase leading-none">
{t('Library search dropdown title')}
</span>
</div>
<div className="font-semibold truncate">{search}</div>
</Item>
)
}
function AuthorItem({
search,
onClick,
selected
}: {
search: string
onClick?: () => void
selected?: boolean
}) {
const { t } = useTranslation()
return (
<Item onClick={onClick} selected={selected}>
<div className="flex flex-col items-center gap-0.5">
<User className="text-muted-foreground" />
<span className="text-[10px] text-muted-foreground/70 uppercase leading-none">
{t('Library search dropdown author')}
</span>
</div>
<div className="font-semibold truncate">{search}</div>
</Item>
)
}
function DTagItem({
dtag,
onClick,
selected
}: {
dtag: string
onClick?: () => void
selected?: boolean
}) {
const { t } = useTranslation()
return (
<Item onClick={onClick} selected={selected}>
<div className="flex flex-col items-center gap-0.5">
<FileText className="text-muted-foreground" />
<span className="text-[10px] text-muted-foreground/70 uppercase leading-none">
{t('Library search dropdown dtag')}
</span>
</div>
<div className="font-semibold truncate">{dtag}</div>
</Item>
)
}