← 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
M apps/web/app/episodes/page.tsxM apps/web/app/globals.cssA apps/web/components/EpisodeControls.tsx
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 →