diff --git a/apps/library/app/actions/compile-mdx.ts b/apps/library/app/actions/compile-mdx.ts new file mode 100644 index 0000000..1040e8c --- /dev/null +++ b/apps/library/app/actions/compile-mdx.ts @@ -0,0 +1,19 @@ +"use server"; + +import { compileMDX } from "@wikipefia/mdx-compiler/compile"; + +/** + * Compile a Markdown/MDX source string to the runnable function-body form used + * by `@mdx-js/mdx`'s `run()`. Runs server-side (the compiler is heavy and + * Node-oriented), mirroring Studio's `compileAction`. + */ +export async function compileMdxAction( + source: string, +): Promise<{ compiled: string } | { error: string }> { + try { + const { compiled } = await compileMDX(source, { filePath: "library.md" }); + return { compiled }; + } catch (err) { + return { error: err instanceof Error ? err.message : "Failed to compile" }; + } +} diff --git a/apps/library/app/api/pdf/route.ts b/apps/library/app/api/pdf/route.ts new file mode 100644 index 0000000..3758a61 --- /dev/null +++ b/apps/library/app/api/pdf/route.ts @@ -0,0 +1,119 @@ +import { api } from "@wikipefia/backend/api"; +import type { Id } from "@wikipefia/backend/dataModel"; +import { ConvexHttpClient } from "convex/browser"; +import type { NextRequest } from "next/server"; + +/** + * Download a library file converted to PDF (via the thumbnailer + Gotenberg). + * + * GET /api/pdf?fileId=&style=mdx|plain + * + * Supported: Office documents (LibreOffice) and Markdown (MDX-styled or plain). + * The render service token is held server-side and never exposed to the browser. + */ + +const OFFICE_MIMES = new Set([ + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", +]); + +function pdfName(originalName: string): string { + const base = originalName.replace(/\.[^./\\]+$/, ""); + return `${base || "document"}.pdf`; +} + +/** + * Build a Content-Disposition value that survives non-ASCII filenames (HTTP + * headers are latin1): an ASCII fallback plus an RFC 5987 `filename*`. + */ +function contentDisposition(name: string): string { + const ascii = name.replace(/[^\x20-\x7e]/g, "_").replace(/"/g, "'"); + return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`; +} + +export async function GET(req: NextRequest) { + const params = req.nextUrl.searchParams; + const fileId = params.get("fileId"); + const style = params.get("style") === "plain" ? "plain" : "mdx"; + const dark = params.get("variant") === "dark"; + if (!fileId) { + return new Response("Missing fileId", { status: 400 }); + } + + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL; + const serviceUrl = process.env.THUMBNAIL_SERVICE_URL; + const token = process.env.THUMBNAIL_SERVICE_TOKEN; + if (!convexUrl || !serviceUrl) { + return new Response("PDF service is not configured", { status: 503 }); + } + + try { + const convex = new ConvexHttpClient(convexUrl); + const id = fileId as Id<"libraryFiles">; + const [file, url] = await Promise.all([ + convex.query(api.library.files.get, { fileId: id }), + convex.query(api.library.files.getDownloadUrl, { fileId: id }), + ]); + if (!file || !url) { + return new Response("File not found", { status: 404 }); + } + + const ct = file.contentType.toLowerCase(); + const isOffice = OFFICE_MIMES.has(ct); + const isMarkdown = ct === "text/markdown"; + const isPdf = ct === "application/pdf"; + // PDF files only support the dark variant (the original is the file itself). + if (!isOffice && !isMarkdown && !(isPdf && dark)) { + return new Response("This file type can't be converted to PDF", { + status: 415, + }); + } + + const base = pdfName(file.originalName).replace(/\.pdf$/, ""); + const headers = { + "content-type": "application/pdf", + "content-disposition": contentDisposition( + `${base}${dark ? "-dark" : ""}.pdf`, + ), + "cache-control": "no-store", + }; + + // Fast path: stream the pre-rendered blob (dark PDF, or styled/Office PDF). + const stored = dark + ? file.darkPdfUrl + : style !== "plain" + ? file.pdfUrl + : null; + if (stored) { + const res = await fetch(stored, { signal: AbortSignal.timeout(30_000) }); + if (res.ok) return new Response(res.body, { headers }); + } + + // Fallback: convert on demand via the render service (Gotenberg). + const res = await fetch(`${serviceUrl.replace(/\/+$/, "")}/pdf`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(token ? { "x-thumbnail-token": token } : {}), + }, + body: JSON.stringify({ + url, + contentType: file.contentType, + style, + theme: dark ? "dark" : "light", + }), + signal: AbortSignal.timeout(240_000), + }); + if (!res.ok) { + return new Response(`Conversion failed (${res.status})`, { status: 502 }); + } + return new Response(res.body, { headers }); + } catch (err) { + console.error("PDF route failed:", err); + return new Response("PDF generation failed", { status: 502 }); + } +} diff --git a/apps/library/app/file/[id]/page.tsx b/apps/library/app/file/[id]/page.tsx index b42c2fd..ed06337 100644 --- a/apps/library/app/file/[id]/page.tsx +++ b/apps/library/app/file/[id]/page.tsx @@ -9,6 +9,7 @@ import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; import { useState } from "react"; import { Comments } from "@/components/comments"; +import { FilePreview } from "@/components/file-preview"; import { Masthead } from "@/components/masthead"; import { MetadataEditor } from "@/components/metadata-editor"; import { RatingStars } from "@/components/rating-stars"; @@ -19,6 +20,7 @@ import { TRANSCRIPTION_COLOR, TRANSCRIPTION_LABEL, } from "@/lib/metadata"; +import { pdfConvertKind } from "@/lib/preview"; import { FONT } from "@/lib/theme"; import type { LibraryFileDetail } from "@/lib/types"; @@ -70,6 +72,7 @@ export default function FileDetailPage() { file.transcriptionStatus === "failed"; const subjectHref = `/subject/${file.subjectId}`; + const pdfKind = pdfConvertKind(file.contentType, file.originalName); async function handleDelete() { if (!confirm("Delete this file permanently? This cannot be undone.")) @@ -145,6 +148,46 @@ export default function FileDetailPage() { ↓ Download )} + {pdfKind === "office" && ( + <> + + ↓ PDF + + + ↓ PDF · dark + + + )} + {pdfKind === "pdf" && file.darkPdfUrl && ( + + ↓ Dark PDF + + )} + {pdfKind === "markdown" && ( + <> + + ↓ PDF · styled + + + ↓ PDF · plain + + + )} + ))} + + ); +} + +/** + * PDF preview with a dark/light toggle. The dark variant is the recolored PDF + * generated by the render service; it defaults to dark when available. + */ +function PdfView({ + file, + lightUrl, + darkUrl, +}: { + file: LibraryFileDetail; + lightUrl: string; + darkUrl: string | null; +}) { + const [variant, setVariant] = useState<"dark" | "light">( + darkUrl ? "dark" : "light", + ); + const active = variant === "dark" && darkUrl ? darkUrl : lightUrl; + return ( +
+ {darkUrl && ( + + )} + + + +
+ ); +} + +/** + * Office preview: the Microsoft Office Online viewer (faithful, no dark mode) + * plus a "PDF" tab showing the converted PDF with a dark/light toggle. + */ +function OfficePreview({ + file, + url, +}: { + file: LibraryFileDetail; + url: string; +}) { + const pdfLightUrl = file.pdfUrl ?? file.darkPdfUrl ?? null; + const hasPdf = pdfLightUrl !== null; + const [tab, setTab] = useState<"office" | "pdf">("office"); + return ( +
+ {hasPdf && ( + + )} + {tab === "pdf" && pdfLightUrl ? ( + + ) : ( + // No `sandbox`: the Office Online viewer submits a form to load its inner + // PowerPoint/Word/Excel frame, which a restrictive sandbox blocks. It's a + // trusted Microsoft service, so we let it run with full permissions. +