Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "p0dcasters",
"version": "0.5.0",
"version": "0.6.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
23 changes: 15 additions & 8 deletions src/app/category/[category]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { safeDecode, titleCase } from "@/lib/format";
import { listingJsonLd, jsonLdScript } from "@/lib/jsonld";
import Link from "next/link";
import TimeAgo from "@/components/TimeAgo";
import SortTabs from "@/components/SortTabs";
import { listHref, listSort, sortSql } from "@/lib/sort";

export const revalidate = 3600;
const PER = 60;
Expand All @@ -32,12 +34,15 @@ export default async function Category({
searchParams,
}: {
params: Promise<{ category: string }>;
searchParams: Promise<{ page?: string }>;
searchParams: Promise<{ page?: string; sort?: string }>;
}) {
const { category } = await params;
const { page } = await searchParams;
const { page, sort: sortParam } = await searchParams;
const cat = safeDecode(category);
const pg = Math.max(1, parseInt(page || "1", 10) || 1);
const sort = listSort(sortParam);
const by = sortSql(sort);
const path = `/category/${category}`;

const total = await count("SELECT COUNT(*) AS n FROM podcasts WHERE category = ?", [cat]);
if (!total) notFound();
Expand All @@ -54,8 +59,8 @@ export default async function Category({
);

const rows = await all<Podcast>(
"SELECT * FROM podcasts WHERE category = ? ORDER BY score DESC LIMIT ? OFFSET ?",
[cat, PER, (pg - 1) * PER],
`SELECT * FROM podcasts WHERE category = ?${by.where} ORDER BY ${by.order} LIMIT ? OFFSET ?`,
[cat, ...by.params, PER, (pg - 1) * PER],
);
const pages = Math.ceil(total / PER);

Expand Down Expand Up @@ -105,8 +110,9 @@ export default async function Category({
{facts?.newest ? <TimeAgo unix={facts.newest} /> : <>unknown</>}.
</li>
<li>
Ordered by catalogue depth and longevity weighted by recency — not by raw
recency, and not by popularity. <Link href="/about">Why, and what gets in</Link>.
Popular orders by catalogue depth and longevity weighted by recency;{" "}
<Link href={listHref(path, "recent")}>Recent</Link> puts the newest episode first.{" "}
<Link href="/about">Why, and what gets in</Link>.
</li>
<li>
Nothing is hosted here: each card links to the show, and the show links to its
Expand All @@ -116,6 +122,7 @@ export default async function Category({
</div>
)}
<Ad format={AD_TEXT} />
<SortTabs path={path} sort={sort} />
<div className="grid">
{(split ? rows.slice(0, split) : rows).map((p) => (
<Card key={p.id} p={p} />
Expand All @@ -133,15 +140,15 @@ export default async function Category({
{pages > 1 && (
<div className="pager">
{pg > 1 && (
<Link className="btn" href={`/category/${category}?page=${pg - 1}`}>
<Link className="btn" href={listHref(path, sort, pg - 1)}>
← Previous
</Link>
)}
<span className="btn" style={{ borderColor: "transparent" }}>
{pg} of {pages}
</span>
{pg < pages && (
<Link className="btn" href={`/category/${category}?page=${pg + 1}`}>
<Link className="btn" href={listHref(path, sort, pg + 1)}>
Next →
</Link>
)}
Expand Down
1 change: 1 addition & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ h2.sec {
.row .n { margin-left: auto; font-family: ui-monospace, Menlo, monospace; font-size: 12.5px; color: var(--muted); white-space: nowrap; }

.pager { display: flex; gap: 10px; justify-content: center; padding: 30px 0; }
.sorttabs { display: flex; gap: 8px; margin: 0 0 14px; }
.empty { padding: 46px 0; color: var(--muted); text-align: center; }
.prose { max-width: 68ch; font-size: 16.5px; line-height: 1.72; }
.prose h2 { font-size: 21px; margin: 32px 0 10px; letter-spacing: -0.01em; }
Expand Down
17 changes: 11 additions & 6 deletions src/app/language/[lang]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { languageName, normalizeLang, safeDecode } from "@/lib/format";
import { listingJsonLd, jsonLdScript } from "@/lib/jsonld";
import Link from "next/link";
import TimeAgo from "@/components/TimeAgo";
import SortTabs from "@/components/SortTabs";
import { listHref, listSort, sortSql } from "@/lib/sort";

export const revalidate = 3600;
const PER = 60;
Expand All @@ -35,11 +37,13 @@ export default async function Language({
searchParams,
}: {
params: Promise<{ lang: string }>;
searchParams: Promise<{ page?: string }>;
searchParams: Promise<{ page?: string; sort?: string }>;
}) {
const { lang } = await params;
const { page } = await searchParams;
const { page, sort: sortParam } = await searchParams;
const pg = Math.max(1, parseInt(page || "1", 10) || 1);
const sort = listSort(sortParam);
const by = sortSql(sort);

// One language, one URL. Anything that resolves to a code but isn't spelled
// like it — /language/eng, /language/en_US, an old link to /language/franç —
Expand Down Expand Up @@ -71,8 +75,8 @@ export default async function Language({
);

const rows = await all<Podcast>(
`SELECT * FROM podcasts WHERE lang_base IN (${holes}) ORDER BY score DESC LIMIT ? OFFSET ?`,
[...variants, PER, (pg - 1) * PER],
`SELECT * FROM podcasts WHERE lang_base IN (${holes})${by.where} ORDER BY ${by.order} LIMIT ? OFFSET ?`,
[...variants, ...by.params, PER, (pg - 1) * PER],
);
const pages = Math.ceil(total / PER);

Expand Down Expand Up @@ -133,6 +137,7 @@ export default async function Language({
</div>
)}
<Ad format={AD_TEXT} />
<SortTabs path={`/language/${code}`} sort={sort} />
<div className="grid">
{(split ? rows.slice(0, split) : rows).map((p) => (
<Card key={p.id} p={p} />
Expand All @@ -150,15 +155,15 @@ export default async function Language({
{pages > 1 && (
<div className="pager">
{pg > 1 && (
<Link className="btn" href={`/language/${code}?page=${pg - 1}`}>
<Link className="btn" href={listHref(`/language/${code}`, sort, pg - 1)}>
← Previous
</Link>
)}
<span className="btn" style={{ borderColor: "transparent" }}>
{pg} of {pages}
</span>
{pg < pages && (
<Link className="btn" href={`/language/${code}?page=${pg + 1}`}>
<Link className="btn" href={listHref(`/language/${code}`, sort, pg + 1)}>
Next →
</Link>
)}
Expand Down
16 changes: 11 additions & 5 deletions src/app/networks/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import Card from "@/components/Card";
import Ad from "@/components/Ad";
import { AD_TEXT } from "@/lib/ads";
import { listingJsonLd, jsonLdScript } from "@/lib/jsonld";
import SortTabs from "@/components/SortTabs";
import { listHref, listSort, sortSql } from "@/lib/sort";

const { all, count } = cachedDb(3600);

Expand All @@ -31,19 +33,22 @@ export default async function NetworkPage({
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ page?: string }>;
searchParams: Promise<{ page?: string; sort?: string }>;
}) {
const { slug } = await params;
const { page } = await searchParams;
const { page, sort: sortParam } = await searchParams;
const sort = listSort(sortParam);
const by = sortSql(sort, "score DESC, newest_pubdate DESC");
const n = networkBySlug(slug);
if (!n) notFound();
const pg = Math.max(1, parseInt(page || "1", 10) || 1);
const w = networkWhere(n);

const total = await count(`SELECT COUNT(*) AS n FROM podcasts WHERE ${w.sql}`, w.params);
const rows = total
? await all<Podcast>(`SELECT * FROM podcasts WHERE ${w.sql} ORDER BY score DESC, newest_pubdate DESC LIMIT ? OFFSET ?`, [
? await all<Podcast>(`SELECT * FROM podcasts WHERE (${w.sql})${by.where} ORDER BY ${by.order} LIMIT ? OFFSET ?`, [
...w.params,
...by.params,
PER,
(pg - 1) * PER,
])
Expand Down Expand Up @@ -82,6 +87,7 @@ export default async function NetworkPage({
</p>
)}
<Ad format={AD_TEXT} />
<SortTabs path={`/networks/${n.slug}`} sort={sort} />
<div className="grid">
{rows.map((p) => (
<Card key={p.id} p={p} />
Expand All @@ -90,15 +96,15 @@ export default async function NetworkPage({
{pages > 1 && (
<div className="pager">
{pg > 1 && (
<Link className="btn" href={`/networks/${n.slug}?page=${pg - 1}`}>
<Link className="btn" href={listHref(`/networks/${n.slug}`, sort, pg - 1)}>
← Previous
</Link>
)}
<span className="btn" style={{ borderColor: "transparent" }}>
{pg} of {pages}
</span>
{pg < pages && (
<Link className="btn" href={`/networks/${n.slug}?page=${pg + 1}`}>
<Link className="btn" href={listHref(`/networks/${n.slug}`, sort, pg + 1)}>
Next →
</Link>
)}
Expand Down
4 changes: 3 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { Podcast } from "@/lib/db";
import Card from "@/components/Card";
import Art from "@/components/Art";
import Link from "next/link";
import { recentCeiling } from "@/lib/sort";

const { all, count, languageBuckets } = cachedDb(300);

Expand All @@ -27,7 +28,8 @@ export default async function Home() {
count("SELECT COUNT(DISTINCT host) AS n FROM podcasts"),
all<Podcast>("SELECT * FROM podcasts ORDER BY score DESC LIMIT 12"),
all<Podcast>(
"SELECT * FROM podcasts WHERE episode_count >= 8 ORDER BY newest_pubdate DESC LIMIT 10",
"SELECT * FROM podcasts WHERE episode_count >= 8 AND newest_pubdate <= ? ORDER BY newest_pubdate DESC LIMIT 10",
[recentCeiling()],
),
all<CatRow>(
"SELECT category, COUNT(*) AS n FROM podcasts WHERE category IS NOT NULL GROUP BY category ORDER BY n DESC LIMIT 18",
Expand Down
19 changes: 18 additions & 1 deletion src/app/podcasts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Card from "@/components/Card";
import Ad from "@/components/Ad";
import { AD_TEXT } from "@/lib/ads";
import { listingJsonLd, jsonLdScript } from "@/lib/jsonld";
import { recentCeiling } from "@/lib/sort";

const { all, count, languageBuckets } = cachedDb(300);

Expand All @@ -26,10 +27,14 @@ export const metadata: Metadata = {
* page only points into the catalogue.
*/
export default async function Podcasts() {
const [total, hosts, featured, cats, langs] = await Promise.all([
const [total, hosts, featured, fresh, cats, langs] = await Promise.all([
count("SELECT COUNT(*) AS n FROM podcasts"),
count("SELECT COUNT(DISTINCT host) AS n FROM podcasts"),
all<Podcast>("SELECT * FROM podcasts ORDER BY score DESC LIMIT 24"),
all<Podcast>(
"SELECT * FROM podcasts WHERE episode_count >= 8 AND newest_pubdate <= ? ORDER BY newest_pubdate DESC LIMIT 12",
[recentCeiling()],
),
all<{ category: string; n: number }>(
"SELECT category, COUNT(*) AS n FROM podcasts WHERE category IS NOT NULL GROUP BY category ORDER BY n DESC LIMIT 24",
),
Expand Down Expand Up @@ -94,6 +99,18 @@ export default async function Podcasts() {

<Ad format={AD_TEXT} />

<section>
<h2 className="sec">Just published</h2>
<div className="grid">
{fresh.map((p) => (
<Card key={p.id} p={p} />
))}
</div>
<p style={{ color: "var(--muted)", margin: "10px 0 0" }}>
Every subject, language and network has its own: pick one above and choose <b>Recent</b>.
</p>
</section>

<section>
<h2 className="sec">Deep catalogues, still publishing</h2>
<div className="grid">
Expand Down
25 changes: 25 additions & 0 deletions src/components/SortTabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Link from "next/link";
import { listHref, type ListSort } from "@/lib/sort";

/** Popular / Recent switch above a listing. Changing it goes back to page 1. */
export default function SortTabs({ path, sort }: { path: string; sort: ListSort }) {
return (
<nav className="sorttabs" aria-label="Sort shows">
<Link
className={`btn small${sort === "popular" ? " primary" : ""}`}
href={listHref(path, "popular")}
aria-current={sort === "popular" ? "page" : undefined}
>
Popular
</Link>
<Link
className={`btn small${sort === "recent" ? " primary" : ""}`}
href={listHref(path, "recent")}
aria-current={sort === "recent" ? "page" : undefined}
rel="nofollow"
>
Recent
</Link>
</nav>
);
}
44 changes: 44 additions & 0 deletions src/lib/sort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* How a listing is ordered: "popular" (the score, the default) or "recent"
* (newest episode first). Every section page takes it as ?sort=recent, so a
* reader can see what just came out in one category, language or network
* rather than only on the home page.
*/

export type ListSort = "popular" | "recent";

export function listSort(raw: string | string[] | undefined): ListSort {
return raw === "recent" ? "recent" : "popular";
}

/**
* A show whose newest episode is dated more than a day ahead (one scheduled for
* next month, or a feed that stamps next year on everything) would otherwise sit
* on top of "recent" until that date. It still lists under "popular"; it joins
* "recent" when the date arrives. A day of slack covers clock skew.
*/
export const FUTURE_SLACK_SECONDS = 86_400;

export function recentCeiling(now = Date.now()): number {
return Math.floor(now / 1000) + FUTURE_SLACK_SECONDS;
}

/**
* The extra WHERE and ORDER BY for a sort. Recent walks the newest_pubdate
* index; popular keeps the score order each page already used, plus whatever
* tiebreak the page passes.
*/
export function sortSql(sort: ListSort, popularOrder = "score DESC"): { where: string; order: string; params: number[] } {
return sort === "recent"
? { where: " AND newest_pubdate <= ?", order: "newest_pubdate DESC", params: [recentCeiling()] }
: { where: "", order: popularOrder, params: [] };
}

/** A link to one page of a listing, keeping the sort (popular is the bare URL). */
export function listHref(path: string, sort: ListSort, page = 1): string {
const q = new URLSearchParams();
if (sort === "recent") q.set("sort", "recent");
if (page > 1) q.set("page", String(page));
const s = q.toString();
return s ? `${path}?${s}` : path;
}
32 changes: 32 additions & 0 deletions test/sort.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { FUTURE_SLACK_SECONDS, listHref, listSort, recentCeiling, sortSql } from '../src/lib/sort.ts';

test('only ?sort=recent switches; anything else is popular', () => {
assert.equal(listSort('recent'), 'recent');
assert.equal(listSort(undefined), 'popular');
assert.equal(listSort('newest'), 'popular');
assert.equal(listSort(['recent', 'x']), 'popular');
});

test('recent orders by the newest episode and stops a day ahead', () => {
const now = Date.UTC(2026, 9, 29);
const by = sortSql('recent');
assert.equal(by.order, 'newest_pubdate DESC');
assert.equal(by.where, ' AND newest_pubdate <= ?');
assert.equal(recentCeiling(now), now / 1000 + FUTURE_SLACK_SECONDS);
// A show whose next episode is dated next month is not "recent" yet.
assert.ok(Date.UTC(2026, 10, 25) / 1000 > recentCeiling(now));
});

test('popular keeps the page\'s own order and adds no filter', () => {
assert.deepEqual(sortSql('popular'), { where: '', order: 'score DESC', params: [] });
assert.equal(sortSql('popular', 'score DESC, newest_pubdate DESC').order, 'score DESC, newest_pubdate DESC');
});

test('links keep the sort across pages; popular page 1 is the bare URL', () => {
assert.equal(listHref('/category/comedy', 'popular'), '/category/comedy');
assert.equal(listHref('/category/comedy', 'popular', 2), '/category/comedy?page=2');
assert.equal(listHref('/category/comedy', 'recent'), '/category/comedy?sort=recent');
assert.equal(listHref('/category/comedy', 'recent', 3), '/category/comedy?sort=recent&page=3');
});
Loading