Skip to content
Open
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
19 changes: 19 additions & 0 deletions apps/library/app/actions/compile-mdx.ts
Original file line number Diff line number Diff line change
@@ -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" };
}
}
119 changes: 119 additions & 0 deletions apps/library/app/api/pdf/route.ts
Original file line number Diff line number Diff line change
@@ -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=<id>&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 });
}
}
84 changes: 84 additions & 0 deletions apps/library/app/file/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -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."))
Expand Down Expand Up @@ -145,6 +148,46 @@ export default function FileDetailPage() {
↓ Download
</a>
)}
{pdfKind === "office" && (
<>
<PdfLink
href={`/api/pdf?fileId=${file._id}`}
previewUrl={file.thumbnailUrl}
>
↓ PDF
</PdfLink>
<PdfLink
href={`/api/pdf?fileId=${file._id}&variant=dark`}
previewUrl={file.thumbnailUrlDark ?? file.thumbnailUrl}
>
↓ PDF · dark
</PdfLink>
</>
)}
{pdfKind === "pdf" && file.darkPdfUrl && (
<PdfLink
href={`/api/pdf?fileId=${file._id}&variant=dark`}
previewUrl={file.thumbnailUrlDark ?? file.thumbnailUrl}
>
↓ Dark PDF
</PdfLink>
)}
{pdfKind === "markdown" && (
<>
<PdfLink
href={`/api/pdf?fileId=${file._id}&style=mdx`}
previewUrl={file.thumbnailUrl}
>
↓ PDF · styled
</PdfLink>
<PdfLink
href={`/api/pdf?fileId=${file._id}&style=plain`}
previewUrl={file.plainThumbnailUrl}
>
↓ PDF · plain
</PdfLink>
</>
)}
<Button
variant="outline"
size="sm"
Expand All @@ -157,6 +200,11 @@ export default function FileDetailPage() {
</Button>
</div>

{/* Full-width content preview */}
<div className="mt-7">
<FilePreview file={file} downloadUrl={downloadUrl} />
</div>

{/* Two-column body on wide screens */}
<div className="mt-9 grid grid-cols-1 gap-9 lg:grid-cols-[1.4fr_1fr]">
<div className="space-y-9">
Expand Down Expand Up @@ -272,6 +320,42 @@ function Shell({ children }: { children: React.ReactNode }) {
);
}

/**
* Outline-styled download link to the PDF route, with a hover tooltip showing a
* preview of what will be downloaded (the styled vs plain render).
*/
function PdfLink({
href,
previewUrl,
children,
}: {
href: string;
previewUrl?: string | null;
children: React.ReactNode;
}) {
return (
<span className="group relative inline-flex">
<a
href={href}
className="inline-flex h-[28px] cursor-pointer items-center justify-center gap-1.5 border border-line-soft px-2.5 text-[10px] font-bold uppercase tracking-[0.1em] text-fg transition-colors hover:border-accent hover:text-accent"
style={{ fontFamily: FONT.mono }}
>
{children}
</a>
{previewUrl && (
<span className="pointer-events-none absolute top-full left-0 z-20 mt-2 hidden w-52 border border-line-soft bg-surface p-1 shadow-xl group-hover:block">
{/* biome-ignore lint/performance/noImgElement: signed Storage URL, not a static asset. */}
<img
src={previewUrl}
alt="Download preview"
className="block max-h-64 w-full object-contain object-top"
/>
</span>
)}
</span>
);
}

function Meta({
label,
value,
Expand Down
Loading