summaryrefslogtreecommitdiff
path: root/apps/web/app/reader/_components/search-overlay.tsx
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-07 01:42:57 -0800
committerFuwn <[email protected]>2026-02-07 01:42:57 -0800
commit5c5b1993edd890a80870ee05607ac5f088191d4e (patch)
treea721b76bcd49ba10826c53efc87302c7a689512f /apps/web/app/reader/_components/search-overlay.tsx
downloadasa.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.tsx180
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>
+ )
+}