212 lines
6.8 KiB
TypeScript
212 lines
6.8 KiB
TypeScript
import { useEffect, useState, useRef } from 'react'
|
|
import type { Article } from '@/types/nostr'
|
|
import { objectCache } from '@/lib/objectCache'
|
|
import { getSearchHistory } from '@/lib/searchHistory'
|
|
import { Card, Button } from './ui'
|
|
import { t } from '@/lib/i18n'
|
|
|
|
interface SearchSuggestionsProps {
|
|
query: string
|
|
onSelect: (query: string) => void
|
|
onClose: () => void
|
|
}
|
|
|
|
interface SuggestionItem {
|
|
type: 'article' | 'author' | 'history'
|
|
title: string
|
|
query: string
|
|
subtitle?: string
|
|
timestamp?: number
|
|
}
|
|
|
|
const loadHistorySuggestions = async (): Promise<SuggestionItem[]> => {
|
|
try {
|
|
const history = await getSearchHistory()
|
|
return history.slice(0, 5).map((item) => ({
|
|
type: 'history' as const,
|
|
title: item.query,
|
|
query: item.query,
|
|
timestamp: item.timestamp,
|
|
}))
|
|
} catch (error) {
|
|
console.error('Error loading search history:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
const loadSuggestions = async (searchQuery: string): Promise<SuggestionItem[]> => {
|
|
try {
|
|
const allArticles = (await objectCache.getAll('publication')) as Article[]
|
|
const allAuthors = (await objectCache.getAll('author')) as Article[]
|
|
const queryLower = searchQuery.toLowerCase()
|
|
|
|
const articleSuggestions: SuggestionItem[] = allArticles
|
|
.filter((article) => article.title.toLowerCase().includes(queryLower))
|
|
.slice(0, 5)
|
|
.map((article) => ({
|
|
type: 'article' as const,
|
|
title: article.title,
|
|
query: article.title,
|
|
subtitle: article.preview?.substring(0, 60),
|
|
}))
|
|
|
|
const authorSuggestions: SuggestionItem[] = allAuthors
|
|
.filter((author) => {
|
|
const name = extractAuthorName(author)
|
|
return name.toLowerCase().includes(queryLower)
|
|
})
|
|
.slice(0, 3)
|
|
.map((author) => ({
|
|
type: 'author' as const,
|
|
title: extractAuthorName(author),
|
|
query: extractAuthorName(author),
|
|
subtitle: author.description?.substring(0, 60),
|
|
}))
|
|
|
|
return [...articleSuggestions, ...authorSuggestions].slice(0, 8)
|
|
} catch (error) {
|
|
console.error('Error loading suggestions:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
function useSuggestionsLoader(query: string): { suggestions: SuggestionItem[]; loading: boolean } {
|
|
const [suggestions, setSuggestions] = useState<SuggestionItem[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
useEffect(() => {
|
|
const updateSuggestions = async (): Promise<void> => {
|
|
if (!query.trim()) {
|
|
const historySuggestions = await loadHistorySuggestions()
|
|
setSuggestions(historySuggestions)
|
|
return
|
|
}
|
|
setLoading(true)
|
|
try {
|
|
const loadedSuggestions = await loadSuggestions(query)
|
|
setSuggestions(loadedSuggestions)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
void updateSuggestions()
|
|
}, [query])
|
|
|
|
return { suggestions, loading }
|
|
}
|
|
|
|
function useClickOutsideHandler(containerRef: React.RefObject<HTMLDivElement | null>, onClose: () => void): void {
|
|
useEffect(() => {
|
|
const handleClickOutside = (e: MouseEvent): void => {
|
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
onClose()
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClickOutside)
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside)
|
|
}
|
|
}, [containerRef, onClose])
|
|
}
|
|
|
|
export function SearchSuggestions({ query, onSelect, onClose }: SearchSuggestionsProps): React.ReactElement | null {
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const { suggestions, loading } = useSuggestionsLoader(query)
|
|
useClickOutsideHandler(containerRef, onClose)
|
|
|
|
if (suggestions.length === 0 && !loading) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<div ref={containerRef} className="absolute top-full left-0 right-0 mt-1 z-50">
|
|
<Card variant="default" className="bg-cyber-dark border-neon-cyan/30 max-h-96 overflow-y-auto">
|
|
{loading && (
|
|
<div className="p-4 text-center text-cyber-accent/70">
|
|
<p>{t('common.loading')}</p>
|
|
</div>
|
|
)}
|
|
{!loading && suggestions.length > 0 && (
|
|
<div className="divide-y divide-neon-cyan/20">
|
|
{suggestions.map((suggestion, index) => (
|
|
<SuggestionItemComponent
|
|
key={`${suggestion.type}-${suggestion.query}-${suggestion.timestamp ?? index}`}
|
|
suggestion={suggestion}
|
|
onSelect={onSelect}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function getSuggestionIcon(type: SuggestionItem['type']): React.ReactElement {
|
|
if (type === 'article') {
|
|
return (
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
</svg>
|
|
)
|
|
}
|
|
if (type === 'author') {
|
|
return (
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
|
</svg>
|
|
)
|
|
}
|
|
return (
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function getSuggestionAriaLabel(type: SuggestionItem['type'], title: string): string {
|
|
if (type === 'article') {
|
|
return `${t('search.suggestion.article')}: ${title}`
|
|
}
|
|
if (type === 'author') {
|
|
return `${t('search.suggestion.author')}: ${title}`
|
|
}
|
|
return `${t('search.suggestion.history')}: ${title}`
|
|
}
|
|
|
|
function SuggestionItemComponent({
|
|
suggestion,
|
|
onSelect,
|
|
}: {
|
|
suggestion: SuggestionItem
|
|
onSelect: (query: string) => void
|
|
}): React.ReactElement {
|
|
return (
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => {
|
|
onSelect(suggestion.query)
|
|
}}
|
|
className="w-full justify-start text-left p-3 h-auto"
|
|
aria-label={getSuggestionAriaLabel(suggestion.type, suggestion.title)}
|
|
>
|
|
<div className="flex items-start gap-3 flex-1">
|
|
<div className="text-neon-cyan mt-0.5 flex-shrink-0">{getSuggestionIcon(suggestion.type)}</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium text-cyber-accent truncate">{suggestion.title}</div>
|
|
{suggestion.subtitle && (
|
|
<div className="text-xs text-cyber-accent/70 mt-1 truncate">{suggestion.subtitle}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
function extractAuthorName(author: Article): string {
|
|
if (author.isPresentation) {
|
|
return author.title
|
|
}
|
|
return author.title
|
|
}
|