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
42 changes: 25 additions & 17 deletions docs/16-learning-areas.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ area section is written so one work session can pick it up on its own.
| Area | Route | Status | What it is |
|---|---|---|---|
| Courses | `/catalog`, `/my-courses`, `/courses/...` | Open | Full courses: read, play, listen, prove. |
| Listen | `/listen` | Open | Every course with listen-along audio, its chapters and hours. Videos later. |
| Audiobooks | `/audiobooks`, `/audiobooks/[course]` | Open | Every narrated course as an audiobook, with a player that follows you across pages. |
| Labs | `/labs` | Partly open | Visual labs open; guided and shell labs planned. |
| Five-minute practice | `/practice` | Coming soon | One topic a few minutes a day, led by questions. |
| Puzzles | `/puzzles` | Coming soon | Several kinds of daily puzzle. |
Expand All @@ -42,13 +42,13 @@ an area, change its `status` there and replace its overview page with the real f
**Routes.** The area pages live in the `(catalog)` route group, next to `/catalog`, so they share its
layout: public for visitors (a simple header with Courses, Labs and All areas), and inside the learner
workspace shell for signed-in learners. They need no API and no database, so they render even when the
API is down. The one exception is `/listen`, which reads the catalog for course titles and shows an
API is down. The one exception is `/audiobooks`, which reads the catalog for course titles and shows an
empty state when the API is unreachable.

```
frontend/src/app/(learn)/(catalog)/
catalog/ Courses (existing)
listen/ Listen: courses with audio, read from the live manifests
audiobooks/ Audiobooks: narrated courses as books, read from the live manifests
learn/ The hub: every area on one page
labs/ Labs overview: the three kinds, open challenges, how a visual lab works
visual/ Every visual lab, grouped
Expand Down Expand Up @@ -85,23 +85,31 @@ gate that route (and drop the slugs from `sitemap.ts`).
Unchanged. See [`01-domain-model.mdx`](01-domain-model.mdx) through [`12-course-variants-and-publishing.mdx`](12-course-variants-and-publishing.mdx).
Courses stay the spine: every other area links back to the modules it practises.

## Listen (open)
## Audiobooks (open)

Code: `app/(learn)/(catalog)/listen/page.tsx`, `lib/listen-library.ts` (server loader), `lib/listen-summary.ts`
(pure helpers, tested in `tests/unit/lib/listen-summary.test.ts`).
Replaced the Listen area in November 2026. `/listen` and anything under it redirect to `/audiobooks`
(permanent, in `next.config.ts`).

- Course list: the published catalog (`/api/v1/catalog`). Audio: each course's live manifest at
Code: `app/(learn)/(catalog)/audiobooks/page.tsx` (shelf), `audiobooks/[courseSlug]/page.tsx` (book page),
`lib/audiobook-library.ts` (server loader), `lib/audiobooks.ts` and `lib/audiobook-cover.ts` (pure helpers,
tested in `tests/unit/lib/audiobooks.test.ts`), `lib/audiobook-progress.ts` (saved place) and
`components/audiobooks/` (cover, shelf, book page, player).

- Books: the published catalog (`/api/v1/catalog`) joined with each course's live manifest at
`https://audio.edorax.com/<courseSlug>/manifest.json`, cached for five minutes. `NARRATION_BASE_URL`
(https only) points it elsewhere, as it does for the reader. A course shows up as soon as its
manifest exists; nothing in the database changes.
- Each course card shows its chapter count and total hours, a "Listen from the start" button and the
full chapter list. Links open the reader at `/courses/<course>/<chapter>#listen`, the chapter's
Listen player (`components/learn/chapter-audio.tsx` carries `id="listen"`).
- Courses are grouped by catalog category, in the order of `lib/course-categories.ts`.
- Local development reads reader audio from `public/narration`, which has only a few courses, so a
chapter link can open a reader without a player there. Production has every manifest.
- Videos: shown as coming soon on the page and the landing card. When they exist they can join this
area (a "Watch" tab beside Listen) or become their own area; decide when the first videos ship.
(https only) points it elsewhere. A course shows up as soon as it has a manifest; nothing is generated here.
- Tracks: the manifest's numbered files (`01.02-...mp3`) give the part and chapter; chapter and part titles
come from the public course outline (`/api/v1/catalog/<slug>`), falling back to the slug.
- Covers: drawn as SVG from the title, subtitle (after the first colon), category and author (Zeeshan Adil)
on the Edorax palette. The same SVG, turned into a PNG, is the lock-screen artwork.
- Player: `AudiobookPlayerProvider` in the root layout, so audio keeps playing across pages. A mini player
sits above the phone tab bar and beside the desktop sidebar (CSS variables `--audiobook-bar-*` in
`globals.css`); it opens a full player with skip back 15 s and forward 30 s, 0.75x to 2x, a sleep timer,
the chapter list and auto-advance. Media Session handles lock-screen and headphone controls. The
chapter reader's own Listen player and this one pause each other (`data-narration`).
- Saved place: per chapter and per book in `localStorage` (`edorax:audiobooks:v1`), on this device only,
because there is no listening-progress model on the server yet. It also writes the reader player's
per-file key, so stopping in one player resumes in the other.

