feat: add Word (.docx) export, enhance print density, and fix auth security guards
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
This commit is contained in:
@@ -13,15 +13,23 @@ function signCookie(value: string): string {
|
||||
return `${value}.${sig}`;
|
||||
}
|
||||
|
||||
function safeTimingEqual(a: string, b: string): boolean {
|
||||
try {
|
||||
const bufA = Buffer.from(a);
|
||||
const bufB = Buffer.from(b);
|
||||
if (bufA.length !== bufB.length) return false;
|
||||
return timingSafeEqual(bufA, bufB);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function verifyCookie(cookieValue: string | undefined): boolean {
|
||||
if (!cookieValue) return false;
|
||||
const [value, sig] = cookieValue.split(".");
|
||||
if (!value || !sig) return false;
|
||||
const expected = signCookie(value);
|
||||
return timingSafeEqual(
|
||||
Buffer.from(cookieValue),
|
||||
Buffer.from(expected),
|
||||
);
|
||||
return safeTimingEqual(cookieValue, expected);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
@@ -32,10 +40,7 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const ok = timingSafeEqual(
|
||||
Buffer.from(password),
|
||||
Buffer.from(ADMIN_PASSWORD),
|
||||
);
|
||||
const ok = safeTimingEqual(password, ADMIN_PASSWORD);
|
||||
|
||||
if (!ok) {
|
||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
@@ -6,8 +6,12 @@ import { timingSafeEqual } from "node:crypto";
|
||||
function isAuthorized(req: NextRequest): boolean {
|
||||
if (!WEBHOOK_SECRET) return false;
|
||||
const headerSecret = req.headers.get("x-webhook-secret") ?? "";
|
||||
if (headerSecret && timingSafeEqual(Buffer.from(headerSecret), Buffer.from(WEBHOOK_SECRET))) {
|
||||
return true;
|
||||
if (headerSecret) {
|
||||
const bufA = Buffer.from(headerSecret);
|
||||
const bufB = Buffer.from(WEBHOOK_SECRET);
|
||||
if (bufA.length === bufB.length && timingSafeEqual(bufA, bufB)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const cookie = req.cookies.get("mcpedia_admin")?.value ?? "";
|
||||
return cookie.startsWith("admin.");
|
||||
|
||||
@@ -13,8 +13,12 @@ import { timingSafeEqual } from "node:crypto";
|
||||
function isAuthorized(req: NextRequest): boolean {
|
||||
if (!WEBHOOK_SECRET) return false;
|
||||
const headerSecret = req.headers.get("x-webhook-secret") ?? "";
|
||||
if (headerSecret && timingSafeEqual(Buffer.from(headerSecret), Buffer.from(WEBHOOK_SECRET))) {
|
||||
return true;
|
||||
if (headerSecret) {
|
||||
const bufA = Buffer.from(headerSecret);
|
||||
const bufB = Buffer.from(WEBHOOK_SECRET);
|
||||
if (bufA.length === bufB.length && timingSafeEqual(bufA, bufB)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const cookie = req.cookies.get("mcpedia_admin")?.value ?? "";
|
||||
return cookie.startsWith("admin.");
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getExportDocuments, compileExportMarkdown, type ExportSortOption } from "@mcpedia/core";
|
||||
import {
|
||||
getExportDocuments,
|
||||
compileExportMarkdown,
|
||||
generateExportDocx,
|
||||
type ExportSortOption,
|
||||
} from "@mcpedia/core";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -11,6 +16,8 @@ export async function GET(req: NextRequest) {
|
||||
const sortBy = (searchParams.get("sort") as ExportSortOption) || "category_points";
|
||||
const slugsParam = searchParams.get("slugs");
|
||||
const slugs = slugsParam ? slugsParam.split(",").map((s) => s.trim()) : undefined;
|
||||
const pageBreaksParam = searchParams.get("pageBreaks");
|
||||
const pageBreaks = pageBreaksParam !== "false" && pageBreaksParam !== "0";
|
||||
|
||||
try {
|
||||
const data = await getExportDocuments({
|
||||
@@ -20,13 +27,25 @@ export async function GET(req: NextRequest) {
|
||||
sortBy,
|
||||
});
|
||||
|
||||
const safeFilename = data.summary.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "") || "mcpedia-export";
|
||||
|
||||
if (format === "docx" || format === "word") {
|
||||
const buffer = await generateExportDocx(data, { pageBreaks });
|
||||
const uint8 = new Uint8Array(buffer);
|
||||
return new NextResponse(uint8, {
|
||||
headers: {
|
||||
"Content-Type":
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"Content-Disposition": `attachment; filename="${safeFilename}.docx"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (format === "markdown" || format === "md") {
|
||||
const md = compileExportMarkdown(data);
|
||||
const safeFilename = data.summary.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
|
||||
return new NextResponse(md, {
|
||||
headers: {
|
||||
"Content-Type": "text/markdown; charset=utf-8",
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { restoreRevision, getRevision } from "@mcpedia/core";
|
||||
import { WEBHOOK_SECRET } from "@mcpedia/config";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
function isAuthorized(req: NextRequest): boolean {
|
||||
if (!WEBHOOK_SECRET) return false;
|
||||
const headerSecret = req.headers.get("x-webhook-secret") ?? "";
|
||||
if (headerSecret) {
|
||||
const bufA = Buffer.from(headerSecret);
|
||||
const bufB = Buffer.from(WEBHOOK_SECRET);
|
||||
if (bufA.length === bufB.length && timingSafeEqual(bufA, bufB)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const cookie = req.cookies.get("mcpedia_admin")?.value ?? "";
|
||||
return cookie.startsWith("admin.");
|
||||
}
|
||||
|
||||
// POST /api/revisions/restore — restore a document to a past revision.
|
||||
// Body (form-urlencoded): id=<revision uuid>
|
||||
// After restoring, we rebuild semantic chunks (handled inside restoreRevision)
|
||||
// and revalidate the doc page so the Web UI reflects the restored body.
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!isAuthorized(req)) {
|
||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const form = await req.formData().catch(() => null);
|
||||
const id = form?.get("id");
|
||||
if (typeof id !== "string" || id.length === 0) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Check,
|
||||
Share2,
|
||||
FileDown,
|
||||
FileText,
|
||||
Edit3,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -84,6 +85,16 @@ export default function DocActions({ slug, body, canEdit }: DocActionsProps) {
|
||||
<span>PDF</span>
|
||||
</Link>
|
||||
|
||||
{/* Export Word (.docx) */}
|
||||
<a
|
||||
href={`/api/export?path=${encodeURIComponent(slug)}&format=docx`}
|
||||
title="Download editable Microsoft Word (.docx) file"
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
<span>Word (.docx)</span>
|
||||
</a>
|
||||
|
||||
{/* Edit Link */}
|
||||
<Link
|
||||
href={canEdit ? `/${slug}?edit=1` : `/login?redirect=/${slug}`}
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function Header() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b border-[var(--border-color)] glass-nav transition-all">
|
||||
<header className="site-header no-print sticky top-0 z-40 border-b border-[var(--border-color)] glass-nav transition-all">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 h-14 flex items-center justify-between gap-3 sm:gap-4">
|
||||
{/* Brand */}
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -51,16 +51,16 @@ function PreBlock({ children, ...props }: React.HTMLAttributes<HTMLPreElement>)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="code-block-wrapper relative group my-6 rounded-xl overflow-hidden border border-[var(--border-code)] bg-[var(--code-block-bg)] shadow-sm print:my-3 print:rounded-md print:border-slate-300 print:bg-slate-50">
|
||||
<div className="code-block-wrapper relative group my-6 print:my-2 rounded-xl print:rounded border border-[var(--border-code)] print:border-slate-300 bg-[var(--code-block-bg)] print:bg-slate-50 shadow-sm print:shadow-none">
|
||||
{/* Code block header bar */}
|
||||
<div className="code-block-header flex items-center justify-between px-4 py-2.5 bg-[var(--code-header-bg)] border-b border-[var(--border-code)] text-xs font-mono select-none print:px-3 print:py-1 print:bg-slate-100 print:border-slate-300">
|
||||
<div className="code-block-header flex items-center justify-between px-4 py-2.5 print:px-2.5 print:py-0.5 bg-[var(--code-header-bg)] print:bg-slate-100 border-b border-[var(--border-code)] print:border-slate-300 text-xs font-mono select-none">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="no-print flex items-center gap-1.5 opacity-75 group-hover:opacity-100 transition-opacity">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-rose-500/80 inline-block" />
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-amber-500/80 inline-block" />
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500/80 inline-block" />
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-muted)] print:text-slate-700 group-hover:text-[var(--text-primary)] transition-colors">
|
||||
<span className="text-[11px] print:text-[7pt] font-semibold uppercase tracking-wider text-[var(--text-muted)] print:text-slate-700 group-hover:text-[var(--text-primary)] transition-colors">
|
||||
{language}
|
||||
</span>
|
||||
</div>
|
||||
@@ -85,7 +85,7 @@ function PreBlock({ children, ...props }: React.HTMLAttributes<HTMLPreElement>)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<pre className="!m-0 !bg-transparent p-4 sm:p-5 print:p-3 overflow-x-auto text-[13px] sm:text-[13.5px] print:text-[8.5pt] text-[var(--pre-text)] print:text-slate-900 font-mono leading-relaxed" {...props}>
|
||||
<pre className="!m-0 !bg-transparent p-4 sm:p-5 print:p-2 overflow-x-auto text-[13px] sm:text-[13.5px] print:text-[7.5pt] text-[var(--pre-text)] print:text-slate-900 font-mono leading-relaxed print:leading-snug" {...props}>
|
||||
{children}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -97,15 +97,15 @@ export default function Markdown({ source }: { source: string }) {
|
||||
<div
|
||||
className="prose max-w-none
|
||||
prose-headings:text-[var(--text-primary)] prose-headings:font-bold prose-headings:tracking-tight
|
||||
prose-h1:text-2xl sm:prose-h1:text-3xl prose-h1:mb-6 prose-h1:mt-8 prose-h1:pb-3 prose-h1:border-b prose-h1:border-[var(--border-color)]
|
||||
prose-h2:text-xl sm:prose-h2:text-2xl prose-h2:mb-4 prose-h2:mt-8 prose-h2:pb-2 prose-h2:border-b prose-h2:border-[var(--border-subtle)]
|
||||
prose-h3:text-lg prose-h3:mb-3 prose-h3:mt-6
|
||||
prose-p:text-[var(--text-secondary)] prose-p:leading-relaxed prose-p:mb-4
|
||||
prose-h1:text-2xl sm:prose-h1:text-3xl prose-h1:mb-6 prose-h1:mt-8 prose-h1:pb-3 prose-h1:border-b prose-h1:border-[var(--border-color)] print:prose-h1:text-[13pt] print:prose-h1:mt-2 print:prose-h1:mb-1 print:prose-h1:pb-0.5
|
||||
prose-h2:text-xl sm:prose-h2:text-2xl prose-h2:mb-4 prose-h2:mt-8 prose-h2:pb-2 prose-h2:border-b prose-h2:border-[var(--border-subtle)] print:prose-h2:text-[10.5pt] print:prose-h2:mt-2 print:prose-h2:mb-1 print:prose-h2:pb-0.5
|
||||
prose-h3:text-lg prose-h3:mb-3 prose-h3:mt-6 print:prose-h3:text-[9.5pt] print:prose-h3:mt-1.5 print:prose-h3:mb-0.5
|
||||
prose-p:text-[var(--text-secondary)] prose-p:leading-relaxed prose-p:mb-4 print:prose-p:text-[8.5pt] print:prose-p:leading-snug print:prose-p:mb-1.5
|
||||
prose-a:text-[var(--accent)] prose-a:font-medium prose-a:no-underline hover:prose-a:underline hover:prose-a:text-[var(--accent-hover)]
|
||||
prose-ul:text-[var(--text-secondary)] prose-ul:my-4 prose-li:my-1.5
|
||||
prose-ol:text-[var(--text-secondary)] prose-ol:my-4
|
||||
prose-ul:text-[var(--text-secondary)] prose-ul:my-4 prose-li:my-1.5 print:prose-ul:my-1 print:prose-li:my-0.5
|
||||
prose-ol:text-[var(--text-secondary)] prose-ol:my-4 print:prose-ol:my-1
|
||||
prose-strong:text-[var(--text-primary)] prose-strong:font-semibold
|
||||
prose-hr:border-[var(--border-color)] prose-hr:my-8"
|
||||
prose-hr:border-[var(--border-color)] prose-hr:my-8 print:prose-hr:my-2"
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
@@ -122,7 +122,7 @@ export default function Markdown({ source }: { source: string }) {
|
||||
}
|
||||
return (
|
||||
<code
|
||||
className="px-1.5 py-0.5 rounded-md bg-[var(--code-inline-bg)] text-[var(--code-inline-text)] border border-[var(--code-inline-border)] font-mono text-[0.875em] font-medium"
|
||||
className="px-1.5 py-0.5 rounded-md print:rounded-none bg-[var(--code-inline-bg)] print:bg-slate-100 text-[var(--code-inline-text)] print:text-slate-900 border border-[var(--code-inline-border)] print:border-slate-200 font-mono text-[0.875em] print:text-[7.5pt] font-medium"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -130,7 +130,7 @@ export default function Markdown({ source }: { source: string }) {
|
||||
);
|
||||
},
|
||||
table: ({ node, ...props }) => (
|
||||
<div className="overflow-x-auto my-6 border border-[var(--border-color)] rounded-xl shadow-xs">
|
||||
<div className="overflow-x-auto my-6 print:my-1.5 border border-[var(--border-color)] print:border-slate-300 rounded-xl print:rounded-none shadow-xs print:shadow-none">
|
||||
<table className="w-full border-collapse" {...props} />
|
||||
</div>
|
||||
),
|
||||
@@ -140,35 +140,35 @@ export default function Markdown({ source }: { source: string }) {
|
||||
|
||||
if (alertMatch) {
|
||||
const alertType = alertMatch[1].toUpperCase();
|
||||
let borderClass = "border-indigo-500 bg-indigo-500/10 text-indigo-900 dark:text-indigo-200";
|
||||
let borderClass = "border-indigo-500 bg-indigo-500/10 text-indigo-900 dark:text-indigo-200 print:bg-indigo-50 print:text-indigo-950";
|
||||
let Icon = Info;
|
||||
let title = "Note";
|
||||
|
||||
if (alertType === "TIP") {
|
||||
borderClass = "border-emerald-500 bg-emerald-500/10 text-emerald-900 dark:text-emerald-200";
|
||||
borderClass = "border-emerald-500 bg-emerald-500/10 text-emerald-900 dark:text-emerald-200 print:bg-emerald-50 print:text-emerald-950";
|
||||
Icon = Lightbulb;
|
||||
title = "Tip";
|
||||
} else if (alertType === "IMPORTANT") {
|
||||
borderClass = "border-violet-500 bg-violet-500/10 text-violet-900 dark:text-violet-200";
|
||||
borderClass = "border-violet-500 bg-violet-500/10 text-violet-900 dark:text-violet-200 print:bg-violet-50 print:text-violet-950";
|
||||
Icon = Flame;
|
||||
title = "Important";
|
||||
} else if (alertType === "WARNING") {
|
||||
borderClass = "border-amber-500 bg-amber-500/10 text-amber-900 dark:text-amber-200";
|
||||
borderClass = "border-amber-500 bg-amber-500/10 text-amber-900 dark:text-amber-200 print:bg-amber-50 print:text-amber-950";
|
||||
Icon = AlertTriangle;
|
||||
title = "Warning";
|
||||
} else if (alertType === "CAUTION") {
|
||||
borderClass = "border-rose-500 bg-rose-500/10 text-rose-900 dark:text-rose-200";
|
||||
borderClass = "border-rose-500 bg-rose-500/10 text-rose-900 dark:text-rose-200 print:bg-rose-50 print:text-rose-950";
|
||||
Icon = ShieldAlert;
|
||||
title = "Caution";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`my-5 p-4 rounded-xl border-l-4 ${borderClass} shadow-xs`}>
|
||||
<div className="flex items-center gap-2 font-bold text-xs uppercase tracking-wider mb-1.5">
|
||||
<Icon className="w-4 h-4" />
|
||||
<div className={`my-5 print:my-1.5 p-4 print:p-2 rounded-xl print:rounded border-l-4 ${borderClass} shadow-xs print:shadow-none`}>
|
||||
<div className="flex items-center gap-2 font-bold text-xs print:text-[7.5pt] uppercase tracking-wider mb-1.5 print:mb-0.5">
|
||||
<Icon className="w-4 h-4 print:w-3 print:h-3" />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm text-[var(--text-secondary)] [&>p]:mb-1 [&>p:last-child]:mb-0">
|
||||
<div className="text-xs sm:text-sm print:text-[8pt] text-[var(--text-secondary)] print:text-slate-900 [&>p]:mb-1 [&>p:last-child]:mb-0 print:[&>p]:mb-0.5">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
@@ -176,7 +176,7 @@ export default function Markdown({ source }: { source: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<blockquote className="border-l-4 border-[var(--brand)] bg-[var(--brand)]/5 dark:bg-[var(--brand)]/10 pl-4 py-2.5 my-5 rounded-r-xl text-[var(--text-secondary)] not-italic">
|
||||
<blockquote className="border-l-4 border-[var(--brand)] print:border-slate-500 bg-[var(--brand)]/5 dark:bg-[var(--brand)]/10 print:bg-slate-50 pl-4 print:pl-2.5 py-2.5 print:py-1 my-5 print:my-1.5 rounded-r-xl print:rounded-none text-[var(--text-secondary)] print:text-slate-900 not-italic print:text-[8pt]">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { getSectionMeta } from "@mcpedia/config/sections";
|
||||
import {
|
||||
Printer,
|
||||
FileDown,
|
||||
FileText,
|
||||
Copy,
|
||||
Check,
|
||||
ArrowLeft,
|
||||
@@ -119,6 +120,37 @@ export default function PdfExportView({
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const [downloadingDocx, setDownloadingDocx] = useState(false);
|
||||
|
||||
async function handleDownloadDocx() {
|
||||
setDownloadingDocx(true);
|
||||
try {
|
||||
const slugs = filteredAndSortedDocs.map((d) => d.slug).join(",");
|
||||
const safeName =
|
||||
summary.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "") || "export";
|
||||
const url = `/api/export?format=docx&slugs=${encodeURIComponent(slugs)}&sort=${sortBy}&pageBreaks=${pageBreaks}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error("Failed to generate Word document");
|
||||
const blob = await res.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = blobUrl;
|
||||
a.download = `${safeName}.docx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
} catch (err) {
|
||||
console.error("DOCX download error:", err);
|
||||
alert("Failed to generate .docx file. Please try again.");
|
||||
} finally {
|
||||
setDownloadingDocx(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-16 print:pb-0">
|
||||
{/* Export Control Toolbar (Screen Only — Hidden in Print) */}
|
||||
@@ -183,6 +215,22 @@ export default function PdfExportView({
|
||||
<span>Download .md</span>
|
||||
</button>
|
||||
|
||||
{/* Download Word (.docx) */}
|
||||
<button
|
||||
onClick={handleDownloadDocx}
|
||||
disabled={downloadingDocx}
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer disabled:opacity-50"
|
||||
title="Download editable Microsoft Word (.docx) document"
|
||||
>
|
||||
{downloadingDocx ? (
|
||||
<div className="w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span>Download .docx</span>
|
||||
</button>
|
||||
|
||||
{/* Print / Save as PDF Button */}
|
||||
<button
|
||||
onClick={handlePrint}
|
||||
@@ -280,13 +328,13 @@ export default function PdfExportView({
|
||||
</aside>
|
||||
|
||||
{/* Writeup Document Flow */}
|
||||
<main className="print-container bg-white text-slate-900 max-w-4xl mx-auto px-4 sm:px-8 py-6 print:p-0 print:max-w-none rounded-xl border border-[var(--border-color)] print:border-none shadow-xs print:shadow-none">
|
||||
<main className="print-container bg-white text-slate-900 max-w-4xl mx-auto px-4 sm:px-8 py-6 print:p-0 print:m-0 print:max-w-none rounded-xl border border-[var(--border-color)] print:border-none shadow-xs print:shadow-none">
|
||||
{/* Document Header */}
|
||||
<header className="mb-8 pb-4 border-b border-slate-300">
|
||||
<h1 className="text-3xl sm:text-4xl font-extrabold text-slate-950 tracking-tight mb-2">
|
||||
<header className="doc-header mb-8 pb-4 print:mb-2.5 print:pb-1.5 border-b border-slate-300">
|
||||
<h1 className="text-3xl sm:text-4xl print:text-lg font-extrabold text-slate-950 tracking-tight mb-2 print:mb-0.5">
|
||||
{summary.title}
|
||||
</h1>
|
||||
<div className="text-xs text-slate-500 font-mono flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<div className="text-xs print:text-[7.5pt] text-slate-500 font-mono flex flex-wrap items-center gap-x-2 gap-y-1 print:gap-x-1.5 print:gap-y-0.5">
|
||||
<span>{filteredAndSortedDocs.length} Challenges</span>
|
||||
{currentTotalPoints > 0 && (
|
||||
<span>· {currentTotalPoints.toLocaleString()} Total Points</span>
|
||||
@@ -297,25 +345,25 @@ export default function PdfExportView({
|
||||
|
||||
{/* Table of Contents */}
|
||||
{showTOC && filteredAndSortedDocs.length > 1 && (
|
||||
<section className="toc-section mb-10 pb-6 border-b border-slate-200">
|
||||
<h2 className="text-xs font-bold uppercase font-mono text-slate-700 mb-3 tracking-wider flex items-center gap-1.5">
|
||||
<BookOpen className="w-3.5 h-3.5" />
|
||||
<section className="toc-section mb-10 pb-6 print:mb-2.5 print:pb-1.5 border-b border-slate-200 print:border-slate-300">
|
||||
<h2 className="text-xs print:text-[8pt] font-bold uppercase font-mono text-slate-700 mb-3 print:mb-1 tracking-wider flex items-center gap-1.5">
|
||||
<BookOpen className="w-3.5 h-3.5 print:w-3 print:h-3" />
|
||||
<span>Table of Contents</span>
|
||||
</h2>
|
||||
<ol className="list-decimal list-inside space-y-1.5 text-xs text-slate-800">
|
||||
<ol className="list-decimal list-inside space-y-1.5 print:space-y-0.5 text-xs print:text-[7.5pt] text-slate-800">
|
||||
{filteredAndSortedDocs.map((doc, idx) => {
|
||||
const cat = extractDocCategory(doc);
|
||||
const pts = extractDocPoints(doc);
|
||||
|
||||
return (
|
||||
<li key={doc.slug}>
|
||||
<li key={doc.slug} className="print:leading-tight">
|
||||
<a
|
||||
href={`#ch-${idx + 1}`}
|
||||
className="hover:underline font-medium text-slate-900"
|
||||
>
|
||||
{doc.title}
|
||||
</a>
|
||||
<span className="text-slate-500 font-mono ml-2">
|
||||
<span className="text-slate-500 font-mono ml-2 print:ml-1">
|
||||
[{cat}{pts > 0 ? ` · ${pts} pts` : ""}]
|
||||
</span>
|
||||
</li>
|
||||
@@ -340,14 +388,14 @@ export default function PdfExportView({
|
||||
id={`ch-${chNum}`}
|
||||
className={`challenge-entry ${
|
||||
pageBreaks && idx > 0 ? "page-break-before" : ""
|
||||
} pt-6 first:pt-0`}
|
||||
} pt-6 first:pt-0 print:pt-2.5 print:pb-2 print:border-b print:border-slate-200 print:last:border-none`}
|
||||
>
|
||||
{/* Challenge Title & Metadata */}
|
||||
<div className="mb-4 pb-2 border-b border-slate-200">
|
||||
<h2 className="text-2xl font-bold text-slate-950 tracking-tight leading-tight mb-1.5">
|
||||
<div className="challenge-header mb-4 pb-2 print:mb-1.5 print:pb-1 border-b border-slate-200 print:border-slate-300">
|
||||
<h2 className="text-2xl print:text-[11pt] font-bold text-slate-950 tracking-tight leading-tight mb-1.5 print:mb-0.5">
|
||||
{chNum}. {doc.title}
|
||||
</h2>
|
||||
<div className="text-xs font-mono text-slate-600 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<div className="text-xs print:text-[7.5pt] font-mono text-slate-600 flex flex-wrap items-center gap-x-3 print:gap-x-2 gap-y-1 print:gap-y-0.5">
|
||||
<span><strong>Category:</strong> {cat}</span>
|
||||
{pts > 0 && <span><strong>Points:</strong> {pts} pts</span>}
|
||||
{diff && <span><strong>Difficulty:</strong> {diff}</span>}
|
||||
@@ -387,7 +435,7 @@ export default function PdfExportView({
|
||||
</div>
|
||||
|
||||
{/* Markdown Writeup Content */}
|
||||
<div className="prose prose-slate max-w-none text-slate-900 mb-8">
|
||||
<div className="prose prose-slate max-w-none text-slate-900 mb-8 print:mb-1">
|
||||
<Markdown source={doc.body} />
|
||||
</div>
|
||||
|
||||
|
||||
+175
-24
@@ -437,27 +437,31 @@ body {
|
||||
@media print {
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 18mm 15mm 18mm 15mm;
|
||||
margin: 10mm 12mm 10mm 12mm;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
html, body {
|
||||
background-color: #ffffff !important;
|
||||
color: #09090b !important;
|
||||
color: #0f172a !important;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
|
||||
font-size: 10pt !important;
|
||||
line-height: 1.5 !important;
|
||||
font-size: 8.5pt !important;
|
||||
line-height: 1.35 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
header,
|
||||
aside,
|
||||
/* Hide navigation, toolbars, sidebar, and web UI controls */
|
||||
header.site-header,
|
||||
header.glass-nav,
|
||||
aside.site-sidebar,
|
||||
aside:not(.print-include),
|
||||
footer,
|
||||
nav.site-header,
|
||||
.no-print,
|
||||
@@ -467,7 +471,8 @@ body {
|
||||
}
|
||||
|
||||
main,
|
||||
.print-container {
|
||||
.print-container,
|
||||
main > div {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
margin: 0 !important;
|
||||
@@ -477,53 +482,190 @@ body {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Explicit page break classes */
|
||||
.page-break-before,
|
||||
.chapter-page,
|
||||
.challenge-entry {
|
||||
.chapter-page {
|
||||
break-before: page !important;
|
||||
page-break-before: always !important;
|
||||
}
|
||||
|
||||
/* Prevent awkward mid-element page breaks */
|
||||
.avoid-break-inside,
|
||||
.code-block-wrapper,
|
||||
.code-block-container,
|
||||
.challenge-entry,
|
||||
.callout,
|
||||
blockquote,
|
||||
table,
|
||||
tr,
|
||||
img,
|
||||
pre,
|
||||
blockquote {
|
||||
img {
|
||||
break-inside: avoid !important;
|
||||
page-break-inside: avoid !important;
|
||||
}
|
||||
|
||||
/* Prevent headings from being orphaned at the bottom of pages */
|
||||
.challenge-header,
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
break-after: avoid !important;
|
||||
page-break-after: avoid !important;
|
||||
}
|
||||
|
||||
/* Dense / compact challenge flow */
|
||||
.challenge-entry {
|
||||
break-inside: auto !important;
|
||||
page-break-inside: auto !important;
|
||||
}
|
||||
|
||||
/* Compact typography & prose overrides */
|
||||
p,
|
||||
.prose p {
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 2.5pt !important;
|
||||
font-size: 8.5pt !important;
|
||||
line-height: 1.35 !important;
|
||||
color: #1e293b !important;
|
||||
}
|
||||
|
||||
h1,
|
||||
.prose h1 {
|
||||
font-size: 13pt !important;
|
||||
line-height: 1.2 !important;
|
||||
font-weight: 800 !important;
|
||||
color: #020617 !important;
|
||||
margin-top: 6pt !important;
|
||||
margin-bottom: 2pt !important;
|
||||
padding-bottom: 1pt !important;
|
||||
border-bottom: 1px solid #cbd5e1 !important;
|
||||
}
|
||||
|
||||
h2,
|
||||
.prose h2 {
|
||||
font-size: 10.5pt !important;
|
||||
line-height: 1.2 !important;
|
||||
font-weight: 700 !important;
|
||||
color: #020617 !important;
|
||||
margin-top: 5pt !important;
|
||||
margin-bottom: 2pt !important;
|
||||
padding-bottom: 1pt !important;
|
||||
border-bottom: 1px solid #e2e8f0 !important;
|
||||
}
|
||||
|
||||
h3,
|
||||
.prose h3 {
|
||||
font-size: 9.5pt !important;
|
||||
line-height: 1.2 !important;
|
||||
font-weight: 700 !important;
|
||||
color: #0f172a !important;
|
||||
margin-top: 4pt !important;
|
||||
margin-bottom: 1.5pt !important;
|
||||
}
|
||||
|
||||
h4, h5, h6,
|
||||
.prose h4, .prose h5, .prose h6 {
|
||||
font-size: 8.5pt !important;
|
||||
line-height: 1.2 !important;
|
||||
font-weight: 600 !important;
|
||||
color: #1e293b !important;
|
||||
margin-top: 3pt !important;
|
||||
margin-bottom: 1pt !important;
|
||||
}
|
||||
|
||||
ul, ol,
|
||||
.prose ul, .prose ol {
|
||||
margin-top: 1pt !important;
|
||||
margin-bottom: 2.5pt !important;
|
||||
padding-left: 13pt !important;
|
||||
}
|
||||
|
||||
li,
|
||||
.prose li {
|
||||
margin-top: 0.5pt !important;
|
||||
margin-bottom: 0.5pt !important;
|
||||
font-size: 8.5pt !important;
|
||||
line-height: 1.3 !important;
|
||||
}
|
||||
|
||||
li > p {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
blockquote,
|
||||
.prose blockquote,
|
||||
.callout {
|
||||
margin: 2.5pt 0 !important;
|
||||
padding: 2pt 5pt !important;
|
||||
font-size: 8pt !important;
|
||||
line-height: 1.3 !important;
|
||||
border-left: 2.5px solid #64748b !important;
|
||||
background-color: #f8fafc !important;
|
||||
border-radius: 0 2px 2px 0 !important;
|
||||
}
|
||||
|
||||
table,
|
||||
.prose table {
|
||||
margin: 2.5pt 0 !important;
|
||||
font-size: 7.5pt !important;
|
||||
line-height: 1.25 !important;
|
||||
border-collapse: collapse !important;
|
||||
width: 100% !important;
|
||||
border: 1px solid #cbd5e1 !important;
|
||||
}
|
||||
|
||||
th, td,
|
||||
.prose th, .prose td {
|
||||
padding: 2pt 4pt !important;
|
||||
border: 1px solid #cbd5e1 !important;
|
||||
color: #1e293b !important;
|
||||
}
|
||||
|
||||
th,
|
||||
.prose th {
|
||||
background-color: #f1f5f9 !important;
|
||||
font-weight: 700 !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: 0.04em !important;
|
||||
}
|
||||
|
||||
hr,
|
||||
.prose hr {
|
||||
margin: 3pt 0 !important;
|
||||
border-color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100% !important;
|
||||
max-height: 220px !important;
|
||||
height: auto !important;
|
||||
object-fit: contain !important;
|
||||
margin: 2.5pt auto !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.code-block-wrapper,
|
||||
.code-block-container {
|
||||
break-inside: avoid !important;
|
||||
page-break-inside: avoid !important;
|
||||
border: 1px solid #d4d4d8 !important;
|
||||
background-color: #f4f4f5 !important;
|
||||
border-radius: 4px !important;
|
||||
margin: 8pt 0 !important;
|
||||
border: 1px solid #cbd5e1 !important;
|
||||
background-color: #f8fafc !important;
|
||||
border-radius: 3px !important;
|
||||
margin: 2.5pt 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.code-block-header {
|
||||
border-bottom: 1px solid #d4d4d8 !important;
|
||||
background-color: #e4e4e7 !important;
|
||||
padding: 3pt 8pt !important;
|
||||
color: #27272a !important;
|
||||
border-bottom: 1px solid #cbd5e1 !important;
|
||||
background-color: #f1f5f9 !important;
|
||||
padding: 1.5pt 5pt !important;
|
||||
color: #334155 !important;
|
||||
font-size: 7pt !important;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
|
||||
font-weight: 700 !important;
|
||||
text-transform: uppercase !important;
|
||||
}
|
||||
|
||||
.code-block-wrapper pre {
|
||||
border: none !important;
|
||||
background-color: transparent !important;
|
||||
padding: 6pt 8pt !important;
|
||||
padding: 3pt 5pt !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
@@ -535,7 +677,16 @@ body {
|
||||
word-break: break-all !important;
|
||||
overflow-wrap: anywhere !important;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
|
||||
font-size: 8.5pt !important;
|
||||
line-height: 1.4 !important;
|
||||
font-size: 7.5pt !important;
|
||||
line-height: 1.3 !important;
|
||||
color: #0f172a !important;
|
||||
}
|
||||
|
||||
:not(pre) > code {
|
||||
padding: 0.5pt 2pt !important;
|
||||
font-size: 7.5pt !important;
|
||||
background-color: #f1f5f9 !important;
|
||||
border: 1px solid #e2e8f0 !important;
|
||||
border-radius: 2px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,11 +46,11 @@ export default function RootLayout({
|
||||
|
||||
<div className="mx-auto max-w-7xl w-full flex-1 flex flex-col">
|
||||
<div className="flex flex-1">
|
||||
<aside className="hidden lg:block w-72 shrink-0 border-r border-[var(--border-color)] sticky top-14 h-[calc(100vh-3.5rem)] overflow-y-auto bg-[var(--bg-app)]">
|
||||
<aside className="no-print hidden lg:block w-72 shrink-0 border-r border-[var(--border-color)] sticky top-14 h-[calc(100vh-3.5rem)] overflow-y-auto bg-[var(--bg-app)]">
|
||||
<Sidebar />
|
||||
</aside>
|
||||
<main className="flex-1 min-w-0">
|
||||
<div className="px-4 sm:px-8 py-8 sm:py-10 max-w-4xl mx-auto xl:max-w-5xl">
|
||||
<div className="px-4 sm:px-8 py-8 sm:py-10 print:p-0 print:m-0 print:max-w-none max-w-4xl mx-auto xl:max-w-5xl">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user