From c3e6bc0789e20caae8f30b024bf8529960a18552 Mon Sep 17 00:00:00 2001 From: Ilya Zabolotny Date: Mon, 15 Jun 2026 23:41:48 +0200 Subject: [PATCH 1/2] Add inline previews and PDF export for library files - Render Markdown, Office, PDF, and text previews in the file detail view - Add thumbnail generation, PDF conversion endpoints, and MDX preview plumbing - Update file cards to show generated thumbnails and file-type icons --- apps/library/app/actions/compile-mdx.ts | 19 + apps/library/app/api/pdf/route.ts | 113 +++++ apps/library/app/file/[id]/page.tsx | 84 ++++ apps/library/app/globals.css | 125 ++++++ apps/library/app/internal/md-thumb/page.tsx | 108 +++++ apps/library/app/layout.tsx | 3 + apps/library/components/file-card.tsx | 167 +++++--- apps/library/components/file-preview.tsx | 291 +++++++++++++ apps/library/components/file-type-icon.tsx | 133 ++++++ apps/library/components/markdown-preview.tsx | 119 ++++++ apps/library/components/mdx-view.tsx | 80 ++++ apps/library/lib/mdx-components.tsx | 12 + apps/library/lib/preview.ts | 104 +++++ apps/library/next.config.ts | 8 +- apps/library/package.json | 7 + packages/backend/convex.json | 8 +- packages/backend/convex/_generated/api.d.ts | 89 +++- packages/backend/convex/library/files.ts | 186 ++++++++- .../backend/convex/library/thumbnail_kind.ts | 35 ++ packages/backend/convex/library/thumbnails.ts | 210 ++++++++++ packages/backend/convex/schema.ts | 47 ++- packages/backend/package.json | 1 + pnpm-lock.yaml | 272 ++++++++++++ services/thumbnailer/.dockerignore | 5 + services/thumbnailer/.env.example | 16 + services/thumbnailer/Dockerfile | 22 + services/thumbnailer/README.md | 49 +++ services/thumbnailer/docker-compose.yml | 54 +++ services/thumbnailer/package.json | 14 + services/thumbnailer/server.mjs | 394 ++++++++++++++++++ 30 files changed, 2703 insertions(+), 72 deletions(-) create mode 100644 apps/library/app/actions/compile-mdx.ts create mode 100644 apps/library/app/api/pdf/route.ts create mode 100644 apps/library/app/internal/md-thumb/page.tsx create mode 100644 apps/library/components/file-preview.tsx create mode 100644 apps/library/components/file-type-icon.tsx create mode 100644 apps/library/components/markdown-preview.tsx create mode 100644 apps/library/components/mdx-view.tsx create mode 100644 apps/library/lib/mdx-components.tsx create mode 100644 apps/library/lib/preview.ts create mode 100644 packages/backend/convex/library/thumbnail_kind.ts create mode 100644 packages/backend/convex/library/thumbnails.ts create mode 100644 services/thumbnailer/.dockerignore create mode 100644 services/thumbnailer/.env.example create mode 100644 services/thumbnailer/Dockerfile create mode 100644 services/thumbnailer/README.md create mode 100644 services/thumbnailer/docker-compose.yml create mode 100644 services/thumbnailer/package.json create mode 100644 services/thumbnailer/server.mjs 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..8d48b0a --- /dev/null +++ b/apps/library/app/api/pdf/route.ts @@ -0,0 +1,113 @@ +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 }); + } + + 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 light PDF). + const stored = dark + ? file.darkPdfUrl + : style !== "plain" + ? file.pdfUrl + : null; + if (stored) { + const res = await fetch(stored); + 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", + }), + }); + if (!res.ok) { + return new Response(`Conversion failed (${res.status})`, { status: 502 }); + } + return new Response(res.body, { headers }); +} 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. +