diff --git a/src/pages.gen.ts b/src/pages.gen.ts index bc5384a..e2c7950 100644 --- a/src/pages.gen.ts +++ b/src/pages.gen.ts @@ -3,12 +3,10 @@ // prettier-ignore import type { PathsForPages, GetConfigResponse } from 'waku/router'; -// prettier-ignore -import type { getConfig as Index_getConfig } from './pages/index'; // prettier-ignore type Page = -| ({ path: '/' } & GetConfigResponse); +| { path: '/'; render: 'dynamic' }; // prettier-ignore declare module 'waku/router' { diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 90b5193..36cb47a 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,60 +1,303 @@ -import { Compass, Newspaper } from "lucide-react"; +"use client"; -import { SearchTrigger } from "../components/search-trigger"; -import { getFeedData } from "../_data/feeds"; +import { useState, useEffect, useRef } from "react"; +import { Search } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { RSSFeed } from "../types"; +import { useRouter_UNSTABLE } from "waku"; -export default async function App() { - const feeds = await getFeedData(); +// Reusing the SearchEngine from the original search-dialog.tsx +interface SearchIndex { + invertedIndex: { + [term: string]: { + [docId: string]: number; + }; + }; + documentMap: { + [docId: string]: { + feedTitle: string; + title: string; + link: string; + pubDate: string; + }; + }; + commonTerms: string[]; + metadata: { + documentCount: number; + termCount: number; + indexSize: number; + generatedAt: string; + }; +} + +interface SearchResult { + id: string; + score: number; + title: string; + link: string; + pubDate: string; + feedTitle: string; +} + +// Client-side search engine +class SearchEngine { + private invertedIndex: SearchIndex["invertedIndex"]; + private documentMap: SearchIndex["documentMap"]; + private commonTerms: Set; + + constructor(searchIndex: SearchIndex) { + this.invertedIndex = searchIndex.invertedIndex; + this.documentMap = searchIndex.documentMap; + this.commonTerms = new Set(searchIndex.commonTerms || []); + } + + search(query: string, limit: number = 10): SearchResult[] { + if (!query || query.trim() === "") { + return []; + } + + // Tokenize the query + const tokens = this._tokenize(query); + if (tokens.length === 0) return []; + + // Filter out common terms for more efficient search + let searchTokens = tokens; + + // Only filter common terms if we have enough tokens + if (tokens.length > 1) { + searchTokens = tokens.filter((token) => !this.commonTerms.has(token)); + } + + // If all terms were common, use a few of them anyway + if (searchTokens.length === 0 && tokens.length > 0) { + searchTokens.push(...tokens.slice(0, 2)); + } + + // Calculate scores for each document + const scores: Record = {}; + + // Process each search token + searchTokens.forEach((token) => { + // Look for exact matches + if (this.invertedIndex[token]) { + const matchingDocs = this.invertedIndex[token]; + + for (const docId in matchingDocs) { + const score = matchingDocs[docId]!; + scores[docId] = (scores[docId] || 0) + score; + } + } + + // Look for prefix matches (for partial word search) + Object.keys(this.invertedIndex).forEach((indexedToken) => { + if (indexedToken.startsWith(token) && indexedToken !== token) { + const matchingDocs = this.invertedIndex[indexedToken]; + + for (const docId in matchingDocs) { + // Lower weight for prefix matches + const score = matchingDocs[docId]! * 0.75; + scores[docId] = (scores[docId] || 0) + score; + } + } + }); + }); + + // Convert scores to array and sort + const results: SearchResult[] = Object.entries(scores) + .map(([docId, score]) => ({ + id: docId, + score, + ...this.documentMap[docId]!, + })) + .sort((a, b) => b.score - a.score) + .slice(0, limit); + + return results; + } + + private _tokenize(text: string): string[] { + if (!text) return []; + + // Simple tokenization - split on non-word characters and convert to lowercase + return text + .toLowerCase() + .replace(/[^\w\s]/g, " ") + .split(/\s+/) + .filter((word) => word.length > 1); + } +} + +// Fetch search index using React Query +const fetchSearchIndex = async (): Promise => { + const response = await fetch("/api/search.json"); + if (!response.ok) { + throw new Error("Failed to load search index"); + } + return response.json(); +}; + +export default function Home({ feeds }: { feeds: RSSFeed[] }) { + const router = useRouter_UNSTABLE(); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const searchInputRef = useRef(null); + + // Fetch the search index using React Query + const { + data: searchIndex, + isLoading, + error, + } = useQuery({ + queryKey: ["searchIndex"], + queryFn: fetchSearchIndex, + staleTime: Infinity, // The index doesn't change during a session + }); + + // Create search engine when index is loaded + const searchEngine = searchIndex ? new SearchEngine(searchIndex) : null; + + // Perform search when query changes + useEffect(() => { + if (!searchEngine || !query.trim()) { + setResults([]); + return; + } + + const searchResults = searchEngine.search(query, 10); + setResults(searchResults); + }, [query, searchEngine]); + + // Handle keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Focus search on '/' or cmd+k/ctrl+k + if ( + (e.key === "/" || (e.key === "k" && (e.metaKey || e.ctrlKey))) && + document.activeElement !== searchInputRef.current + ) { + e.preventDefault(); + searchInputRef.current?.focus(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, []); + + // Set query from URL parameter + useEffect(() => { + const params = new URLSearchParams(router.query); + const searchQuery = params.get("q"); + if (searchQuery) { + setQuery(searchQuery); + searchInputRef.current?.focus(); + } + }, [router.query]); return ( -
-
-
- -

RSS Quest

+
+
+ {/* Logo, title and search section */} +
+ RSS Quest Koala +
+

RSS Quest

+ + {/* Search bar */} +
+
+ +
+ setQuery(e.target.value)} + placeholder={ + isLoading + ? "Loading search index..." + : "Search RSS feeds... (Press '/' to focus)" + } + className="w-full py-4 pl-10 pr-4 text-lg border rounded-full shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + disabled={isLoading} + /> +
+
- -
- -
-
- {feeds.map((feed, index) => ( -
-
- -
-

{feed.title}

-

{feed.description}

-
- {feed.items.slice(0, 3).map((item, itemIndex) => ( - - ))} -
+ + {/* Search results */} +
+ {isLoading ? ( +
+ Loading search index... +
+ ) : error ? ( +
+ Error loading search index +
+ ) : results.length > 0 ? ( + -
- ))} + ))} +
+ ) : query.trim() ? ( +
+ No results found +
+ ) : ( +
+ Type to search RSS feeds +
+ )} +
+ + {/* Keyboard shortcut hint */} +
+ Press / or{" "} + ⌘K to search
- + ); } -export const getConfig = async () => { +export async function getStaticProps() { + // This might need adjustment based on your data fetching strategy + const { getFeedData } = require("../_data/feeds"); + const feeds = await getFeedData(); + return { - render: "static", - } as const; -}; + props: { + feeds, + }, + // Optionally set revalidation period + revalidate: 3600, // Revalidate every hour + }; +}