-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Frontend Search with Client-side Index #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
dff3909
27638bc
7eb0166
b220eb8
58ce7ba
7d2a1be
99d8c3a
4dff898
a7cecc1
263c10e
03d9cf3
fc46a3b
67e7846
1e9b936
5bba27e
4717138
c4d913e
e55e4b5
f6f34ec
dc6a6af
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,130 +1,51 @@ | ||
| 'use client' | ||
| import React, { useState, useEffect, Suspense } from 'react' | ||
| import { useRouter, useSearchParams } from 'next/navigation' | ||
| import React, { useState, useEffect } from 'react' | ||
| import { useSearchParams } from 'next/navigation' | ||
| import SearchForm from '@/components/search/SearchForm' | ||
| import SearchResults from '@/components/search/SearchResults' | ||
| import { SearchResult } from '@/components/search/SearchResult' | ||
| import { SearchParams as SearchParamsType } from '@/components/search/SearchResult' | ||
| import { useClientSearch } from '@/components/search/useClientSearch' | ||
|
|
||
| // Loading component | ||
| const SearchLoading = () => ( | ||
| <div className="flex justify-center p-8"> | ||
| <div className="animate-pulse">Loading...</div> | ||
| </div> | ||
| ) | ||
|
|
||
| // Define proper types for search parameters | ||
| interface SearchParams { | ||
| query: string | ||
| domain?: string | null | ||
| tag?: string | null | ||
| year?: string | null | ||
| region?: string | null | ||
| } | ||
|
|
||
| // Main search component | ||
| function SearchContent() { | ||
| const router = useRouter() | ||
| export default function SearchPage() { | ||
| const searchParams = useSearchParams() | ||
| const [isSearching, setIsSearching] = useState(false) | ||
| const [error, setError] = useState<string | null>(null) | ||
| const [results, setResults] = useState<SearchResult[]>([]) | ||
|
|
||
| // Check if any search parameters exist | ||
| const hasSearchParams = () => { | ||
| return Boolean( | ||
| searchParams.get('term') || | ||
| searchParams.get('domain') || | ||
| searchParams.get('tag') || | ||
| searchParams.get('year') || | ||
| searchParams.get('region') | ||
| ) | ||
| } | ||
| const { search, isSearching, error, results } = useClientSearch() | ||
| const [hasInitialSearch, setHasInitialSearch] = useState(false) | ||
|
|
||
| // Perform initial search with params | ||
| const performInitialSearch = () => { | ||
| const query = searchParams.get('term') | ||
| const domain = searchParams.get('domain') | ||
| const tag = searchParams.get('tag') | ||
| const year = searchParams.get('year') | ||
| const region = searchParams.get('region') | ||
| useEffect(() => { | ||
| const query = searchParams.get('term') || '' | ||
| const domain = searchParams.get('domain') || undefined | ||
| const tag = searchParams.get('tag') || undefined | ||
| const year = searchParams.get('year') || undefined | ||
| const region = searchParams.get('region') || undefined | ||
|
|
||
| if (query || domain || tag || year || region) { | ||
| handleSearch({ | ||
| query: query || '', // ensure query is never undefined | ||
| domain: domain || undefined, | ||
| tag: tag || undefined, | ||
| year: year || undefined, | ||
| region: region || undefined, | ||
| }) | ||
| search({ query, domain, tag, year, region }) | ||
| setHasInitialSearch(true) | ||
| } | ||
| } | ||
|
|
||
| // Update useEffect to use new functions | ||
| useEffect(() => { | ||
| if (hasSearchParams()) { | ||
| performInitialSearch() | ||
| } | ||
| }, [searchParams]) | ||
|
|
||
| const handleSearch = async ({ query, domain, tag, year, region }: SearchParams) => { | ||
| setError(null) | ||
| setIsSearching(true) | ||
| setResults([]) | ||
|
|
||
| try { | ||
| const params = new URLSearchParams({ | ||
| term: query, | ||
| ...(domain && { domain }), | ||
| ...(tag && { tag }), | ||
| ...(year && { year }), | ||
| ...(region && { region }), | ||
| }) | ||
| }, [searchParams, search]) | ||
|
|
||
| router.push(`?${params.toString()}`, { scroll: false }) | ||
|
|
||
| const response = await fetch(`/api/search?${params.toString()}`) | ||
| if (!response.ok) { | ||
| throw new Error('Search failed') | ||
| } | ||
| const data: SearchResult[] = await response.json() | ||
| setResults(data) | ||
| } catch (err) { | ||
| setError('Search failed. Please try again.') | ||
| } finally { | ||
| setIsSearching(false) | ||
| } | ||
| const handleSearch = (params: SearchParamsType) => { | ||
| search(params) | ||
| setHasInitialSearch(true) | ||
| } | ||
|
weekendfish marked this conversation as resolved.
Outdated
|
||
|
|
||
| return ( | ||
| <div className="mx-auto max-w-7xl p-8"> | ||
| <div className="mb-8 rounded-md bg-amber-50 p-4 text-center text-amber-800"> | ||
| 更完善的搜索功能请访问 <a href="https://tsindex.org">tsindex.org 多元性别搜索引擎</a> | ||
| </div> | ||
|
|
||
| <div className="flex flex-col items-center gap-6"> | ||
| <h1 className="text-2xl font-bold">条目检索</h1> | ||
| <div className="mx-auto max-w-4xl px-4 py-8"> | ||
| <h1 className="mb-8 text-center text-3xl font-bold text-gray-900 dark:text-gray-100"> | ||
| 搜索 | ||
| </h1> | ||
|
|
||
| <div className="mb-8"> | ||
| <SearchForm | ||
| onSearch={handleSearch} | ||
| isSearching={isSearching} | ||
| initialValues={{ | ||
| query: searchParams.get('term') || '', | ||
| domain: searchParams.get('domain') || '', | ||
| tag: searchParams.get('tag') || '', | ||
| year: searchParams.get('year') || '', | ||
| region: searchParams.get('region') || '', | ||
| }} | ||
| initialValues={{ query: searchParams.get('term') || '' }} | ||
|
weekendfish marked this conversation as resolved.
Outdated
|
||
| /> | ||
| <SearchResults results={results} error={error} /> | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| // Main exported component with Suspense boundary | ||
| export default function FileSearch() { | ||
| return ( | ||
| <Suspense fallback={<SearchLoading />}> | ||
| <SearchContent /> | ||
| </Suspense> | ||
| {(hasInitialSearch || results.length > 0) && ( | ||
| <SearchResults results={results} error={error} /> | ||
| )} | ||
| </div> | ||
| ) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| /* eslint-disable @typescript-eslint/no-explicit-any */ | ||
| import { useState, useCallback } from 'react' | ||
| import pako from 'pako' | ||
| import { SearchResult, SearchParams } from './SearchResult' | ||
|
|
||
| const REPO_INDEXES: Record<string, string> = { | ||
| "digital.transchinese.org": "/search-index/repo-digital-transchinese-org.json.gz", | ||
| "novel.transchinese.org": "/search-index/repo-novel-transchinese-org.json.gz", | ||
| "comic.transchinese.org": "/search-index/repo-comic-transchinese-org.json.gz", | ||
| "archive.cdtsf.com": "/search-index/repo-archive-cdtsf-com.json.gz", | ||
| "news.transchinese.org": "/search-index/repo-news-transchinese-org.json.gz", | ||
|
weekendfish marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| const cache: Record<string, any> = {} | ||
|
weekendfish marked this conversation as resolved.
Outdated
|
||
|
|
||
| async function loadIndex(domain: string): Promise<any | null> { | ||
| if (cache[domain]) return cache[domain] | ||
| const file = REPO_INDEXES[domain] | ||
| if (!file) return null | ||
|
|
||
| try { | ||
| const res = await fetch(file) | ||
| const compressed = await res.arrayBuffer() | ||
| const decompressed = pako.inflate(new Uint8Array(compressed), { to: 'string' }) | ||
| const data = JSON.parse(decompressed) | ||
| cache[domain] = data | ||
| return data | ||
| } catch (e) { | ||
| console.error('Failed to load', domain, e) | ||
| return null | ||
|
weekendfish marked this conversation as resolved.
Outdated
|
||
| } | ||
| } | ||
|
|
||
| export function useClientSearch() { | ||
| const [isSearching, setIsSearching] = useState(false) | ||
| const [error, setError] = useState<string | null>(null) | ||
| const [results, setResults] = useState<SearchResult[]>([]) | ||
|
|
||
| const search = useCallback(async (params: SearchParams) => { | ||
| setIsSearching(true) | ||
| setError(null) | ||
| setResults([]) | ||
|
|
||
| try { | ||
| const domains = params.domain ? params.domain.split(',') : Object.keys(REPO_INDEXES) | ||
| const lowerQuery = params.query.toLowerCase() | ||
| const found: SearchResult[] = [] | ||
|
|
||
| for (const domain of domains) { | ||
| const index = await loadIndex(domain) | ||
| if (!index) continue | ||
|
|
||
| for (const [key, doc] of Object.entries(index)) { | ||
| const d = doc as any | ||
| const keyLower = key.toLowerCase() | ||
| const descLower = (d.description || '').toLowerCase() | ||
|
|
||
| if (lowerQuery && !keyLower.includes(lowerQuery) && !descLower.includes(lowerQuery)) { | ||
|
weekendfish marked this conversation as resolved.
Outdated
|
||
| continue | ||
| } | ||
|
|
||
| if (params.tag && d.tags && !d.tags.includes(params.tag)) continue | ||
| if (params.year && d.date && !d.date.includes(params.year)) continue | ||
| if (params.region && d.region !== params.region) continue | ||
|
|
||
| found.push({ | ||
| url: 'https://' + domain + '/' + key.replace(/\.[^/.]+$/, ''), | ||
| description: d.description || '', | ||
| tags: d.tags || [], | ||
| type: d.type || '', | ||
| author: d.author || '', | ||
| date: d.date || '', | ||
| region: d.region || '', | ||
| format: d.format || '', | ||
| size: d.size || 0, | ||
| link: 'https://' + domain + '/' + key.replace(/\.[^/.]+$/, ''), | ||
|
weekendfish marked this conversation as resolved.
Outdated
|
||
| }) | ||
|
|
||
| if (found.length >= 100) break | ||
|
weekendfish marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| if (found.length >= 100) break | ||
| } | ||
|
weekendfish marked this conversation as resolved.
Outdated
|
||
|
|
||
| setResults(found) | ||
| } catch (e: any) { | ||
| setError(e.message) | ||
| } finally { | ||
| setIsSearching(false) | ||
| } | ||
| }, []) | ||
|
|
||
| return { search, isSearching, error, results } | ||
| } | ||
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| name = "transchinese-test" | ||
| compatibility_date = "2024-01-01" | ||
| account_id = "bc82e687de1803b47adc1dfe23aca0a4" | ||
|
weekendfish marked this conversation as resolved.
Outdated
|
||
|
|
||
| [site] | ||
| bucket = "./out" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| name = "transchinese-org" | ||
| compatibility_date = "2024-01-01" | ||
| account_id = "bc82e687de1803b47adc1dfe23aca0a4" | ||
|
|
||
| [site] | ||
| bucket = "./out" |
Uh oh!
There was an error while loading. Please reload this page.