[object Object]

← back to Nineoh Guide

loop refine 2/n: sort (season asc/desc) + comfortable/compact density on /episodes (recap show/hide, localStorage)

4c1a88b303eba0f16450dabcdac558113e51ddbb · 2026-07-26 16:27:20 -0700 · Steve

Files touched

Diff

commit 4c1a88b303eba0f16450dabcdac558113e51ddbb
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun Jul 26 16:27:20 2026 -0700

    loop refine 2/n: sort (season asc/desc) + comfortable/compact density on /episodes (recap show/hide, localStorage)
---
 apps/web/app/episodes/page.tsx          | 24 ++++++++++----
 apps/web/app/globals.css                |  1 +
 apps/web/components/EpisodeControls.tsx | 59 +++++++++++++++++++++++++++++++++
 3 files changed, 77 insertions(+), 7 deletions(-)

diff --git a/apps/web/app/episodes/page.tsx b/apps/web/app/episodes/page.tsx
index 00184a0..a258940 100644
--- a/apps/web/app/episodes/page.tsx
+++ b/apps/web/app/episodes/page.tsx
@@ -1,5 +1,6 @@
 import { pool } from "@/lib/db";
 import { SpoilerSettings, SpoilerRecap } from "@/components/SpoilerGuard";
+import { EpisodeControls } from "@/components/EpisodeControls";
 
 export const dynamic = "force-dynamic";
 export const metadata = { title: "Episode Guide" };
@@ -21,9 +22,15 @@ async function getEpisodesBySeason() {
   }
 }
 
-export default async function Episodes() {
+export default async function Episodes({
+  searchParams,
+}: {
+  searchParams: Promise<{ order?: string }>;
+}) {
+  const { order: orderRaw } = await searchParams;
+  const order = orderRaw === "desc" ? "desc" : "asc";
   const bySeason = await getEpisodesBySeason();
-  const seasons = [...bySeason.keys()].sort((a, b) => a - b);
+  const seasons = [...bySeason.keys()].sort((a, b) => (order === "desc" ? b - a : a - b));
 
   return (
     <section>
@@ -33,6 +40,7 @@ export default async function Episodes() {
         recaps are written by our editors — use the spoiler guard to hide ones
         past where you've watched.
       </p>
+      <EpisodeControls order={order} />
       <SpoilerSettings seasons={Math.max(5, ...seasons, 1)} />
       {seasons.length === 0 && <p>No episodes indexed yet.</p>}
       {seasons.map((s) => (
@@ -68,11 +76,13 @@ export default async function Episodes() {
                   >
                     {ep.title}
                   </a>
-                  <SpoilerRecap
-                    season={s}
-                    episode={ep.episode_number}
-                    text={ep.summary_short_original}
-                  />
+                  <span className="ep-recap">
+                    <SpoilerRecap
+                      season={s}
+                      episode={ep.episode_number}
+                      text={ep.summary_short_original}
+                    />
+                  </span>
                 </span>
                 <span style={{ color: "var(--muted)", fontSize: 12, whiteSpace: "nowrap" }}>
                   {ep.air_date
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
index 1773fca..fedb4bb 100644
--- a/apps/web/app/globals.css
+++ b/apps/web/app/globals.css
@@ -176,6 +176,7 @@ h2 { font-size: clamp(19px, 3vw, 23px); margin: 28px 0 10px; }
   color: var(--maroon); background: #f3e7e6; border-radius: 999px; padding: 2px 8px;
 }
 .se { color: var(--maroon); font-weight: 700; }
+.ep-recap { display: var(--ep-recaps, block); }
 
 input[type="search"], input[type="number"] {
   font-family: inherit; border: 1px solid var(--line); border-radius: 8px; padding: 9px 11px; font-size: 14px; background: var(--paper-2);
diff --git a/apps/web/components/EpisodeControls.tsx b/apps/web/components/EpisodeControls.tsx
new file mode 100644
index 0000000..b5b3008
--- /dev/null
+++ b/apps/web/components/EpisodeControls.tsx
@@ -0,0 +1,59 @@
+"use client";
+import { useEffect, useState } from "react";
+
+/**
+ * Sort + density for the episode guide (standing rule). Density toggles whether
+ * recaps show (comfortable) or hide for fast title scanning (compact), via a CSS
+ * var + localStorage. Sort flips season order through a query param (server-ordered).
+ */
+const KEY = "nineoh.epDensity.v1";
+
+export function EpisodeControls({ order }: { order: string }) {
+  const [compact, setCompact] = useState(false);
+
+  useEffect(() => {
+    const c = localStorage.getItem(KEY) === "compact";
+    setCompact(c);
+    document.documentElement.style.setProperty("--ep-recaps", c ? "none" : "block");
+  }, []);
+
+  const setDensity = (c: boolean) => {
+    setCompact(c);
+    document.documentElement.style.setProperty("--ep-recaps", c ? "none" : "block");
+    localStorage.setItem(KEY, c ? "compact" : "comfortable");
+  };
+
+  const onSort = (e: React.ChangeEvent<HTMLSelectElement>) => {
+    const url = new URL(window.location.href);
+    url.searchParams.set("order", e.target.value);
+    window.location.href = url.toString();
+  };
+
+  return (
+    <div style={{ display: "flex", gap: 14, alignItems: "center", flexWrap: "wrap", margin: "10px 0 4px", fontSize: 13 }}>
+      <label>
+        Sort{" "}
+        <select defaultValue={order} onChange={onSort} aria-label="Sort episodes">
+          <option value="asc">Season 1 → 5</option>
+          <option value="desc">Season 5 → 1</option>
+        </select>
+      </label>
+      <span style={{ display: "inline-flex", border: "1px solid var(--line)", borderRadius: 8, overflow: "hidden" }}>
+        <button
+          onClick={() => setDensity(false)}
+          style={{ border: "none", borderRadius: 0, background: !compact ? "var(--gold-2)" : "transparent" }}
+          aria-pressed={!compact}
+        >
+          Comfortable
+        </button>
+        <button
+          onClick={() => setDensity(true)}
+          style={{ border: "none", borderRadius: 0, background: compact ? "var(--gold-2)" : "transparent" }}
+          aria-pressed={compact}
+        >
+          Compact
+        </button>
+      </span>
+    </div>
+  );
+}

← 3c1fd3a loop refine 1/n: sort (newest/oldest/source) + persisted den  ·  back to Nineoh Guide  ·  loop refine 3/n: ISR (hourly revalidate) on stable pages (ho 78a8f84 →