← 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
M apps/web/app/news/page.tsxA apps/web/components/NewsControls.tsx
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 →