diff options
| author | Fuwn <[email protected]> | 2026-02-07 01:42:57 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-07 01:42:57 -0800 |
| commit | 5c5b1993edd890a80870ee05607ac5f088191d4e (patch) | |
| tree | a721b76bcd49ba10826c53efc87302c7a689512f /apps/web/app/reader/_components/search-overlay.tsx | |
| download | asa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.tar.xz asa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.zip | |
feat: asa.news RSS reader with developer tier, REST API, and webhooks
Full-stack RSS reader SaaS: Supabase + Next.js + Go worker.
Includes three subscription tiers (free/pro/developer), API key auth,
read-only REST API, webhook push notifications, Stripe billing with
proration, and PWA support.
Diffstat (limited to 'apps/web/app/reader/_components/search-overlay.tsx')
| -rw-r--r-- | apps/web/app/reader/_components/search-overlay.tsx | 180 |
1 files changed, 180 insertions, 0 deletions
diff --git a/apps/web/app/reader/_components/search-overlay.tsx b/apps/web/app/reader/_components/search-overlay.tsx new file mode 100644 index 0000000..5cfdb57 --- /dev/null +++ b/apps/web/app/reader/_components/search-overlay.tsx @@ -0,0 +1,180 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { useEntrySearch } from "@/lib/queries/use-entry-search" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" + +function getMatchSnippet(text: string, query: string): string | null { + const stripped = text.replace(/<[^>]*>/g, "") + const lowerStripped = stripped.toLowerCase() + const matchIndex = lowerStripped.indexOf(query) + if (matchIndex === -1) return null + const start = Math.max(0, matchIndex - 40) + const end = Math.min(stripped.length, matchIndex + query.length + 80) + const prefix = start > 0 ? "\u2026" : "" + const suffix = end < stripped.length ? "\u2026" : "" + return prefix + stripped.slice(start, end) + suffix +} + +function highlightText(text: string, query: string): React.ReactNode { + if (!query) return text + const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + const parts = text.split(new RegExp(`(${escapedQuery})`, "gi")) + return parts.map((part, index) => + part.toLowerCase() === query.toLowerCase() ? ( + <mark key={index} className="bg-[rgba(234,179,8,0.18)] text-text-primary"> + {part} + </mark> + ) : ( + part + ) + ) +} + +interface SearchOverlayProperties { + onClose: () => void +} + +export function SearchOverlay({ onClose }: SearchOverlayProperties) { + const [searchQuery, setSearchQuery] = useState("") + const [selectedResultIndex, setSelectedResultIndex] = useState(-1) + const inputReference = useRef<HTMLInputElement>(null) + const resultListReference = useRef<HTMLDivElement>(null) + const { data: results, isLoading } = useEntrySearch(searchQuery) + const setSelectedEntryIdentifier = useUserInterfaceStore( + (state) => state.setSelectedEntryIdentifier + ) + + useEffect(() => { + inputReference.current?.focus() + }, []) + + useEffect(() => { + setSelectedResultIndex(-1) + }, [searchQuery]) + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + onClose() + } + } + + document.addEventListener("keydown", handleKeyDown) + + return () => document.removeEventListener("keydown", handleKeyDown) + }, [onClose]) + + function handleSelectEntry(entryIdentifier: string) { + setSelectedEntryIdentifier(entryIdentifier) + onClose() + } + + function handleInputKeyDown(event: React.KeyboardEvent) { + if (event.key === "Backspace" && searchQuery === "") { + onClose() + return + } + + if (!results || results.length === 0) return + + if (event.key === "ArrowDown") { + event.preventDefault() + setSelectedResultIndex((previous) => { + const nextIndex = previous < results.length - 1 ? previous + 1 : 0 + scrollResultIntoView(nextIndex) + return nextIndex + }) + } else if (event.key === "ArrowUp") { + event.preventDefault() + setSelectedResultIndex((previous) => { + const nextIndex = previous > 0 ? previous - 1 : results.length - 1 + scrollResultIntoView(nextIndex) + return nextIndex + }) + } else if (event.key === "Enter" && selectedResultIndex >= 0) { + event.preventDefault() + handleSelectEntry(results[selectedResultIndex].entryIdentifier) + } + } + + function scrollResultIntoView(index: number) { + const container = resultListReference.current + if (!container) return + const items = container.querySelectorAll("[data-result-item]") + items[index]?.scrollIntoView({ block: "nearest" }) + } + + function handleBackdropClick(event: React.MouseEvent) { + if (event.target === event.currentTarget) { + onClose() + } + } + + return ( + <div + className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[15vh]" + onClick={handleBackdropClick} + > + <div className="w-full max-w-lg border border-border bg-background-primary shadow-lg"> + <div className="border-b border-border px-4 py-3"> + <input + ref={inputReference} + type="text" + value={searchQuery} + onChange={(event) => setSearchQuery(event.target.value)} + onKeyDown={handleInputKeyDown} + placeholder="search entries..." + className="w-full bg-transparent text-text-primary outline-none placeholder:text-text-dim" + /> + </div> + <div ref={resultListReference} className="max-h-80 overflow-auto"> + {isLoading && searchQuery.trim().length >= 2 && ( + <p className="px-4 py-3 text-text-dim">searching...</p> + )} + {!isLoading && + searchQuery.trim().length >= 2 && + results?.length === 0 && ( + <p className="px-4 py-3 text-text-dim">no results</p> + )} + {results?.map((entry, index) => { + const query = searchQuery.trim().toLowerCase() + const titleMatches = (entry.entryTitle ?? "").toLowerCase().includes(query) + const summarySnippet = !titleMatches && entry.summary + ? getMatchSnippet(entry.summary, query) + : null + + return ( + <button + key={entry.entryIdentifier} + type="button" + data-result-item + onClick={() => handleSelectEntry(entry.entryIdentifier)} + className={`block w-full px-4 py-2 text-left transition-colors hover:bg-background-tertiary ${ + index === selectedResultIndex + ? "bg-background-tertiary" + : "" + }`} + > + <p className="truncate text-text-primary"> + {titleMatches + ? highlightText(entry.entryTitle ?? "", query) + : entry.entryTitle} + </p> + <p className="truncate text-[0.6875rem] text-text-dim"> + {entry.customTitle ?? entry.feedTitle} + {entry.author && ` \u00b7 ${entry.author}`} + </p> + {summarySnippet && ( + <p className="mt-0.5 line-clamp-2 text-[0.6875rem] text-text-secondary"> + {highlightText(summarySnippet, query)} + </p> + )} + </button> + ) + })} + </div> + </div> + </div> + ) +} |