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

This commit is contained in:
asepharyana
2026-08-22 17:59:32 +07:00
parent 067115ba13
commit 5ccba958d2
16 changed files with 1374 additions and 85 deletions
+11
View File
@@ -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}`}
+1 -1
View File
@@ -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">
+23 -23
View File
@@ -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>
);
+63 -15
View File
@@ -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>