[object Object]

← 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

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 →