[object Object]

← back to Nineoh Guide

loop refine 1/n: sort (newest/oldest/source) + persisted density control on /news grid (Steve standing rule)

3c1fd3a8bd0b68e7d73c8b394baaa1c62b2b8242 · 2026-07-26 16:22:14 -0700 · Steve

Files touched

Diff

commit 3c1fd3a8bd0b68e7d73c8b394baaa1c62b2b8242
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun Jul 26 16:22:14 2026 -0700

    loop refine 1/n: sort (newest/oldest/source) + persisted density control on /news grid (Steve standing rule)
---
 apps/web/app/news/page.tsx           | 33 ++++++++++++-----
 apps/web/components/NewsControls.tsx | 68 ++++++++++++++++++++++++++++++++++++
 2 files changed, 93 insertions(+), 8 deletions(-)

diff --git a/apps/web/app/news/page.tsx b/apps/web/app/news/page.tsx
index 3f2ddfc..16c389c 100644
--- a/apps/web/app/news/page.tsx
+++ b/apps/web/app/news/page.tsx
@@ -1,17 +1,23 @@
 import { pool } from "@/lib/db";
+import { NewsControls } from "@/components/NewsControls";
 
 export const dynamic = "force-dynamic";
 export const metadata = { title: "News" };
 
 const PER_PAGE = 60;
+const ORDER: Record<string, string> = {
+  newest: "published_at desc nulls last",
+  oldest: "published_at asc nulls last",
+  source: "publisher_name asc nulls last, published_at desc",
+};
 
-async function getNews(offset: number) {
+async function getNews(offset: number, sort: string) {
   try {
     const total = await pool.query(`select count(*)::int as n from news_items`);
     const { rows } = await pool.query(
       `select id, headline, summary_original, canonical_url, publisher_name, published_at
          from news_items
-        order by published_at desc nulls last
+        order by ${ORDER[sort] ?? ORDER.newest}
         limit $1 offset $2`,
       [PER_PAGE, offset]
     );
@@ -24,12 +30,14 @@ async function getNews(offset: number) {
 export default async function News({
   searchParams,
 }: {
-  searchParams: Promise<{ page?: string }>;
+  searchParams: Promise<{ page?: string; sort?: string }>;
 }) {
-  const { page } = await searchParams;
+  const { page, sort: sortRaw } = await searchParams;
+  const sort = sortRaw && ORDER[sortRaw] ? sortRaw : "newest";
   const p = Math.max(1, Number(page) || 1);
-  const { rows: news, total } = await getNews((p - 1) * PER_PAGE);
+  const { rows: news, total } = await getNews((p - 1) * PER_PAGE, sort);
   const pages = Math.ceil(total / PER_PAGE);
+  const q = (n: number) => `/news?sort=${sort}&page=${n}`;
 
   return (
     <section>
@@ -40,10 +48,19 @@ export default async function News({
         reproduce article text.
       </p>
 
+      <NewsControls sort={sort} />
+
       {news.length === 0 ? (
         <p style={{ color: "var(--maroon)" }}>No news items yet.</p>
       ) : (
-        <div style={{ display: "grid", gap: 10, marginTop: 16 }}>
+        <div
+          style={{
+            display: "grid",
+            gap: 10,
+            marginTop: 8,
+            gridTemplateColumns: "repeat(var(--news-cols, 1), minmax(0, 1fr))",
+          }}
+        >
           {news.map((n) => (
             <a
               key={n.id}
@@ -79,11 +96,11 @@ export default async function News({
 
       {pages > 1 && (
         <div style={{ display: "flex", gap: 12, marginTop: 20, alignItems: "center" }}>
-          {p > 1 ? <a href={`/news?page=${p - 1}`}>← Newer</a> : <span />}
+          {p > 1 ? <a href={q(p - 1)}>← Prev</a> : <span />}
           <span style={{ fontSize: 13, color: "var(--muted)" }}>
             Page {p} of {pages}
           </span>
-          {p < pages ? <a href={`/news?page=${p + 1}`}>Older →</a> : <span />}
+          {p < pages ? <a href={q(p + 1)}>Next →</a> : <span />}
         </div>
       )}
     </section>
diff --git a/apps/web/components/NewsControls.tsx b/apps/web/components/NewsControls.tsx
new file mode 100644
index 0000000..a3b5d2d
--- /dev/null
+++ b/apps/web/components/NewsControls.tsx
@@ -0,0 +1,68 @@
+"use client";
+import { useEffect, useState } from "react";
+
+/**
+ * Sort + density controls for the news grid (Steve's standing rule: every grid
+ * gets sort + a density control, both persisted). Density writes a CSS var the
+ * grid reads; sort navigates with a query param (server does the ordering).
+ */
+const KEY = "nineoh.newsCols.v1";
+
+export function NewsControls({ sort }: { sort: string }) {
+  const [cols, setCols] = useState(1);
+
+  useEffect(() => {
+    const saved = Number(localStorage.getItem(KEY));
+    const c = saved >= 1 && saved <= 4 ? saved : 1;
+    setCols(c);
+    document.documentElement.style.setProperty("--news-cols", String(c));
+  }, []);
+
+  const onCols = (c: number) => {
+    setCols(c);
+    document.documentElement.style.setProperty("--news-cols", String(c));
+    localStorage.setItem(KEY, String(c));
+  };
+
+  const onSort = (e: React.ChangeEvent<HTMLSelectElement>) => {
+    const url = new URL(window.location.href);
+    url.searchParams.set("sort", e.target.value);
+    url.searchParams.delete("page");
+    window.location.href = url.toString();
+  };
+
+  return (
+    <div
+      style={{
+        display: "flex",
+        gap: 14,
+        alignItems: "center",
+        flexWrap: "wrap",
+        margin: "10px 0 16px",
+        fontSize: 13,
+      }}
+    >
+      <label>
+        Sort{" "}
+        <select defaultValue={sort} onChange={onSort} aria-label="Sort news">
+          <option value="newest">Newest</option>
+          <option value="oldest">Oldest</option>
+          <option value="source">Source A–Z</option>
+        </select>
+      </label>
+      <label style={{ display: "flex", alignItems: "center", gap: 6 }}>
+        Density
+        <input
+          type="range"
+          min={1}
+          max={4}
+          value={cols}
+          onChange={(e) => onCols(Number(e.target.value))}
+          aria-label="News density (columns)"
+          style={{ width: 90 }}
+        />
+        <span style={{ color: "var(--muted)" }}>{cols}-up</span>
+      </label>
+    </div>
+  );
+}

← 59829db richer episodes: per-episode guest/recurring cast (1160 cred  ·  back to Nineoh Guide  ·  loop refine 2/n: sort (season asc/desc) + comfortable/compac 4c1a88b →