← back to Nineoh Guide
internal search: /search (SSR) across episode titles+recaps and cast names+characters+bios, links to detail pages; header nav
2a1d19ab8164d7a7ce500b3826a2e40d5fd6e0f2 · 2026-07-25 11:24:01 -0700 · Steve
Files touched
M apps/web/app/layout.tsxA apps/web/app/search/page.tsx
Diff
commit 2a1d19ab8164d7a7ce500b3826a2e40d5fd6e0f2
Author: Steve <steve@designerwallcoverings.com>
Date: Sat Jul 25 11:24:01 2026 -0700
internal search: /search (SSR) across episode titles+recaps and cast names+characters+bios, links to detail pages; header nav
---
apps/web/app/layout.tsx | 9 +++-
apps/web/app/search/page.tsx | 113 +++++++++++++++++++++++++++++++++++++++++++
2 files changed, 121 insertions(+), 1 deletion(-)
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 73c91d8..9f35afb 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -36,10 +36,17 @@ export default function RootLayout({
alignItems: "baseline",
}}
>
- <strong style={{ fontSize: 18 }}>{APP.displayName}</strong>
+ <a href="/" style={{ textDecoration: "none", color: "inherit" }}>
+ <strong style={{ fontSize: 18 }}>{APP.displayName}</strong>
+ </a>
<span style={{ fontSize: 12, color: "#8a1c1c", fontWeight: 600 }}>
UNOFFICIAL
</span>
+ <nav style={{ marginLeft: "auto", display: "flex", gap: 14, fontSize: 14 }}>
+ <a href="/episodes">Episodes</a>
+ <a href="/cast">Cast</a>
+ <a href="/search">Search</a>
+ </nav>
</header>
<main style={{ maxWidth: 820, margin: "0 auto", padding: "24px 20px" }}>
{children}
diff --git a/apps/web/app/search/page.tsx b/apps/web/app/search/page.tsx
new file mode 100644
index 0000000..7fc0ba6
--- /dev/null
+++ b/apps/web/app/search/page.tsx
@@ -0,0 +1,113 @@
+import { pool } from "@/lib/db";
+import { castPath } from "@nineoh/core";
+
+export const dynamic = "force-dynamic";
+export const metadata = { title: "Search" };
+
+async function search(q: string) {
+ const like = `%${q}%`;
+ try {
+ const eps = await pool.query(
+ `select season_number, episode_number, title, summary_short_original
+ from episodes
+ where title ilike $1 or summary_short_original ilike $1
+ order by season_number, episode_number
+ limit 50`,
+ [like]
+ );
+ const cast = await pool.query(
+ `select cp.name, ch.name as character_name, cp.biography_original
+ from cast_people cp
+ left join credits cr on cr.person_id = cp.id and cr.credit_type='main-cast'
+ left join characters ch on ch.id = cr.character_id
+ where cp.name ilike $1 or cp.biography_original ilike $1 or ch.name ilike $1
+ order by cp.name
+ limit 50`,
+ [like]
+ );
+ return { eps: eps.rows, cast: cast.rows };
+ } catch {
+ return { eps: [], cast: [] };
+ }
+}
+
+export default async function Search({
+ searchParams,
+}: {
+ searchParams: Promise<{ q?: string }>;
+}) {
+ const { q } = await searchParams;
+ const query = (q ?? "").trim();
+ const results = query ? await search(query) : { eps: [], cast: [] };
+ const total = results.eps.length + results.cast.length;
+
+ return (
+ <section>
+ <h1>Search</h1>
+ <form action="/search" method="get" style={{ margin: "12px 0" }}>
+ <input
+ type="search"
+ name="q"
+ defaultValue={query}
+ placeholder="Search episodes, cast, characters…"
+ aria-label="Search"
+ style={{
+ padding: "8px 10px",
+ width: "100%",
+ maxWidth: 460,
+ border: "1px solid #ccc",
+ borderRadius: 6,
+ fontSize: 14,
+ }}
+ />
+ </form>
+
+ {query && (
+ <p style={{ color: "#666", fontSize: 13 }}>
+ {total} result{total === 1 ? "" : "s"} for “{query}”.
+ </p>
+ )}
+
+ {results.cast.length > 0 && (
+ <>
+ <h2 style={{ fontSize: 16 }}>Cast</h2>
+ <ul style={{ listStyle: "none", padding: 0 }}>
+ {results.cast.map((c) => (
+ <li key={c.name} style={{ padding: "6px 0", borderBottom: "1px solid #eee" }}>
+ <a href={castPath(c.name)}>
+ <strong>{c.name}</strong>
+ </a>
+ {c.character_name ? (
+ <span style={{ color: "#8a1c1c" }}> as {c.character_name}</span>
+ ) : null}
+ </li>
+ ))}
+ </ul>
+ </>
+ )}
+
+ {results.eps.length > 0 && (
+ <>
+ <h2 style={{ fontSize: 16 }}>Episodes</h2>
+ <ul style={{ listStyle: "none", padding: 0 }}>
+ {results.eps.map((e) => (
+ <li
+ key={`${e.season_number}-${e.episode_number}`}
+ style={{ padding: "6px 0", borderBottom: "1px solid #eee" }}
+ >
+ <a href={`/episodes/${e.season_number}/${e.episode_number}`}>
+ <span style={{ color: "#8a1c1c", fontWeight: 600 }}>
+ S{e.season_number}E{e.episode_number}
+ </span>{" "}
+ {e.title}
+ </a>
+ </li>
+ ))}
+ </ul>
+ </>
+ )}
+
+ {query && total === 0 && <p>No matches. Try a character name or episode title.</p>}
+ </section>
+ );
+}
← d2ccd5a spoiler guard: progress-based recap hiding on web (SpoilerGu
·
back to Nineoh Guide
·
professional redesign: original luxe design system (globals. e86cb34 →