## Course categories

Expand Down
7 changes: 7 additions & 0 deletions frontend/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ const nextConfig: NextConfig = {
},
];
},
async redirects() {
// The Listen area became Audiobooks; old links and bookmarks land on the shelf.
return [
{ source: "/listen", destination: "/audiobooks", permanent: true },
{ source: "/listen/:path*", destination: "/audiobooks", permanent: true },
];
},
async rewrites() {
return {
beforeFiles: [],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { SITE_SHARE_IMAGE } from "@/lib/site";
import { loadAudiobook } from "@/lib/audiobook-library";
import { categoryLabel, formatBookLength } from "@/lib/audiobooks";
import { AreaTabs } from "@/components/areas/area-tabs";
import { BookCover } from "@/components/audiobooks/book-cover";
import { BookResume, TrackList } from "@/components/audiobooks/book-detail-client";

type Params = { params: Promise<{ courseSlug: string }> };

export async function generateMetadata({ params }: Params): Promise<Metadata> {
const { courseSlug } = await params;
const book = await loadAudiobook(courseSlug);
if (!book) return { title: "Audiobook not found" };
return {
title: `${book.title}, the audiobook`,
description: `${book.tracks.length} chapters, ${formatBookLength(book.totalMinutes)}, by ${book.author}.${book.description ? ` ${book.description.slice(0, 200)}` : ""}`,
alternates: { canonical: `/audiobooks/${book.slug}` },
openGraph: { title: `${book.title}, the audiobook`, url: `/audiobooks/${book.slug}`, images: [SITE_SHARE_IMAGE] },
};
}

export default async function AudiobookPage({ params }: Params) {
const { courseSlug } = await params;
const book = await loadAudiobook(courseSlug);
if (!book) notFound();

return (
<div className="mx-auto w-full max-w-6xl px-4 py-7 sm:px-6 sm:py-10 lg:px-8">
<AreaTabs />
<Link
href="/audiobooks"
className="mt-6 inline-flex min-h-11 items-center gap-2 rounded-sm text-base font-bold text-primary outline-none hover:underline focus-visible:outline-3 focus-visible:outline-brand-yellow"
>
<ArrowLeft aria-hidden className="size-4" />
All audiobooks
</Link>

<div className="mt-4 grid gap-8 md:grid-cols-[15rem_minmax(0,1fr)] md:gap-12 lg:grid-cols-[18rem_minmax(0,1fr)]">
<div className="mx-auto w-44 sm:w-52 md:sticky md:top-24 md:mx-0 md:w-full md:self-start">
<BookCover book={book} />
</div>

<div className="min-w-0">
<p className="text-sm font-bold uppercase tracking-wider text-primary">
{categoryLabel(book.category)}
{book.subcategory ? ` · ${book.subcategory}` : ""}
</p>
<h1 className="mt-2 text-[28px] leading-tight tracking-[-0.035em] sm:text-[36px]">{book.title}</h1>
<p className="mt-2 text-lg">
<span className="text-muted-foreground">Written by </span>
<span className="font-bold">{book.author}</span>
</p>
<p className="mt-1 text-base tabular-nums text-muted-foreground">
{formatBookLength(book.totalMinutes)} · {book.parts.length} {book.parts.length === 1 ? "part" : "parts"} · {book.tracks.length} chapters
</p>

<BookResume book={book} />

{book.description ? <p className="mt-8 max-w-2xl text-base leading-relaxed text-muted-foreground">{book.description}</p> : null}
<p className="mt-3 text-base">
<Link href={`/catalog/${book.slug}`} className="rounded-sm font-bold text-primary underline-offset-4 outline-none hover:underline focus-visible:outline-3 focus-visible:outline-brand-yellow">
Read the course
</Link>
<span className="text-muted-foreground">: the same chapters with diagrams, labs and quizzes.</span>
</p>

<h2 className="mt-10 text-2xl tracking-[-0.02em]">Chapters</h2>
<div className="mt-4">
<TrackList book={book} />
</div>
</div>
</div>
</div>
);
}
99 changes: 99 additions & 0 deletions frontend/src/app/(learn)/(catalog)/audiobooks/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { Metadata } from "next";
import Link from "next/link";
import { SITE_SHARE_IMAGE } from "@/lib/site";
import { areaById } from "@/lib/learn-areas";
import { COURSE_CATEGORY_INFO } from "@/lib/course-categories";
import { loadAudiobookLibrary } from "@/lib/audiobook-library";
import { formatBookLength, type Audiobook } from "@/lib/audiobooks";
import { AreaHero, AreaPage, AreaSection } from "@/components/areas/area-page";
import { BookCover } from "@/components/audiobooks/book-cover";
import { ContinueListening, ShelfProgress } from "@/components/audiobooks/library-client";
import { Dot } from "@/components/brand/dot";

export const metadata: Metadata = {
title: "Audiobooks",
description: "Every narrated Edorax course as an audiobook: chapters as tracks, a player that follows you around the site, and your place kept in every book.",
alternates: { canonical: "/audiobooks" },
openGraph: { title: "Audiobooks on Edorax", url: "/audiobooks", images: [SITE_SHARE_IMAGE] },
};

function ShelfBook({ book }: { book: Audiobook }) {
return (
<li data-testid="audiobook">
<Link
href={`/audiobooks/${book.slug}`}
className="group block rounded-sm outline-none focus-visible:outline-3 focus-visible:outline-offset-4 focus-visible:outline-brand-yellow"
>
<BookCover book={book} className="transition-transform duration-200 motion-safe:group-hover:-translate-y-1" />
<h3 className="mt-3 line-clamp-2 font-sans text-base font-bold leading-snug tracking-normal group-hover:underline">{book.title}</h3>
</Link>
<p className="mt-0.5 text-sm text-muted-foreground">{book.author}</p>
<p className="text-sm tabular-nums text-muted-foreground">
{formatBookLength(book.totalMinutes)}, {book.tracks.length} chapters
</p>
<ShelfProgress book={book} />
</li>
);
}

export default async function AudiobooksPage() {
const area = areaById("audiobooks");
const { books, catalogReachable } = await loadAudiobookLibrary();
const minutes = books.reduce((sum, book) => sum + book.totalMinutes, 0);
const chapters = books.reduce((sum, book) => sum + book.tracks.length, 0);
const shelves = COURSE_CATEGORY_INFO.map((category) => ({
...category,
books: books.filter((book) => book.category === category.value),
})).filter((shelf) => shelf.books.length > 0);

return (
<AreaPage>
<AreaHero
eyebrow="Audiobooks"
title={area.name}
status={area.status}
mood="happy"
intro="Every course with narration, as a book you can listen to from start to finish. Chapters play one after another, the player stays with you as you move around Edorax, and your place in each book is kept on this device."
>
{books.length > 0 ? (
<dl className="flex flex-wrap gap-x-8 gap-y-3" data-testid="audiobook-totals">
{[
["Books", String(books.length)],
["Chapters", String(chapters)],
["Listening", formatBookLength(minutes)],
].map(([label, value]) => (
<div key={label}>
<dt className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{label}</dt>
<dd className="text-2xl font-bold tabular-nums tracking-[-0.02em]">{value}</dd>
</div>
))}
</dl>
) : null}
</AreaHero>

<ContinueListening books={books} />

{shelves.length > 0 ? (
shelves.map((shelf) => (
<AreaSection key={shelf.value} id={`shelf-${shelf.param}`} title={shelf.label} intro={shelf.description}>
<ul className="grid grid-cols-2 gap-x-5 gap-y-8 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{shelf.books.map((book) => (
<ShelfBook key={book.slug} book={book} />
))}
</ul>
</AreaSection>
))
) : (
<div className="mt-10 flex flex-col items-center border border-border bg-card px-6 py-14 text-center" data-testid="audiobooks-empty">
<Dot mood="sleep" size={112} />
<h2 className="mt-4 text-xl tracking-[-0.02em]">{catalogReachable ? "No audiobooks yet" : "Books could not be loaded"}</h2>
<p className="mt-2 max-w-sm text-base text-muted-foreground">
{catalogReachable
? "A course shows up here as soon as its chapters are read aloud."
: "The course list is not reachable right now. Try again in a moment."}
</p>
</div>
)}
</AreaPage>
);
}
2 changes: 1 addition & 1 deletion frontend/src/app/(learn)/(catalog)/learn/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export default function LearnHubPage() {
href={area.href}
status={area.status}
icon={<Icon className="size-6" />}
cta={area.status === "coming-soon" ? "See what is coming" : area.id === "listen" ? "Start listening" : `Open ${area.short.toLowerCase()}`}
cta={area.status === "coming-soon" ? "See what is coming" : area.id === "audiobooks" ? "Open the shelf" : `Open ${area.short.toLowerCase()}`}
meta={area.tagline}
/>
);
Expand Down
Loading
Loading