feat: implement bff layer
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getBookMeta } from "@/lib/api";
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ book: string }> }
|
||||
{ params }: { params: Promise<{ book: string }> },
|
||||
) {
|
||||
const { book } = await params;
|
||||
const meta = await prisma.bookMeta.findUnique({ where: { book } });
|
||||
const meta = await getBookMeta(book);
|
||||
if (!meta) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
return NextResponse.json({ book: meta.book, ...meta.data as Record<string, unknown> });
|
||||
return NextResponse.json(meta);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
getSingleVerse,
|
||||
getChapterVerseIndex,
|
||||
getChapterVerses,
|
||||
} from "@/lib/api";
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
@@ -7,7 +11,7 @@ export async function GET(
|
||||
) {
|
||||
const { book } = await params;
|
||||
const url = new URL(req.url);
|
||||
const division = url.searchParams.get("division");
|
||||
const division = url.searchParams.get("division") || null;
|
||||
const chapterStr = url.searchParams.get("chapter");
|
||||
const verseStr = url.searchParams.get("verse");
|
||||
const indexOnly = url.searchParams.get("indexOnly") === "true";
|
||||
@@ -22,20 +26,18 @@ export async function GET(
|
||||
}
|
||||
|
||||
const chapter = parseInt(chapterStr);
|
||||
const where: Record<string, unknown> = { book, chapter };
|
||||
|
||||
if (book === "bg") {
|
||||
where.division = null;
|
||||
} else if (division) {
|
||||
where.division = division;
|
||||
}
|
||||
const effectiveDivision = book === "bg" ? null : division;
|
||||
|
||||
// --- Single verse mode: ?verse=N ---
|
||||
if (verseStr) {
|
||||
const verseNum = parseInt(verseStr);
|
||||
const verse = await prisma.verse.findFirst({
|
||||
where: { ...where, verse: verseNum },
|
||||
});
|
||||
const verse = await getSingleVerse(
|
||||
book,
|
||||
effectiveDivision,
|
||||
chapter,
|
||||
verseNum,
|
||||
);
|
||||
|
||||
if (!verse) {
|
||||
return NextResponse.json({ error: "Verse not found" }, { status: 404 });
|
||||
@@ -46,15 +48,7 @@ export async function GET(
|
||||
|
||||
// --- Index-only mode: return lightweight verse list for the sidebar ---
|
||||
if (indexOnly) {
|
||||
const verses = await prisma.verse.findMany({
|
||||
where,
|
||||
orderBy: { verse: "asc" },
|
||||
select: {
|
||||
verse: true,
|
||||
verseEnd: true,
|
||||
reference: true,
|
||||
},
|
||||
});
|
||||
const verses = await getChapterVerseIndex(book, effectiveDivision, chapter);
|
||||
|
||||
return NextResponse.json({ verses, total: verses.length });
|
||||
}
|
||||
@@ -62,24 +56,14 @@ export async function GET(
|
||||
// --- Paginated mode: return full verse data in pages ---
|
||||
const page = Math.max(1, parseInt(pageStr || "1"));
|
||||
const limit = Math.min(100, Math.max(1, parseInt(limitStr || "20")));
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [verses, total] = await Promise.all([
|
||||
prisma.verse.findMany({
|
||||
where,
|
||||
orderBy: { verse: "asc" },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.verse.count({ where }),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
verses,
|
||||
total,
|
||||
const result = await getChapterVerses(
|
||||
book,
|
||||
effectiveDivision,
|
||||
chapter,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
hasMore: skip + verses.length < total,
|
||||
});
|
||||
);
|
||||
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getAllBooks } from "@/lib/api";
|
||||
|
||||
export async function GET() {
|
||||
const metas = await prisma.bookMeta.findMany();
|
||||
return NextResponse.json(metas.map((m) => ({ book: m.book, ...m.data as Record<string, unknown> })));
|
||||
const books = await getAllBooks();
|
||||
return NextResponse.json(books);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getAllGlossaryEntries } from "@/lib/api";
|
||||
|
||||
export async function GET() {
|
||||
const entries = await prisma.glossaryEntry.findMany({
|
||||
orderBy: { term: "asc" },
|
||||
});
|
||||
const entries = await getAllGlossaryEntries();
|
||||
return NextResponse.json(entries);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,6 @@ export default async function BbdPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -162,14 +162,7 @@ function SidebarLink({
|
||||
item: BkSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
let label: string;
|
||||
if (item.type === "chapter" && item.chapter != null) {
|
||||
// Extract just the title portion after "Chapter N: "
|
||||
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
|
||||
label = `${item.chapter}. ${titlePart}`;
|
||||
} else {
|
||||
label = item.label;
|
||||
}
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -118,7 +118,6 @@ export default async function BsPage() {
|
||||
{introduction.title}
|
||||
</p>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{introduction.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -133,7 +132,6 @@ export default async function BsPage() {
|
||||
{foreword.title}
|
||||
</p>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{foreword.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -162,14 +162,7 @@ function SidebarLink({
|
||||
item: CbhcSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
let label: string;
|
||||
if (item.type === "chapter" && item.chapter != null) {
|
||||
// Extract just the title portion after "Chapter N: "
|
||||
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
|
||||
label = `${item.chapter}. ${titlePart}`;
|
||||
} else {
|
||||
label = item.label;
|
||||
}
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -63,7 +63,6 @@ export default async function DsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -63,7 +63,6 @@ export default async function EjPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -60,7 +60,6 @@ export default async function EkPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -217,14 +217,7 @@ function SidebarLink({
|
||||
item: GgSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
let label: string;
|
||||
if (item.type === "chapter" && item.chapter != null) {
|
||||
// Extract just the title portion after "Chapter N: "
|
||||
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
|
||||
label = `${item.chapter}. ${titlePart}`;
|
||||
} else {
|
||||
label = item.label;
|
||||
}
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -64,7 +64,6 @@ export default async function GgPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -126,7 +125,6 @@ export default async function GgPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -60,7 +60,6 @@ export default async function HkhPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -50,8 +50,11 @@ export async function generateMetadata({
|
||||
const isVerse_ = content && "mantra" in content && "devanagari" in content;
|
||||
const paragraphs = content
|
||||
? isVerse_
|
||||
? [(content as IsoVerse).translation, ...(content as IsoVerse).purport]
|
||||
: (content as IsoIntroSegment).paragraphs
|
||||
? [
|
||||
(content as unknown as IsoVerse).translation,
|
||||
...(content as unknown as IsoVerse).purport,
|
||||
]
|
||||
: (content as unknown as IsoIntroSegment).paragraphs
|
||||
: undefined;
|
||||
|
||||
const quote = extractQuoteFromHl(sp, paragraphs);
|
||||
@@ -64,7 +67,7 @@ export async function generateMetadata({
|
||||
}
|
||||
|
||||
const title = isVerse_
|
||||
? `${(content as IsoVerse).reference}: ${(content as IsoVerse).title}`
|
||||
? `${(content as unknown as IsoVerse).reference}`
|
||||
: content && "title" in content
|
||||
? (content as IsoIntroSegment).title
|
||||
: `Mantra ${slug}`;
|
||||
@@ -110,7 +113,7 @@ export default async function IsoMantraPage({
|
||||
{verse && (
|
||||
<>
|
||||
<span className="text-primary">{verse.reference}: </span>
|
||||
{verse.title}
|
||||
{verse.bookTitle}
|
||||
</>
|
||||
)}
|
||||
{intro && intro.title}
|
||||
|
||||
@@ -68,7 +68,6 @@ export default async function IsoPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{frontPages.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -85,7 +84,6 @@ export default async function IsoPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{introduction.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -164,7 +162,6 @@ export default async function IsoPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{aboutAuthor.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -72,7 +72,6 @@ export default async function KbVolumePage({
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -106,7 +105,6 @@ export default async function KbVolumePage({
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{ch.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -221,14 +221,7 @@ function SidebarLink({
|
||||
item: LclSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
let label: string;
|
||||
if (item.type === "chapter" && item.chapter != null) {
|
||||
// Extract just the title portion after "Chapter N: "
|
||||
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
|
||||
label = `${item.chapter}. ${titlePart}`;
|
||||
} else {
|
||||
label = item.label;
|
||||
}
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -64,7 +64,6 @@ export default async function LclPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -126,7 +125,6 @@ export default async function LclPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -60,7 +60,6 @@ export default async function LobPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -60,7 +60,6 @@ export default async function MgPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -116,7 +116,6 @@ export default async function MmPage() {
|
||||
{introduction.title}
|
||||
</p>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{introduction.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -60,7 +60,6 @@ export default async function MogPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -116,7 +116,6 @@ export default async function NbsPage() {
|
||||
{introduction.title}
|
||||
</p>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{introduction.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -203,14 +203,7 @@ function SidebarLink({
|
||||
item: NodSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
let label: string;
|
||||
if (item.type === "chapter" && item.chapter != null) {
|
||||
// Extract just the title portion after "Chapter N: "
|
||||
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
|
||||
label = `${item.chapter}. ${titlePart}`;
|
||||
} else {
|
||||
label = item.label;
|
||||
}
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -70,7 +70,6 @@ export default async function NodPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -65,7 +65,6 @@ export default async function NoiPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{frontPages.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -82,7 +81,6 @@ export default async function NoiPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{preface.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -141,7 +139,6 @@ export default async function NoiPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{appendixes.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -60,7 +60,6 @@ export default async function OchkmPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -60,7 +60,6 @@ export default async function OwkPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -60,7 +60,6 @@ export default async function PoyPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -239,14 +239,7 @@ function SidebarLink({
|
||||
item: PqpaSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
let label: string;
|
||||
if (item.type === "chapter" && item.chapter != null) {
|
||||
// Extract just the title portion after "Chapter N: "
|
||||
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
|
||||
label = `${item.chapter}. ${titlePart}`;
|
||||
} else {
|
||||
label = item.label;
|
||||
}
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -64,7 +64,6 @@ export default async function PqpaPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -151,7 +150,6 @@ export default async function PqpaPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -60,7 +60,6 @@ export default async function RvPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -64,7 +64,6 @@ export default async function SsrPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -123,7 +122,6 @@ export default async function SsrPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -64,7 +64,6 @@ export default async function SvaPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -123,7 +122,6 @@ export default async function SvaPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -141,6 +141,31 @@ function TlcSidebar({
|
||||
const introItems = items.filter((item) => item.type === "intro-segment");
|
||||
const chapterItems = items.filter((item) => item.type === "chapter");
|
||||
|
||||
// Group chapters by part (if parts exist)
|
||||
const hasParts = chapterItems.some((item) => item.partNumber != null);
|
||||
|
||||
const partGroups: {
|
||||
partNumber: number;
|
||||
partTitle: string;
|
||||
chapters: TlcSidebarItem[];
|
||||
}[] = [];
|
||||
if (hasParts) {
|
||||
const partMap = new Map<
|
||||
number,
|
||||
{ partTitle: string; chapters: TlcSidebarItem[] }
|
||||
>();
|
||||
for (const item of chapterItems) {
|
||||
const pn = item.partNumber ?? 0;
|
||||
const pt = item.partTitle ?? `Part ${pn}`;
|
||||
if (!partMap.has(pn)) partMap.set(pn, { partTitle: pt, chapters: [] });
|
||||
partMap.get(pn)!.chapters.push(item);
|
||||
}
|
||||
for (const [partNumber, { partTitle, chapters }] of partMap) {
|
||||
partGroups.push({ partNumber, partTitle, chapters });
|
||||
}
|
||||
partGroups.sort((a, b) => a.partNumber - b.partNumber);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block w-56 shrink-0 sticky top-[89px] self-start max-h-[calc(100vh-89px)] overflow-y-auto py-8 pr-4">
|
||||
{/* Front Matter */}
|
||||
@@ -161,23 +186,40 @@ function TlcSidebar({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
{/* Chapters — grouped by part if available */}
|
||||
{hasParts
|
||||
? partGroups.map((group) => (
|
||||
<div key={group.partNumber} className="mb-4">
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
{group.partTitle}
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{group.chapters.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
))
|
||||
: chapterItems.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3 px-2">
|
||||
Chapters
|
||||
</h2>
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{chapterItems.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.slug}
|
||||
item={item}
|
||||
isActive={item.slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -189,14 +231,7 @@ function SidebarLink({
|
||||
item: TlcSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
let label: string;
|
||||
if (item.type === "chapter" && item.chapter != null) {
|
||||
// Extract just the title portion after "Chapter N: "
|
||||
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
|
||||
label = `${item.chapter}. ${titlePart}`;
|
||||
} else {
|
||||
label = item.label;
|
||||
}
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -68,7 +68,6 @@ export default async function TlcPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -226,14 +226,7 @@ function SidebarLink({
|
||||
item: TlkSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
let label: string;
|
||||
if (item.type === "chapter" && item.chapter != null) {
|
||||
// Extract just the title portion after "Chapter N: "
|
||||
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
|
||||
label = `${item.chapter}. ${titlePart}`;
|
||||
} else {
|
||||
label = item.label;
|
||||
}
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -64,7 +64,6 @@ export default async function TlkPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -126,7 +125,6 @@ export default async function TlkPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -226,14 +226,7 @@ function SidebarLink({
|
||||
item: TqkSidebarItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
let label: string;
|
||||
if (item.type === "chapter" && item.chapter != null) {
|
||||
// Extract just the title portion after "Chapter N: "
|
||||
const titlePart = item.label.replace(/^Chapter \d+:\s*/, "");
|
||||
label = `${item.chapter}. ${titlePart}`;
|
||||
} else {
|
||||
label = item.label;
|
||||
}
|
||||
const label = item.label;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -64,7 +64,6 @@ export default async function TqkPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -126,7 +125,6 @@ export default async function TqkPage() {
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground sm:shrink-0 sm:ml-4">
|
||||
{seg.paragraphs.length} paragraphs
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
+11
-27
@@ -10,8 +10,7 @@ import { buttonVariants } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobileNav, type MobileNavGroup } from "@/components/mobile-nav";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { JsonValue } from "@prisma/client/runtime/library";
|
||||
import { bff, bffList } from "@/lib/bff";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML sanitisation (same pattern used across all readers)
|
||||
@@ -31,25 +30,6 @@ function sanitizeHtml(html: string): string {
|
||||
return clean;
|
||||
}
|
||||
|
||||
function normalizeParagraphs(paragraphs: string[] | JsonValue): string[] {
|
||||
let result: string[];
|
||||
|
||||
if (typeof paragraphs === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(paragraphs);
|
||||
result = Array.isArray(parsed) ? parsed : [paragraphs];
|
||||
} catch {
|
||||
result = [paragraphs];
|
||||
}
|
||||
} else if (Array.isArray(paragraphs)) {
|
||||
result = paragraphs as string[];
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
|
||||
return result.map(sanitizeHtml);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data fetching
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -69,7 +49,14 @@ type SidebarItem = {
|
||||
};
|
||||
|
||||
async function getEssay(slug: string): Promise<EssayData | null> {
|
||||
const doc = await prisma.essay.findUnique({ where: { slug } });
|
||||
const doc = await bff<{
|
||||
slug: string;
|
||||
title: string;
|
||||
author: string;
|
||||
year: number | null;
|
||||
group: string | null;
|
||||
paragraphs: string[];
|
||||
}>(`/essays/${slug}`);
|
||||
if (!doc) return null;
|
||||
return {
|
||||
slug: doc.slug,
|
||||
@@ -77,15 +64,12 @@ async function getEssay(slug: string): Promise<EssayData | null> {
|
||||
author: doc.author,
|
||||
year: doc.year,
|
||||
group: doc.group,
|
||||
paragraphs: normalizeParagraphs(doc.paragraphs),
|
||||
paragraphs: (doc.paragraphs ?? []).map(sanitizeHtml),
|
||||
};
|
||||
}
|
||||
|
||||
async function getEssaySidebar(): Promise<SidebarItem[]> {
|
||||
return prisma.essay.findMany({
|
||||
orderBy: [{ group: "asc" }, { order: "asc" }],
|
||||
select: { slug: true, title: true },
|
||||
});
|
||||
return bffList<SidebarItem>("/essays");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+7
-5
@@ -4,7 +4,7 @@ import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { buildOgMeta } from "@/lib/og";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { bffList } from "@/lib/bff";
|
||||
import { EssaysGrid } from "./essays-grid";
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
@@ -17,10 +17,12 @@ export function generateMetadata(): Metadata {
|
||||
}
|
||||
|
||||
async function getAllEssays() {
|
||||
return prisma.essay.findMany({
|
||||
orderBy: [{ group: "asc" }, { order: "asc" }],
|
||||
select: { slug: true, title: true, year: true, group: true },
|
||||
});
|
||||
return bffList<{
|
||||
slug: string;
|
||||
title: string;
|
||||
year: number | null;
|
||||
group: string | null;
|
||||
}>("/essays");
|
||||
}
|
||||
|
||||
export default async function EssaysPage() {
|
||||
|
||||
@@ -67,7 +67,11 @@ export function PlayAudioButton({
|
||||
aria-label={isThisPlaying ? "Pause" : "Play audio"}
|
||||
>
|
||||
{isThisPlaying ? (
|
||||
<Pause className="size-3.5" />
|
||||
<AudioWaveAnimation
|
||||
paused={false}
|
||||
colorClassName="bg-primary-foreground"
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<Play className="size-3.5 ml-0.5" />
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,8 @@ import { buildOgMeta } from "@/lib/og";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Clock } from "lucide-react";
|
||||
import { Clock, MapPin } from "lucide-react";
|
||||
import { PlayAudioButton } from "@/app/transcriptions/[slug]/play-audio-button";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
@@ -60,7 +61,7 @@ export default async function TranscriptionsByPlacePage({
|
||||
<Link
|
||||
key={t.slug}
|
||||
href={`/transcriptions/${t.slug}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 py-3.5 gap-1 sm:gap-4 hover:bg-muted transition-colors"
|
||||
className="flex items-center px-5 py-3.5 gap-3 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -87,6 +88,16 @@ export default async function TranscriptionsByPlacePage({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{t.audioUrl && (
|
||||
<PlayAudioButton
|
||||
audioUrl={t.audioUrl}
|
||||
title={t.name}
|
||||
subtitle={[t.date, t.place].filter(Boolean).join(" · ")}
|
||||
sourceUrl={`/transcriptions/${t.slug}`}
|
||||
type={t.type}
|
||||
variant="compact"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</CardContent>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { buttonVariants } from "@/components/ui/button";
|
||||
import { ReadingSettings } from "@/components/reading-settings";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronLeft, ChevronRight, Menu, Clock, MapPin } from "lucide-react";
|
||||
import { PlayAudioButton } from "@/app/transcriptions/[slug]/play-audio-button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
@@ -34,6 +35,7 @@ export type TranscriptionItem = {
|
||||
reference: string | null;
|
||||
durationMinutes: number | null;
|
||||
participants: string[];
|
||||
audioUrl: string | null;
|
||||
};
|
||||
|
||||
type YearGroup = {
|
||||
@@ -364,7 +366,7 @@ function MonthSection({
|
||||
<li key={t.slug}>
|
||||
<Link
|
||||
href={`/transcriptions/${t.slug}`}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-5 pl-12 py-3.5 gap-1 sm:gap-4 hover:bg-muted transition-colors"
|
||||
className="flex items-center px-5 pl-12 py-3.5 gap-3 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -396,6 +398,16 @@ function MonthSection({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{t.audioUrl && (
|
||||
<PlayAudioButton
|
||||
audioUrl={t.audioUrl}
|
||||
title={t.name}
|
||||
subtitle={[t.date, t.place].filter(Boolean).join(" · ")}
|
||||
sourceUrl={`/transcriptions/${t.slug}`}
|
||||
type={t.type}
|
||||
variant="compact"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user