Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a00fd1909 | ||
|
|
48566081f0 | ||
|
|
10a15a133c | ||
|
|
5b1345cf9e | ||
|
|
4ca5f0af39 | ||
|
|
283d66b5e0 | ||
|
|
b311c363c5 | ||
|
|
006bf79655 | ||
|
|
1a45ba9fdb | ||
|
|
b2fa2a98e0 | ||
|
|
4d424e67ad | ||
|
|
263a92f368 | ||
|
|
f44e66bcd5 | ||
|
|
66e6fad876 | ||
|
|
befb898c45 | ||
|
|
0638c4a82d | ||
|
|
e412767847 | ||
|
|
ea4f34271c | ||
|
|
caabe05c68 | ||
|
|
9d8d876ba7 | ||
|
|
8f98bed7dd | ||
|
|
bd3a0894b4 | ||
|
|
03616b4e60 | ||
|
|
5ccba958d2 | ||
|
|
067115ba13 | ||
|
|
011571e12d | ||
|
|
9591c67478 | ||
|
|
d65c5613d3 | ||
|
|
4f0d2da876 | ||
|
|
72b53afe06 | ||
|
|
5d85226ff0 |
No files matched your search
@@ -0,0 +1,17 @@
|
||||
name: Dependabot Auto-Merge
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
steps:
|
||||
- name: Enable auto-merge for Dependabot PR
|
||||
run: gh pr merge --auto --merge "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,18 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
production:
|
||||
dependency-type: "production"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
development:
|
||||
dependency-type: "development"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
@@ -10,7 +10,7 @@
|
||||
"start": "bun run src/index.ts",
|
||||
"chat": "bun run src/index.ts",
|
||||
"ask": "bun run src/ask.ts",
|
||||
"lint": "bun run lint",
|
||||
"lint": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "bun build src/index.ts --outdir=dist --format=esm",
|
||||
"test": "bun test"
|
||||
@@ -20,7 +20,7 @@
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/node": "^26",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/node": "^26",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,21 @@ import PdfExportView from "@/components/PdfExportView";
|
||||
import { classifyPath, extractFoldersForSection } from "@mcpedia/core";
|
||||
import { getSectionMeta } from "@mcpedia/config";
|
||||
import type { DocumentMeta } from "@mcpedia/core";
|
||||
import {
|
||||
Folder,
|
||||
FileText,
|
||||
Clock,
|
||||
History,
|
||||
FileDown,
|
||||
ArrowLeft,
|
||||
Sparkles,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Target,
|
||||
Trophy,
|
||||
Zap,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -49,44 +64,41 @@ function CustomFieldBadges({
|
||||
{customEntries.map(([key, value]) => {
|
||||
const label = key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " ");
|
||||
let colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-secondary)]";
|
||||
let icon = "🏷️";
|
||||
let displayValue: string;
|
||||
let IconComponent = Tag;
|
||||
|
||||
if (typeof value === "number") {
|
||||
colorClass = "bg-[var(--brand)]/10 border-[var(--brand)]/30 text-[var(--brand)] dark:text-[var(--accent)]";
|
||||
icon = "🎯";
|
||||
colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-primary)] font-semibold";
|
||||
IconComponent = Target;
|
||||
displayValue = `${value} pts`;
|
||||
} else if (typeof value === "boolean") {
|
||||
colorClass = value
|
||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-rose-500/10 border-rose-500/30 text-rose-600 dark:text-rose-400";
|
||||
icon = value ? "✓" : "✕";
|
||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400 font-semibold"
|
||||
: "bg-rose-500/10 border-rose-500/30 text-rose-600 dark:text-rose-400 font-semibold";
|
||||
IconComponent = value ? CheckCircle2 : XCircle;
|
||||
displayValue = value ? "Solved" : "Pending";
|
||||
} else if (Array.isArray(value)) {
|
||||
colorClass = "bg-[var(--brand)]/10 border-[var(--brand)]/30 text-[var(--brand)] dark:text-[var(--accent)]";
|
||||
icon = "🗂️";
|
||||
colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-secondary)]";
|
||||
IconComponent = Folder;
|
||||
displayValue = value.join(", ");
|
||||
} else {
|
||||
const v = String(value).toLowerCase();
|
||||
if (/^(easy|simple|beginner)/i.test(v)) {
|
||||
colorClass = "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400";
|
||||
icon = "🟢";
|
||||
colorClass = "bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400 font-semibold";
|
||||
displayValue = "Easy";
|
||||
} else if (/^(medium|intermediate)/i.test(v)) {
|
||||
colorClass = "bg-amber-500/10 border-amber-500/30 text-amber-600 dark:text-amber-400";
|
||||
icon = "🟡";
|
||||
colorClass = "bg-amber-500/10 border-amber-500/30 text-amber-600 dark:text-amber-400 font-semibold";
|
||||
displayValue = "Medium";
|
||||
} else if (/^(hard|expert|advanced)/i.test(v)) {
|
||||
colorClass = "bg-rose-500/10 border-rose-500/30 text-rose-600 dark:text-rose-400";
|
||||
icon = "🔴";
|
||||
colorClass = "bg-rose-500/10 border-rose-500/30 text-rose-600 dark:text-rose-400 font-semibold";
|
||||
displayValue = "Hard";
|
||||
} else if (/^(pwn|web|crypto|misc|forensic|reverse|binary)/i.test(v)) {
|
||||
colorClass = "bg-indigo-500/10 border-indigo-500/30 text-indigo-600 dark:text-indigo-300";
|
||||
icon = "⚡";
|
||||
colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-primary)] font-bold";
|
||||
IconComponent = Zap;
|
||||
displayValue = v.toUpperCase();
|
||||
} else if (/ctf|def.?con|event/i.test(v)) {
|
||||
colorClass = "bg-[var(--brand)]/10 border-[var(--brand)]/30 text-[var(--brand)] dark:text-[var(--accent)]";
|
||||
icon = "🏆";
|
||||
colorClass = "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-primary)]";
|
||||
IconComponent = Trophy;
|
||||
displayValue = String(value);
|
||||
} else {
|
||||
displayValue = String(value);
|
||||
@@ -98,9 +110,9 @@ function CustomFieldBadges({
|
||||
key={key}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 border rounded-md text-xs font-mono transition-colors ${colorClass}`}
|
||||
>
|
||||
<span className="text-[10px] opacity-70">{icon}</span>
|
||||
<IconComponent className="w-3.5 h-3.5 opacity-80" />
|
||||
<span className="text-[var(--text-muted)] font-sans">{label}:</span>
|
||||
<span className="font-medium">{displayValue}</span>
|
||||
<span>{displayValue}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
@@ -146,9 +158,9 @@ function FolderIndexPage({
|
||||
const folderName = slug.split("/").pop()!.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-[var(--text-dim)] mb-6 flex-wrap">
|
||||
<div className="space-y-8">
|
||||
{/* Breadcrumb Navigation */}
|
||||
<nav className="flex items-center gap-2 text-xs text-[var(--text-dim)] flex-wrap">
|
||||
<Link href="/" className="hover:text-[var(--text-primary)] transition-colors">
|
||||
MCPedia
|
||||
</Link>
|
||||
@@ -171,58 +183,59 @@ function FolderIndexPage({
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Header card */}
|
||||
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 mb-8 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border-color)] flex items-center justify-center text-xl">
|
||||
📁
|
||||
{/* Header Card */}
|
||||
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 shadow-xs">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3.5">
|
||||
<div className="w-10 h-10 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border-color)] flex items-center justify-center text-[var(--text-primary)]">
|
||||
<Folder className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--text-primary)] tracking-tight">
|
||||
<h1 className="text-2xl font-bold text-[var(--text-primary)] tracking-tight">
|
||||
{folderName}
|
||||
</h1>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-0.5">
|
||||
Directory path: <code className="text-[var(--brand)] dark:text-[var(--accent)] font-mono">{section}/{slug}</code>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-0.5 font-mono">
|
||||
Directory: <code>{section}/{slug}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-[var(--text-muted)] bg-[var(--bg-elevated)] border border-[var(--border-color)] px-2.5 py-1 rounded-md">
|
||||
<span className="text-xs font-mono text-[var(--text-muted)] bg-[var(--bg-elevated)] border border-[var(--border-color)] px-2.5 py-1 rounded">
|
||||
{immediateDocs.length + folders.length} item{folders.length + immediateDocs.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<Link
|
||||
href={`/${section}/${slug}/export`}
|
||||
className="inline-flex items-center gap-1.5 text-xs bg-[var(--brand)] hover:bg-[var(--brand-hover)] text-white px-3 py-1.5 rounded-lg font-medium transition-all shadow-xs"
|
||||
className="inline-flex items-center gap-1.5 text-xs bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] px-3 py-1.5 rounded font-semibold transition-all shadow-xs"
|
||||
title={`Export all ${folderName} documents as a single PDF report`}
|
||||
>
|
||||
<span>📄</span>
|
||||
<span>Export PDF Report</span>
|
||||
<FileDown className="w-3.5 h-3.5" />
|
||||
<span>Export PDF</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subfolders */}
|
||||
{/* Subfolders Grid */}
|
||||
{folders.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xs font-semibold text-[var(--text-muted)] uppercase mb-3 tracking-wider flex items-center gap-2">
|
||||
<span>Subfolders</span>
|
||||
<span className="text-[10px] text-[var(--text-dim)]">({folders.length})</span>
|
||||
<div>
|
||||
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase mb-3 tracking-wider font-mono flex items-center gap-2">
|
||||
<Folder className="w-3.5 h-3.5 text-[var(--text-muted)]" />
|
||||
<span>Subfolders ({folders.length})</span>
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{folders.map(([folder, info]) => (
|
||||
<Link
|
||||
key={folder}
|
||||
href={`/${section}/${slug}/${folder}`.replace(/\/+/g, "/")}
|
||||
className="group flex items-center gap-3 p-3.5 bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-lg transition-all shadow-sm"
|
||||
className="group flex items-center gap-3 p-3.5 bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-lg transition-colors shadow-xs"
|
||||
>
|
||||
<span className="text-2xl group-hover:scale-110 transition-transform">📁</span>
|
||||
<Folder className="w-4 h-4 text-[var(--text-muted)]" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-[var(--text-primary)] group-hover:text-[var(--brand)] dark:group-hover:text-[var(--accent)] transition-colors truncate">
|
||||
<div className="text-xs font-semibold text-[var(--text-primary)] group-hover:underline truncate">
|
||||
{folder.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-0.5 font-mono">
|
||||
<div className="text-[11px] text-[var(--text-muted)] font-mono mt-0.5">
|
||||
{info.docCount} document{info.docCount !== 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
@@ -232,32 +245,32 @@ function FolderIndexPage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documents */}
|
||||
{/* Documents List */}
|
||||
{immediateDocs.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold text-[var(--text-muted)] uppercase mb-3 tracking-wider flex items-center gap-2">
|
||||
<span>Documents</span>
|
||||
<span className="text-[10px] text-[var(--text-dim)]">({immediateDocs.length})</span>
|
||||
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase mb-3 tracking-wider font-mono flex items-center gap-2">
|
||||
<FileText className="w-3.5 h-3.5 text-[var(--text-muted)]" />
|
||||
<span>Documents ({immediateDocs.length})</span>
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{immediateDocs.map((doc) => (
|
||||
<Link
|
||||
key={doc.slug}
|
||||
href={`/${doc.slug}`}
|
||||
className="group flex items-center justify-between gap-4 p-4 bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-lg transition-all shadow-sm"
|
||||
className="group flex items-center justify-between gap-4 p-4 bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-lg transition-colors shadow-xs"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-xl opacity-70 group-hover:opacity-100">📄</span>
|
||||
<FileText className="w-4 h-4 text-[var(--text-muted)] shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-[var(--text-primary)] group-hover:text-[var(--brand)] dark:group-hover:text-[var(--accent)] transition-colors truncate">
|
||||
<div className="text-xs sm:text-sm font-semibold text-[var(--text-primary)] group-hover:underline truncate">
|
||||
{doc.title}
|
||||
</div>
|
||||
{doc.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{doc.tags.slice(0, 4).map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="text-[11px] px-1.5 py-0.25 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)]"
|
||||
className="text-[10px] px-1.5 py-0.2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] font-mono"
|
||||
>
|
||||
#{t}
|
||||
</span>
|
||||
@@ -266,11 +279,15 @@ function FolderIndexPage({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<time className="text-xs text-[var(--text-dim)] shrink-0 font-mono">
|
||||
{new Date(doc.updatedAt).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
|
||||
<time className="text-xs text-[var(--text-dim)] shrink-0 font-mono flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>
|
||||
{new Date(doc.updatedAt).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</time>
|
||||
</Link>
|
||||
))}
|
||||
@@ -279,8 +296,8 @@ function FolderIndexPage({
|
||||
)}
|
||||
|
||||
{folders.length === 0 && immediateDocs.length === 0 && (
|
||||
<div className="text-center py-12 border border-[var(--border-color)] rounded-xl bg-[var(--bg-surface)]">
|
||||
<p className="text-sm text-[var(--text-muted)]">No documents found in this directory.</p>
|
||||
<div className="text-center py-12 border border-[var(--border-color)] rounded-lg bg-[var(--bg-surface)]">
|
||||
<p className="text-sm text-[var(--text-muted)]">No documents in this directory.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -292,7 +309,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||
const searchParamsObj = await searchParams;
|
||||
const { edit, path: queryPath, sort } = searchParamsObj;
|
||||
|
||||
// Handle direct export views: /[section]/export, /[section]/[...folder]/export, or /export/pdf
|
||||
// Handle direct export views
|
||||
if (section === "export" || (slug && slug[slug.length - 1] === "export")) {
|
||||
const isTrailingExport = slug && slug[slug.length - 1] === "export";
|
||||
const targetSlug = isTrailingExport ? slug.slice(0, -1) : slug;
|
||||
@@ -331,14 +348,15 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||
if (edit === "1" && canEdit) {
|
||||
const existingFolders = extractFoldersForSection(docSlugs, doc.section);
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
href={`/${doc.slug}`}
|
||||
className="inline-flex items-center gap-1 text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] mb-4 transition-colors"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
<span>← Back to document</span>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>Back to document</span>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-[var(--text-primary)] mb-6">
|
||||
<h1 className="text-2xl font-bold text-[var(--text-primary)] tracking-tight">
|
||||
Edit: {doc.title}
|
||||
</h1>
|
||||
<DocForm
|
||||
@@ -373,8 +391,8 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||
<div className="flex items-start gap-8">
|
||||
{/* Main Content Column */}
|
||||
<article className="flex-1 min-w-0">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-[var(--text-dim)] mb-4 flex-wrap">
|
||||
{/* Breadcrumbs Navigation */}
|
||||
<nav className="flex items-center gap-2 text-xs text-[var(--text-dim)] mb-4 flex-wrap font-mono">
|
||||
<Link href="/" className="hover:text-[var(--text-primary)] transition-colors">
|
||||
MCPedia
|
||||
</Link>
|
||||
@@ -389,7 +407,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||
<span>/</span>
|
||||
<Link
|
||||
href={`/${crumbPath}`}
|
||||
className={isLast ? "text-[var(--text-primary)] font-semibold" : "hover:text-[var(--text-primary)] transition-colors"}
|
||||
className={isLast ? "text-[var(--text-primary)] font-semibold font-sans" : "hover:text-[var(--text-primary)] transition-colors font-sans"}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
@@ -399,24 +417,26 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||
</nav>
|
||||
|
||||
{/* Hero Metadata Card */}
|
||||
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 mb-8 shadow-sm">
|
||||
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 sm:p-7 mb-8 shadow-xs">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4 mb-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-mono font-medium bg-[var(--brand)]/15 text-[var(--brand)] dark:text-[var(--accent)] border border-[var(--brand)]/30">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap text-xs">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-mono font-bold bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-primary)] uppercase">
|
||||
<span>{sectionInfo.icon}</span>
|
||||
<span className="uppercase">{sectionInfo.label}</span>
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-dim)]">·</span>
|
||||
<span className="text-xs text-[var(--text-muted)]">
|
||||
{doc.type.charAt(0).toUpperCase() + doc.type.slice(1)}
|
||||
<span>{sectionInfo.label}</span>
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-dim)]">·</span>
|
||||
<span className="text-xs text-[var(--text-muted)] font-mono">
|
||||
{readingTime}
|
||||
{doc.type}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-dim)]">·</span>
|
||||
<span className="text-xs text-[var(--text-muted)] font-mono flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{readingTime}</span>
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--text-primary)] tracking-tight leading-tight">
|
||||
|
||||
<h1 className="text-2xl sm:text-3xl font-extrabold text-[var(--text-primary)] tracking-tight leading-tight">
|
||||
{doc.title}
|
||||
</h1>
|
||||
</div>
|
||||
@@ -425,24 +445,27 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||
<DocActions slug={doc.slug} body={doc.body} canEdit={canEdit} />
|
||||
</div>
|
||||
|
||||
{/* Author + Date + Tags */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-[var(--text-muted)] pt-3 border-t border-[var(--border-color)]">
|
||||
{/* Author, Date, Tags */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-[var(--text-muted)] pt-3.5 border-t border-[var(--border-color)]">
|
||||
{doc.author && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-4 rounded-full bg-[var(--brand)] text-white flex items-center justify-center text-[10px] font-bold">
|
||||
<div className="w-4 h-4 rounded bg-[var(--brand)] text-[var(--brand-fg)] flex items-center justify-center text-[10px] font-bold font-mono">
|
||||
{doc.author.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-[var(--text-secondary)]">{doc.author}</span>
|
||||
<span className="text-[var(--text-secondary)] font-medium">{doc.author}</span>
|
||||
<span className="text-[var(--text-dim)]">·</span>
|
||||
</div>
|
||||
)}
|
||||
<span>
|
||||
Updated {new Date(doc.updatedAt).toLocaleDateString(undefined, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
<span className="flex items-center gap-1 font-mono text-[11px]">
|
||||
<span>
|
||||
Updated {new Date(doc.updatedAt).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{doc.tags.length > 0 && (
|
||||
<>
|
||||
<span className="text-[var(--text-dim)]">·</span>
|
||||
@@ -450,7 +473,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||
{doc.tags.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="px-2 py-0.5 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] text-[11px]"
|
||||
className="px-1.5 py-0.2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] text-[10px] font-mono"
|
||||
>
|
||||
#{t}
|
||||
</span>
|
||||
@@ -464,22 +487,25 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||
<CustomFieldBadges doc={{ ...doc }} standardKeys={STANDARD_KEYS} />
|
||||
</div>
|
||||
|
||||
{/* Mobile TOC (rendered inline on smaller screens) */}
|
||||
<div className="xl:hidden mb-6 p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-lg shadow-sm">
|
||||
{/* Mobile TOC */}
|
||||
<div className="xl:hidden mb-6 p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-xs">
|
||||
<TOC source={doc.body} />
|
||||
</div>
|
||||
|
||||
{/* Markdown content */}
|
||||
{/* Markdown Content */}
|
||||
<div className="mb-14">
|
||||
<Markdown source={doc.body} />
|
||||
</div>
|
||||
|
||||
{/* Related Documents */}
|
||||
{/* Related Documents Grid */}
|
||||
{related.length > 0 && (
|
||||
<aside className="mt-12 pt-8 border-t border-[var(--border-color)]">
|
||||
<h2 className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider mb-4 flex items-center gap-2">
|
||||
<span>Related Knowledge</span>
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 mb-3.5">
|
||||
<Sparkles className="w-4 h-4 text-[var(--text-muted)]" />
|
||||
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase tracking-wider font-mono">
|
||||
Related Knowledge
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{related.map((r) => {
|
||||
const rInfo = getSectionInfo(r.section);
|
||||
@@ -487,10 +513,10 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||
<Link
|
||||
key={r.slug}
|
||||
href={`/${r.slug}`}
|
||||
className="group block bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-lg p-4 transition-all shadow-sm"
|
||||
className="group block bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-lg p-4 transition-colors shadow-xs"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<span className="text-xs text-[var(--brand)] dark:text-[var(--accent)] font-mono uppercase">
|
||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||
<span className="text-[10px] text-[var(--text-muted)] font-mono uppercase font-semibold">
|
||||
{rInfo.icon} {r.section}
|
||||
</span>
|
||||
<time className="text-[11px] text-[var(--text-dim)] font-mono">
|
||||
@@ -500,7 +526,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-[var(--text-primary)] group-hover:text-[var(--brand)] dark:group-hover:text-[var(--accent)] transition-colors line-clamp-1 mb-1">
|
||||
<h3 className="text-xs sm:text-sm font-semibold text-[var(--text-primary)] group-hover:underline line-clamp-1">
|
||||
{r.title}
|
||||
</h3>
|
||||
</Link>
|
||||
@@ -513,25 +539,28 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||
{/* Revision History */}
|
||||
{revisions.length > 0 && (
|
||||
<aside className="mt-10 pt-8 border-t border-[var(--border-color)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider">
|
||||
Revision History ({revisions.length})
|
||||
</h2>
|
||||
<div className="flex items-center justify-between mb-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="w-4 h-4 text-[var(--text-muted)]" />
|
||||
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase tracking-wider font-mono">
|
||||
Revision History ({revisions.length})
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{revisions.map((rev) => (
|
||||
<div
|
||||
key={rev.id}
|
||||
className="flex items-center justify-between gap-3 p-3 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-lg text-xs shadow-sm"
|
||||
className="flex items-center justify-between gap-3 p-3 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-lg text-xs shadow-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-mono text-[var(--brand)] dark:text-[var(--accent)] bg-[var(--brand)]/10 px-1.5 py-0.5 rounded border border-[var(--brand)]/30 font-semibold">
|
||||
<div className="flex items-center gap-2 min-w-0 font-mono">
|
||||
<span className="text-[var(--text-primary)] bg-[var(--bg-elevated)] px-1.5 py-0.5 rounded border border-[var(--border-color)] font-bold text-[10px]">
|
||||
v{rev.revisionNo}
|
||||
</span>
|
||||
<span className="text-[var(--text-primary)] truncate">
|
||||
{rev.reason || "Updated document"}
|
||||
<span className="text-[var(--text-secondary)] font-sans truncate text-xs">
|
||||
{rev.reason || "Updated document in PostgreSQL"}
|
||||
</span>
|
||||
<span className="text-[var(--text-dim)] hidden sm:inline">
|
||||
<span className="text-[var(--text-dim)] hidden sm:inline text-[11px]">
|
||||
· {new Date(rev.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -540,7 +569,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||
<input type="hidden" name="id" value={rev.id} />
|
||||
<button
|
||||
type="submit"
|
||||
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] px-2.5 py-1 rounded border border-[var(--border-color)] hover:border-[var(--brand)] transition-colors font-medium shadow-sm"
|
||||
className="text-xs text-[var(--text-primary)] hover:text-black dark:hover:text-white bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] px-2.5 py-1 rounded border border-[var(--border-color)] transition-colors font-medium cursor-pointer"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import Link from "next/link";
|
||||
import { listDocuments } from "@mcpedia/core";
|
||||
import { getSectionMeta } from "@mcpedia/config";
|
||||
import {
|
||||
Folder,
|
||||
FileText,
|
||||
FileDown,
|
||||
Plus,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -81,26 +88,28 @@ function renderTree(nodes: TreeNode[], pathname: string) {
|
||||
<li key={node.slug}>
|
||||
<Link
|
||||
href={`/${node.slug}`}
|
||||
className={`flex items-center justify-between gap-2 text-xs py-1.5 px-2.5 rounded-lg transition-all ${
|
||||
className={`flex items-center justify-between gap-2 text-xs py-1.5 px-2.5 rounded-md transition-colors ${
|
||||
isActive
|
||||
? "text-[var(--brand)] dark:text-[var(--accent)] bg-[var(--brand)]/15 font-semibold border border-[var(--brand)]/30"
|
||||
? "text-[var(--text-primary)] bg-[var(--bg-elevated)] font-semibold border border-[var(--border-color)] shadow-xs"
|
||||
: hasActiveChild
|
||||
? "text-[var(--text-primary)] bg-[var(--bg-elevated)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
<span>{isFolder ? "📁" : "📄"}</span>
|
||||
<span className="truncate">{node.doc?.title || node.name}</span>
|
||||
<span className="text-[var(--text-dim)]">
|
||||
{isFolder ? <Folder className="w-3.5 h-3.5" /> : <FileText className="w-3.5 h-3.5" />}
|
||||
</span>
|
||||
<span className="truncate font-medium">{node.doc?.title || node.name}</span>
|
||||
</span>
|
||||
{isFolder && (
|
||||
<span className="text-[10px] text-[var(--text-dim)] bg-[var(--bg-surface)] border border-[var(--border-color)] px-1.5 py-0.5 rounded font-mono">
|
||||
<span className="text-[10px] text-[var(--text-dim)] bg-[var(--bg-surface)] border border-[var(--border-color)] px-1.5 py-0.2 rounded font-mono">
|
||||
{node.docCount} docs
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
{node.children.length > 0 && (
|
||||
<div className="ml-4 border-l border-[var(--border-color)] pl-2 mt-1">
|
||||
<div className="ml-3.5 border-l border-[var(--border-color)] pl-2.5 mt-0.5 space-y-0.5">
|
||||
{renderTree(node.children, pathname)}
|
||||
</div>
|
||||
)}
|
||||
@@ -124,67 +133,73 @@ export default async function SectionIndexPage({
|
||||
const tree = buildFolderTree(allDocs, section);
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-[var(--text-dim)]">
|
||||
<div className="space-y-8">
|
||||
{/* Breadcrumb Navigation */}
|
||||
<nav className="flex items-center gap-2 text-xs text-[var(--text-dim)] font-mono">
|
||||
<Link href="/" className="hover:text-[var(--text-primary)] transition-colors">
|
||||
MCPedia
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-[var(--text-muted)] font-medium">{sectionInfo.label}</span>
|
||||
<span className="text-[var(--text-muted)] font-semibold font-sans">{sectionInfo.label}</span>
|
||||
</nav>
|
||||
|
||||
{/* Hero Card */}
|
||||
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 shadow-sm">
|
||||
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 shadow-xs">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4 mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-[var(--bg-elevated)] border border-[var(--border-color)] flex items-center justify-center text-2xl shadow-inner">
|
||||
<div className="flex items-center gap-3.5">
|
||||
<div className="w-12 h-12 rounded-xl bg-[var(--bg-elevated)] border border-[var(--border-color)] flex items-center justify-center text-2xl">
|
||||
{sectionInfo.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--text-primary)] tracking-tight">
|
||||
<h1 className="text-2xl sm:text-3xl font-extrabold text-[var(--text-primary)] tracking-tight">
|
||||
{sectionInfo.label}
|
||||
</h1>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-0.5 font-mono uppercase">
|
||||
Section: {section}
|
||||
<p className="text-xs text-[var(--text-muted)] font-mono uppercase tracking-wider mt-0.5">
|
||||
Section: <code>{section}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap sm:flex-nowrap">
|
||||
<span className="text-xs font-mono text-[var(--brand)] dark:text-[var(--accent)] bg-[var(--brand)]/10 border border-[var(--brand)]/25 px-2.5 py-1 rounded-md">
|
||||
<span className="text-xs font-mono text-[var(--text-muted)] bg-[var(--bg-elevated)] border border-[var(--border-color)] px-2.5 py-1 rounded">
|
||||
{sectionDocs.length} document{sectionDocs.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
|
||||
{sectionDocs.length > 0 && (
|
||||
<Link
|
||||
href={`/${section}/export`}
|
||||
className="inline-flex items-center gap-1.5 text-xs bg-[var(--brand)]/10 hover:bg-[var(--brand)]/20 border border-[var(--brand)]/30 hover:border-[var(--brand)]/60 text-[var(--brand)] dark:text-[var(--accent)] px-3 py-1 rounded-md font-medium transition-all shadow-xs"
|
||||
title={`Export all ${sectionInfo.label} documents to PDF`}
|
||||
className="inline-flex items-center gap-1.5 text-xs bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-primary)] px-3 py-1 rounded-md font-semibold transition-all shadow-xs"
|
||||
title={`Export all ${sectionInfo.label} documents to publication-grade PDF`}
|
||||
>
|
||||
<span>📄</span>
|
||||
<FileDown className="w-3.5 h-3.5" />
|
||||
<span>Export PDF</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href="/create"
|
||||
className="inline-flex items-center gap-1 text-xs bg-[var(--brand)] hover:bg-[var(--brand-hover)] text-white px-3 py-1 rounded-md font-medium transition-all"
|
||||
className="inline-flex items-center gap-1.5 text-xs bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] px-3 py-1 rounded-md font-semibold transition-all shadow-xs"
|
||||
>
|
||||
<span>+ Create</span>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Create Doc</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--text-muted)] leading-relaxed max-w-2xl mt-2">
|
||||
<p className="text-xs sm:text-sm text-[var(--text-muted)] leading-relaxed max-w-2xl mt-2">
|
||||
{sectionInfo.desc}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hierarchical Folder Directory */}
|
||||
{tree.length > 0 && (
|
||||
<section className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 shadow-sm">
|
||||
<h2 className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider mb-4 flex items-center gap-2">
|
||||
<span>Folder Structure</span>
|
||||
</h2>
|
||||
<section className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-5 shadow-xs">
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-[var(--border-color)]">
|
||||
<Folder className="w-3.5 h-3.5 text-[var(--text-muted)]" />
|
||||
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase tracking-wider font-mono">
|
||||
Folder & Chapter Structure
|
||||
</h2>
|
||||
</div>
|
||||
{renderTree(tree, section)}
|
||||
</section>
|
||||
)}
|
||||
@@ -192,9 +207,13 @@ export default async function SectionIndexPage({
|
||||
{/* Document List */}
|
||||
{sectionDocs.length > 0 ? (
|
||||
<section>
|
||||
<h2 className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider mb-4">
|
||||
All Documents in {sectionInfo.label}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 mb-3.5">
|
||||
<FileText className="w-3.5 h-3.5 text-[var(--text-muted)]" />
|
||||
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase tracking-wider font-mono">
|
||||
All Documents in {sectionInfo.label}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{[...sectionDocs]
|
||||
.sort(
|
||||
@@ -205,12 +224,12 @@ export default async function SectionIndexPage({
|
||||
<Link
|
||||
key={d.slug}
|
||||
href={`/${d.slug}`}
|
||||
className="group flex items-center justify-between gap-4 p-4 bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-xl transition-all shadow-sm"
|
||||
className="group flex items-center justify-between gap-4 p-4 bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-xl transition-all shadow-xs"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-lg opacity-70 group-hover:opacity-100">📄</span>
|
||||
<FileText className="w-4 h-4 text-[var(--text-muted)] shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium text-[var(--text-primary)] group-hover:text-[var(--brand)] dark:group-hover:text-[var(--accent)] transition-colors truncate">
|
||||
<h3 className="text-xs sm:text-sm font-semibold text-[var(--text-primary)] group-hover:underline truncate">
|
||||
{d.title}
|
||||
</h3>
|
||||
{d.tags.length > 0 && (
|
||||
@@ -218,7 +237,7 @@ export default async function SectionIndexPage({
|
||||
{d.tags.slice(0, 4).map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="text-[10px] px-1.5 py-0.25 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)]"
|
||||
className="text-[10px] px-1.5 py-0.2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] font-mono"
|
||||
>
|
||||
#{t}
|
||||
</span>
|
||||
@@ -227,12 +246,16 @@ export default async function SectionIndexPage({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<time className="text-xs text-[var(--text-dim)] font-mono shrink-0">
|
||||
{new Date(d.updatedAt).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
|
||||
<time className="text-xs text-[var(--text-dim)] font-mono shrink-0 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>
|
||||
{new Date(d.updatedAt).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</time>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
Flag,
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
interface Hit {
|
||||
slug: string;
|
||||
@@ -12,6 +21,15 @@ interface Hit {
|
||||
score: number;
|
||||
}
|
||||
|
||||
interface QuickAction {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: any;
|
||||
href?: string;
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
export default function CommandMenu() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -21,10 +39,48 @@ export default function CommandMenu() {
|
||||
const router = useRouter();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const quickActions: QuickAction[] = [
|
||||
{
|
||||
id: "create-doc",
|
||||
title: "Create New Document",
|
||||
subtitle: "Write technical doc, writeup, or note",
|
||||
icon: Plus,
|
||||
href: "/create",
|
||||
},
|
||||
{
|
||||
id: "hybrid-search",
|
||||
title: "Multi-Modal Hybrid Search",
|
||||
subtitle: "Full-text + Semantic vector search console",
|
||||
icon: Search,
|
||||
href: "/search",
|
||||
},
|
||||
{
|
||||
id: "browse-docs",
|
||||
title: "Documentation Hub",
|
||||
subtitle: "System setup, API references, protocol specs",
|
||||
icon: BookOpen,
|
||||
href: "/docs",
|
||||
},
|
||||
{
|
||||
id: "browse-writeups",
|
||||
title: "CTF & Writeups Directory",
|
||||
subtitle: "Pwn, Reverse, Web, Crypto writeups and solutions",
|
||||
icon: Flag,
|
||||
href: "/writeups",
|
||||
},
|
||||
{
|
||||
id: "browse-research",
|
||||
title: "Research & Benchmarks",
|
||||
subtitle: "Architecture notes, benchmarks, experiments",
|
||||
icon: Sparkles,
|
||||
href: "/research",
|
||||
},
|
||||
];
|
||||
|
||||
// Global shortcut ⌘K or /
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
} else if (e.key === "/" && !["INPUT", "TEXTAREA"].includes((e.target as HTMLElement)?.tagName)) {
|
||||
@@ -56,7 +112,7 @@ export default function CommandMenu() {
|
||||
const timer = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=8`);
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=10`);
|
||||
const data = await res.json();
|
||||
setResults(data.results || []);
|
||||
setSelectedIndex(0);
|
||||
@@ -65,25 +121,31 @@ export default function CommandMenu() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 150);
|
||||
}, 120);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query]);
|
||||
|
||||
function handleNavigate(slug: string) {
|
||||
function handleNavigate(href: string) {
|
||||
setOpen(false);
|
||||
router.push(`/${slug}`);
|
||||
router.push(href);
|
||||
}
|
||||
|
||||
const totalItems = query.trim() ? results.length : quickActions.length;
|
||||
|
||||
function handleKeyNavigation(e: React.KeyboardEvent) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (results.length > 0 ? (prev + 1) % results.length : 0));
|
||||
setSelectedIndex((prev) => (totalItems > 0 ? (prev + 1) % totalItems : 0));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (results.length > 0 ? (prev - 1 + results.length) % results.length : 0));
|
||||
} else if (e.key === "Enter" && results[selectedIndex]) {
|
||||
setSelectedIndex((prev) => (totalItems > 0 ? (prev - 1 + totalItems) % totalItems : 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleNavigate(results[selectedIndex].slug);
|
||||
if (query.trim() && results[selectedIndex]) {
|
||||
handleNavigate(`/${results[selectedIndex].slug}`);
|
||||
} else if (!query.trim() && quickActions[selectedIndex]?.href) {
|
||||
handleNavigate(quickActions[selectedIndex].href!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,13 +153,12 @@ export default function CommandMenu() {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="hidden md:flex items-center gap-2 px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--brand)] text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-lg transition-all shadow-sm"
|
||||
type="button"
|
||||
className="hidden md:flex items-center gap-2.5 px-3 py-1.5 bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-all shadow-xs cursor-pointer group"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 text-[var(--text-dim)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<span>Search docs...</span>
|
||||
<kbd className="ml-2 font-mono bg-[var(--bg-elevated)] px-1.5 py-0.5 rounded border border-[var(--border-color)] text-[10px] text-[var(--text-dim)]">
|
||||
<Search className="w-3.5 h-3.5 text-[var(--text-dim)] group-hover:text-[var(--text-primary)] transition-colors" />
|
||||
<span className="font-sans">Search...</span>
|
||||
<kbd className="ml-2 font-mono bg-[var(--bg-surface)] px-1.5 py-0.2 rounded border border-[var(--border-color)] text-[10px] text-[var(--text-muted)]">
|
||||
⌘K
|
||||
</kbd>
|
||||
</button>
|
||||
@@ -105,87 +166,115 @@ export default function CommandMenu() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-20 px-4">
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-16 sm:pt-24 px-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-sm transition-opacity"
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-xs transition-opacity"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-2xl bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-2xl overflow-hidden animate-fade-in">
|
||||
{/* Search input bar */}
|
||||
<div className="flex items-center gap-3 px-4 py-3.5 border-b border-[var(--border-color)] bg-[var(--bg-elevated)]">
|
||||
<svg className="w-5 h-5 text-[var(--brand)] dark:text-[var(--accent)] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<div className="flex items-center gap-3 px-4 sm:px-5 py-3.5 border-b border-[var(--border-color)] bg-[var(--bg-elevated)]">
|
||||
<Search className="w-4 h-4 text-[var(--text-muted)] shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyNavigation}
|
||||
placeholder="Type to search (titles, keywords, concepts)..."
|
||||
placeholder="Search documents, writeups, concepts, or tools..."
|
||||
className="w-full bg-transparent text-[var(--text-primary)] placeholder-[var(--text-dim)] text-sm focus:outline-none"
|
||||
/>
|
||||
{loading && (
|
||||
<div className="w-4 h-4 border-2 border-[var(--brand)] border-t-transparent rounded-full animate-spin shrink-0" />
|
||||
<div className="w-4 h-4 border-2 border-[var(--text-muted)] border-t-transparent rounded-full animate-spin shrink-0" />
|
||||
)}
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] bg-[var(--bg-surface)] px-1.5 py-0.5 rounded border border-[var(--border-color)]"
|
||||
type="button"
|
||||
className="text-[10px] font-mono text-[var(--text-dim)] hover:text-[var(--text-primary)] bg-[var(--bg-surface)] px-1.5 py-0.5 rounded border border-[var(--border-color)]"
|
||||
>
|
||||
ESC
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results / Suggestions */}
|
||||
<div className="max-h-96 overflow-y-auto p-2">
|
||||
{/* Results / Quick Actions List */}
|
||||
<div className="max-h-[22rem] overflow-y-auto p-2">
|
||||
{query.trim() === "" ? (
|
||||
<div className="px-3 py-6 text-center">
|
||||
<p className="text-xs text-[var(--text-muted)]">Search full-text, semantic embeddings, and CTF writeups.</p>
|
||||
<div className="flex justify-center gap-2 mt-3 text-[11px] text-[var(--text-dim)]">
|
||||
<span>Navigation: <kbd className="bg-[var(--bg-elevated)] px-1 py-0.5 rounded border border-[var(--border-color)]">↑</kbd> <kbd className="bg-[var(--bg-elevated)] px-1 py-0.5 rounded border border-[var(--border-color)]">↓</kbd></span>
|
||||
<span>Select: <kbd className="bg-[var(--bg-elevated)] px-1 py-0.5 rounded border border-[var(--border-color)]">↵</kbd></span>
|
||||
<div className="p-1 space-y-0.5">
|
||||
<div className="px-2.5 py-1 text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)]">
|
||||
Quick Shortcuts
|
||||
</div>
|
||||
{quickActions.map((action, idx) => {
|
||||
const Icon = action.icon;
|
||||
const isSelected = idx === selectedIndex;
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
onClick={() => action.href && handleNavigate(action.href)}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
className={`flex items-center justify-between gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? "bg-[var(--bg-elevated)] text-[var(--text-primary)]"
|
||||
: "hover:bg-[var(--bg-elevated)] text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={`p-1.5 rounded ${isSelected ? "bg-[var(--brand)] text-[var(--brand-fg)]" : "bg-[var(--bg-elevated)] text-[var(--text-muted)]"}`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-[var(--text-primary)]">
|
||||
{action.title}
|
||||
</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)]">
|
||||
{action.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className={`w-3.5 h-3.5 text-[var(--text-dim)] ${isSelected ? "text-[var(--text-primary)]" : ""}`} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : results.length === 0 && !loading ? (
|
||||
<div className="px-4 py-8 text-center text-xs text-[var(--text-muted)]">
|
||||
No documents matching "{query}"
|
||||
<div className="px-4 py-10 text-center text-xs text-[var(--text-muted)] space-y-1">
|
||||
<p>No knowledge base items matched "{query}"</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
<ul className="space-y-1 p-1">
|
||||
{results.map((hit, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
return (
|
||||
<li
|
||||
key={hit.slug}
|
||||
onClick={() => handleNavigate(hit.slug)}
|
||||
onClick={() => handleNavigate(`/${hit.slug}`)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={`flex items-start justify-between gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-all ${
|
||||
className={`flex items-start justify-between gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? "bg-[var(--brand)]/15 border border-[var(--brand)]/40 text-[var(--text-primary)]"
|
||||
? "bg-[var(--bg-elevated)] text-[var(--text-primary)] border border-[var(--border-color)]"
|
||||
: "hover:bg-[var(--bg-elevated)] text-[var(--text-secondary)] border border-transparent"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs px-1.5 py-0.25 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--brand)] dark:text-[var(--accent)] font-mono">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] uppercase font-semibold">
|
||||
{hit.section}
|
||||
</span>
|
||||
<span className={`text-sm font-medium ${isSelected ? "text-[var(--brand)] dark:text-[var(--accent)]" : "text-[var(--text-primary)]"} truncate`}>
|
||||
<span className={`text-xs sm:text-sm font-semibold ${isSelected ? "text-[var(--text-primary)]" : "text-[var(--text-primary)]"} truncate`}>
|
||||
{hit.title}
|
||||
</span>
|
||||
</div>
|
||||
{hit.snippet && (
|
||||
<p
|
||||
className="text-xs text-[var(--text-muted)] mt-1 line-clamp-1 [&>mark]:bg-[var(--brand)]/30 [&>mark]:text-[var(--text-primary)] [&>mark]:rounded [&>mark]:px-0.5"
|
||||
className="text-xs text-[var(--text-muted)] line-clamp-1 mt-1 [&>mark]:bg-[var(--bg-elevated-hover)] [&>mark]:text-[var(--text-primary)] [&>mark]:font-semibold"
|
||||
dangerouslySetInnerHTML={{ __html: hit.snippet }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] text-[var(--text-dim)] shrink-0 self-center">
|
||||
{hit.slug}
|
||||
<span className="text-[10px] font-mono text-[var(--text-dim)] shrink-0 self-center">
|
||||
/{hit.slug}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
@@ -195,14 +284,18 @@ export default function CommandMenu() {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t border-[var(--border-color)] bg-[var(--bg-elevated)] text-[11px] text-[var(--text-dim)]">
|
||||
<span>Tip: Press <kbd className="bg-[var(--bg-surface)] px-1 py-0.5 rounded border border-[var(--border-color)]">Hybrid</kbd> search is enabled</span>
|
||||
<div className="flex items-center justify-between px-4 py-2 border-t border-[var(--border-color)] bg-[var(--bg-elevated)] text-[10px] text-[var(--text-muted)] font-mono">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>[ ↑↓ NAVIGATE ]</span>
|
||||
<span>[ ↵ SELECT ]</span>
|
||||
</div>
|
||||
<Link
|
||||
href={`/search?q=${encodeURIComponent(query)}`}
|
||||
onClick={() => setOpen(false)}
|
||||
className="hover:text-[var(--brand)] dark:hover:text-[var(--accent)] transition-colors"
|
||||
className="hover:text-[var(--text-primary)] font-medium flex items-center gap-1"
|
||||
>
|
||||
Open full search page →
|
||||
<span>Full Search Console</span>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
Share2,
|
||||
FileDown,
|
||||
FileText,
|
||||
Edit3,
|
||||
} from "lucide-react";
|
||||
|
||||
interface DocActionsProps {
|
||||
slug: string;
|
||||
@@ -27,49 +35,41 @@ export default function DocActions({ slug, body, canEdit }: DocActionsProps) {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 shrink-0 flex-wrap">
|
||||
{/* Copy Markdown */}
|
||||
{/* Copy Raw Markdown */}
|
||||
<button
|
||||
onClick={handleCopyMarkdown}
|
||||
type="button"
|
||||
title="Copy raw markdown content"
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-all shadow-sm"
|
||||
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"
|
||||
>
|
||||
{copiedMd ? (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-emerald-500 font-medium">Copied MD</span>
|
||||
<Check className="w-3.5 h-3.5 text-emerald-500" />
|
||||
<span className="text-emerald-500 font-medium">Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2" />
|
||||
</svg>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
<span>Raw MD</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Copy Link */}
|
||||
{/* Copy Share Link */}
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
type="button"
|
||||
title="Copy link to document"
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-all shadow-sm"
|
||||
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"
|
||||
>
|
||||
{copiedLink ? (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-emerald-500 font-medium">Copied Link</span>
|
||||
<Check className="w-3.5 h-3.5 text-emerald-500" />
|
||||
<span className="text-emerald-500 font-medium">Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
<Share2 className="w-3.5 h-3.5" />
|
||||
<span>Share</span>
|
||||
</>
|
||||
)}
|
||||
@@ -78,23 +78,29 @@ export default function DocActions({ slug, body, canEdit }: DocActionsProps) {
|
||||
{/* Export PDF */}
|
||||
<Link
|
||||
href={`/${slug}/export`}
|
||||
title="Export document or parent folder to PDF"
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md transition-all shadow-sm"
|
||||
title="Export document to publication-grade PDF"
|
||||
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"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
|
||||
</svg>
|
||||
<FileDown className="w-3.5 h-3.5" />
|
||||
<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}`}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs bg-[var(--brand)]/10 hover:bg-[var(--brand)]/20 border border-[var(--brand)]/30 hover:border-[var(--brand)]/60 text-[var(--brand)] dark:text-[var(--accent)] rounded-md transition-all font-medium"
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-primary)] rounded-md transition-colors font-medium shadow-xs"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
<span>Edit</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
+144
-109
@@ -3,6 +3,19 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Markdown from "./Markdown";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Code,
|
||||
Link as LinkIcon,
|
||||
Plus,
|
||||
Trash2,
|
||||
Eye,
|
||||
Edit3,
|
||||
Sliders,
|
||||
} from "lucide-react";
|
||||
|
||||
interface DocFormProps {
|
||||
mode: "create" | "edit";
|
||||
@@ -22,8 +35,26 @@ interface DocFormProps {
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_SECTION_LIST = ["docs", "writeups", "research", "notes", "guides", "tutorials", "ctf", "api", "projects"];
|
||||
const DEFAULT_TYPE_LIST = ["documentation", "writeup", "research", "note", "guide", "tutorial", "spec"];
|
||||
const DEFAULT_SECTION_LIST = [
|
||||
"docs",
|
||||
"writeups",
|
||||
"research",
|
||||
"notes",
|
||||
"guides",
|
||||
"tutorials",
|
||||
"ctf",
|
||||
"api",
|
||||
"projects",
|
||||
];
|
||||
const DEFAULT_TYPE_LIST = [
|
||||
"documentation",
|
||||
"writeup",
|
||||
"research",
|
||||
"note",
|
||||
"guide",
|
||||
"tutorial",
|
||||
"spec",
|
||||
];
|
||||
|
||||
const PRESET_FIELDS = [
|
||||
{ key: "difficulty", value: "medium" },
|
||||
@@ -88,7 +119,7 @@ export default function DocForm({
|
||||
const availableFolders = existingFolders?.[section] ?? [];
|
||||
|
||||
const baseInputCls =
|
||||
"w-full px-3.5 py-2 bg-[var(--bg-surface)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-lg text-sm text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--brand)] focus:ring-1 focus:ring-[var(--brand)] transition-colors";
|
||||
"w-full px-3.5 py-2 bg-[var(--bg-surface)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-lg text-sm text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--text-primary)] transition-colors";
|
||||
|
||||
function addCustomField(key = "", value = "") {
|
||||
setCustomFields([...customFields, { key, value }]);
|
||||
@@ -128,7 +159,7 @@ export default function DocForm({
|
||||
setError(null);
|
||||
|
||||
if (!effectiveSlug || !title || !body) {
|
||||
setError("Slug, title, and body are required.");
|
||||
setError("Slug, title, and markdown body are required.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -175,7 +206,7 @@ export default function DocForm({
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error ?? "Save failed");
|
||||
throw new Error(data.error ?? "Failed to save document. Verify ADMIN_PASSWORD or secret.");
|
||||
}
|
||||
|
||||
const result = await res.json();
|
||||
@@ -190,75 +221,73 @@ export default function DocForm({
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 max-w-4xl">
|
||||
{error && (
|
||||
<div className="p-3.5 rounded-lg border border-rose-500/30 bg-rose-500/10 text-rose-500 dark:text-rose-400 text-sm">
|
||||
<div className="p-3.5 rounded-lg border border-rose-500/30 bg-rose-500/10 text-rose-500 dark:text-rose-400 text-xs font-medium animate-fade-in">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
{/* Document Title */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
|
||||
Document Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g. Database Architecture Overview"
|
||||
className={`${baseInputCls} text-base`}
|
||||
placeholder="e.g. Exploiting Stack Alignment Issues in x86_64"
|
||||
className={`${baseInputCls} text-base font-semibold`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Section + Type + Status */}
|
||||
{/* Section, Type, Status */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
|
||||
Section
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
list="section-list"
|
||||
value={section}
|
||||
onChange={(e) => setSection(e.target.value)}
|
||||
placeholder="e.g. docs, writeups, ctf, guides"
|
||||
className={baseInputCls}
|
||||
required
|
||||
/>
|
||||
<datalist id="section-list">
|
||||
{sectionOptions.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
list="section-list"
|
||||
value={section}
|
||||
onChange={(e) => setSection(e.target.value)}
|
||||
placeholder="e.g. docs, writeups, research"
|
||||
className={baseInputCls}
|
||||
required
|
||||
/>
|
||||
<datalist id="section-list">
|
||||
{sectionOptions.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||
Type
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
|
||||
Document Type
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
list="type-list"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
placeholder="e.g. documentation, writeup, note"
|
||||
className={baseInputCls}
|
||||
/>
|
||||
<datalist id="type-list">
|
||||
{DEFAULT_TYPE_LIST.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
list="type-list"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
placeholder="e.g. writeup, documentation, note"
|
||||
className={baseInputCls}
|
||||
/>
|
||||
<datalist id="type-list">
|
||||
{DEFAULT_TYPE_LIST.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
@@ -267,7 +296,7 @@ export default function DocForm({
|
||||
className={baseInputCls}
|
||||
>
|
||||
<option value="published">Published</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="draft">Draft (Unindexed)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -275,8 +304,8 @@ export default function DocForm({
|
||||
{/* Folder + Slug */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||
Parent Folder
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
|
||||
Parent Folder / Chapter
|
||||
</label>
|
||||
<select
|
||||
value={parentFolder}
|
||||
@@ -292,9 +321,10 @@ export default function DocForm({
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||
{isEdit ? "Full Slug" : "Slug (File name) *"}
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
|
||||
{isEdit ? "Document Path" : "Slug (File name) *"}
|
||||
</label>
|
||||
{isEdit ? (
|
||||
<input
|
||||
@@ -309,12 +339,12 @@ export default function DocForm({
|
||||
value={slugInput}
|
||||
onChange={(e) => setSlugInput(e.target.value)}
|
||||
className={`${baseInputCls} font-mono`}
|
||||
placeholder="my-new-post"
|
||||
placeholder="ret2win-guide"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<p className="text-[11px] text-[var(--text-dim)] mt-1 font-mono">
|
||||
Path: <span className="text-[var(--brand)] dark:text-[var(--accent)]">{effectiveSlug || section}</span>
|
||||
<p className="text-[11px] text-[var(--text-dim)] mt-1.5 font-mono">
|
||||
Full Slug: <span className="text-[var(--text-primary)] font-semibold">{effectiveSlug || section}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -322,7 +352,7 @@ export default function DocForm({
|
||||
{/* Tags + Author */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
|
||||
Tags (comma separated)
|
||||
</label>
|
||||
<input
|
||||
@@ -330,11 +360,11 @@ export default function DocForm({
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
className={baseInputCls}
|
||||
placeholder="ctf, pwn, reverse, tutorial"
|
||||
placeholder="pwn, x86_64, rop, ctf"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
|
||||
Author
|
||||
</label>
|
||||
<input
|
||||
@@ -342,31 +372,32 @@ export default function DocForm({
|
||||
value={author}
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
className={baseInputCls}
|
||||
placeholder="asep"
|
||||
placeholder="e.g. asepharyana"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Metadata Fields */}
|
||||
<div className="p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl space-y-3 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl space-y-3.5 shadow-xs">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">
|
||||
Dynamic Custom Metadata
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] font-mono flex items-center gap-1.5">
|
||||
<Sliders className="w-3.5 h-3.5 text-[var(--text-muted)]" />
|
||||
<span>Technical Metadata</span>
|
||||
</h3>
|
||||
<p className="text-[11px] text-[var(--text-dim)] mt-0.5">
|
||||
Add custom key-values (event, difficulty, points, etc.) rendered as styled badges.
|
||||
Custom fields (points, difficulty, event, solved) will render as styled badges in the document hero.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[11px] text-[var(--text-dim)]">Presets:</span>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<span className="text-[10px] font-mono text-[var(--text-dim)] uppercase">Presets:</span>
|
||||
{PRESET_FIELDS.map((preset) => (
|
||||
<button
|
||||
key={preset.key}
|
||||
type="button"
|
||||
onClick={() => addCustomField(preset.key, preset.value)}
|
||||
className="px-2 py-0.5 text-[11px] bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded transition-colors"
|
||||
className="px-2 py-0.5 text-[10px] bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded transition-colors font-mono cursor-pointer"
|
||||
>
|
||||
+{preset.key}
|
||||
</button>
|
||||
@@ -393,10 +424,10 @@ export default function DocForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeCustomField(i)}
|
||||
className="p-2 text-rose-500 hover:text-rose-600 rounded hover:bg-[var(--bg-elevated)] transition-colors"
|
||||
className="p-2 text-rose-500 hover:text-rose-600 rounded hover:bg-[var(--bg-elevated)] transition-colors cursor-pointer"
|
||||
title="Remove field"
|
||||
>
|
||||
✕
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -404,14 +435,15 @@ export default function DocForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addCustomField()}
|
||||
className="text-xs text-[var(--brand)] dark:text-[var(--accent)] hover:underline px-3 py-1.5 rounded-lg border border-[var(--brand)]/30 hover:border-[var(--brand)]/60 bg-[var(--brand)]/5 transition-colors font-medium"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-[var(--text-primary)] hover:underline px-3 py-1 rounded-md border border-[var(--border-color)] bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] transition-colors font-medium cursor-pointer"
|
||||
>
|
||||
+ Add Custom Field
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Add Custom Field</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body with Write / Preview Tabs */}
|
||||
<div className="border border-[var(--border-color)] rounded-xl overflow-hidden bg-[var(--bg-surface)] shadow-sm">
|
||||
{/* Markdown Body Editor */}
|
||||
<div className="border border-[var(--border-color)] rounded-xl overflow-hidden bg-[var(--bg-surface)] shadow-xs">
|
||||
{/* Editor Toolbar */}
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-[var(--bg-elevated)] border-b border-[var(--border-color)] flex-wrap gap-2">
|
||||
{/* Tabs */}
|
||||
@@ -419,90 +451,92 @@ export default function DocForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("write")}
|
||||
className={`px-3 py-1 text-xs rounded-md font-medium transition-all ${
|
||||
className={`flex items-center gap-1.5 px-3 py-1 text-xs rounded font-semibold transition-all cursor-pointer ${
|
||||
activeTab === "write"
|
||||
? "bg-[var(--brand)] text-white shadow-sm"
|
||||
? "bg-[var(--brand)] text-[var(--brand-fg)] shadow-xs"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
Write
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
<span>Write</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("preview")}
|
||||
className={`px-3 py-1 text-xs rounded-md font-medium transition-all ${
|
||||
className={`flex items-center gap-1.5 px-3 py-1 text-xs rounded font-semibold transition-all cursor-pointer ${
|
||||
activeTab === "preview"
|
||||
? "bg-[var(--brand)] text-white shadow-sm"
|
||||
? "bg-[var(--brand)] text-[var(--brand-fg)] shadow-xs"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
Live Preview
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span>Preview</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Markdown Action Shortcuts */}
|
||||
{/* Quick Markdown Formatter Shortcuts */}
|
||||
{activeTab === "write" && (
|
||||
<div className="flex items-center gap-1 text-xs text-[var(--text-muted)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown("**", "**")}
|
||||
className="px-2 py-0.5 hover:bg-[var(--bg-surface)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
|
||||
title="Bold"
|
||||
>
|
||||
<b>B</b>
|
||||
<Bold className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown("*", "*")}
|
||||
className="px-2 py-0.5 hover:bg-[var(--bg-surface)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
|
||||
title="Italic"
|
||||
>
|
||||
<i>I</i>
|
||||
<Italic className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown("## ")}
|
||||
className="px-2 py-0.5 hover:bg-[var(--bg-surface)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
|
||||
title="Heading 2"
|
||||
>
|
||||
H2
|
||||
<Heading2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown("### ")}
|
||||
className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
|
||||
title="Heading 3"
|
||||
>
|
||||
<Heading3 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown("```\n", "\n```")}
|
||||
className="px-2 py-0.5 hover:bg-[var(--bg-surface)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] font-mono"
|
||||
className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
|
||||
title="Code Block"
|
||||
>
|
||||
{"</>"}
|
||||
<Code className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown("[", "](https://)")}
|
||||
className="px-2 py-0.5 hover:bg-[var(--bg-surface)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
className="p-1 hover:bg-[var(--bg-surface)] rounded hover:text-[var(--text-primary)]"
|
||||
title="Link"
|
||||
>
|
||||
Link
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertMarkdown("> [!NOTE]\n> ")}
|
||||
className="px-2 py-0.5 hover:bg-[var(--bg-surface)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
title="Note Callout"
|
||||
>
|
||||
Note
|
||||
<LinkIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{/* Tab Content */}
|
||||
{activeTab === "write" ? (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
className="w-full h-96 p-4 bg-transparent text-[var(--text-primary)] placeholder-[var(--text-dim)] font-mono text-sm focus:outline-none resize-y leading-relaxed"
|
||||
placeholder="# Write your markdown document here..."
|
||||
placeholder="# Write your technical documentation or writeup here in Markdown..."
|
||||
required
|
||||
/>
|
||||
) : (
|
||||
@@ -510,28 +544,29 @@ export default function DocForm({
|
||||
{body.trim() ? (
|
||||
<Markdown source={body} />
|
||||
) : (
|
||||
<p className="text-xs text-[var(--text-dim)] italic">Nothing to preview yet.</p>
|
||||
<p className="text-xs text-[var(--text-dim)] italic font-mono">No content to preview yet.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{/* Submit Action Buttons */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 bg-[var(--brand)] hover:bg-[var(--brand-hover)] text-white rounded-lg font-medium text-sm transition-all shadow-md shadow-[var(--brand)]/20 disabled:opacity-50 flex items-center gap-2"
|
||||
className="px-6 py-2.5 bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] rounded-lg font-semibold text-xs transition-all shadow-xs disabled:opacity-50 flex items-center gap-2 cursor-pointer font-mono uppercase tracking-wider"
|
||||
>
|
||||
{loading && (
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<div className="w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
<span>{loading ? "Saving document..." : mode === "create" ? "Create Document" : "Save Changes"}</span>
|
||||
<span>{loading ? "PERSISTING..." : mode === "create" ? "PUBLISH_DOCUMENT" : "SAVE_CHANGES"}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="px-4 py-2.5 border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-muted)] hover:text-[var(--text-primary)] bg-[var(--bg-surface)] rounded-lg text-sm transition-colors shadow-sm"
|
||||
className="px-4 py-2.5 border border-[var(--border-color)] hover:border-[var(--text-muted)] text-[var(--text-muted)] hover:text-[var(--text-primary)] bg-[var(--bg-surface)] rounded-lg text-xs transition-colors shadow-xs cursor-pointer font-mono"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -6,6 +6,13 @@ import { usePathname } from "next/navigation";
|
||||
import { DEFAULT_SECTIONS, type SectionConfig } from "@mcpedia/config/sections";
|
||||
import ThemeToggle from "@/components/ThemeToggle";
|
||||
import CommandMenu from "@/components/CommandMenu";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Menu,
|
||||
X,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
|
||||
export default function Header() {
|
||||
const pathname = usePathname();
|
||||
@@ -24,24 +31,26 @@ export default function Header() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 border-b border-[var(--border-color)] glass-nav">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 h-14 flex items-center justify-between gap-4">
|
||||
<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">
|
||||
<Link href="/" className="flex items-center gap-2 group">
|
||||
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-[var(--brand)] to-[var(--accent)] flex items-center justify-center text-white font-bold text-xs shadow-md shadow-[var(--brand)]/20 group-hover:scale-105 transition-transform">
|
||||
<Link href="/" className="flex items-center gap-2.5 group">
|
||||
<div className="flex items-center justify-center w-7 h-7 rounded-md bg-[var(--brand)] text-[var(--brand-fg)] font-mono font-bold text-xs shadow-xs group-hover:opacity-90 transition-opacity">
|
||||
M
|
||||
</div>
|
||||
<span className="font-semibold text-[var(--text-primary)] text-base tracking-tight transition-colors">
|
||||
MCPedia
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-[var(--text-primary)] text-sm tracking-tight leading-none">
|
||||
MCPedia
|
||||
</span>
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] hidden sm:inline-block">
|
||||
CORE::DB
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
<span className="hidden sm:inline-block text-[10px] uppercase font-mono px-1.5 py-0.5 rounded bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)]">
|
||||
v0.1.0
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Desktop Nav */}
|
||||
{/* Center: Desktop Sections Nav */}
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
{sections.map((s) => {
|
||||
const isActive = pathname === `/${s.id}` || pathname.startsWith(`/${s.id}/`);
|
||||
@@ -49,101 +58,110 @@ export default function Header() {
|
||||
<Link
|
||||
key={s.id}
|
||||
href={`/${s.id}`}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors flex items-center gap-1.5 ${
|
||||
isActive
|
||||
? "text-[var(--text-primary)] bg-[var(--bg-elevated)] border border-[var(--border-color)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-surface-hover)]"
|
||||
? "text-[var(--text-primary)] bg-[var(--bg-elevated)] border border-[var(--border-color)] font-semibold"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
|
||||
}`}
|
||||
>
|
||||
<span className="mr-1.5 opacity-80">{s.icon}</span>
|
||||
{s.label}
|
||||
<span>{s.icon}</span>
|
||||
<span>{s.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Right tools */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
{/* Right Tools & Navigation Deck */}
|
||||
<div className="flex items-center gap-2">
|
||||
<CommandMenu />
|
||||
|
||||
<Link
|
||||
href="/search"
|
||||
className="md:hidden p-1.5 text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-lg hover:bg-[var(--bg-elevated)] transition-colors"
|
||||
title="Search"
|
||||
className="md:hidden p-1.5 text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md bg-[var(--bg-surface)] border border-[var(--border-color)] transition-colors"
|
||||
title="Search knowledge base"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<Search className="w-4 h-4" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/create"
|
||||
className="hidden sm:inline-flex items-center gap-1.5 px-3 py-1 bg-[var(--brand)] hover:bg-[var(--accent)] text-white text-xs font-medium rounded-lg transition-all shadow-sm"
|
||||
className="hidden sm:inline-flex items-center gap-1.5 px-3 py-1.5 bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] text-xs font-semibold rounded-md shadow-xs transition-all cursor-pointer"
|
||||
>
|
||||
<span>+</span>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>New Doc</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/login"
|
||||
className={`px-2.5 py-1 text-xs rounded-lg transition-colors ${
|
||||
className={`p-1.5 sm:px-2.5 sm:py-1.5 text-xs rounded-md transition-colors flex items-center gap-1.5 ${
|
||||
pathname === "/login"
|
||||
? "text-[var(--text-primary)] bg-[var(--bg-elevated)] font-medium"
|
||||
? "text-[var(--text-primary)] bg-[var(--bg-elevated)] font-semibold border border-[var(--border-color)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
|
||||
}`}
|
||||
title="Admin Login"
|
||||
>
|
||||
Login
|
||||
<Lock className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Admin</span>
|
||||
</Link>
|
||||
|
||||
<div className="border-l border-[var(--border-color)] pl-2.5 flex items-center">
|
||||
<div className="border-l border-[var(--border-color)] pl-2 flex items-center">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* Mobile menu toggle */}
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="md:hidden p-1 text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
className="md:hidden p-1.5 text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-md hover:bg-[var(--bg-elevated)] transition-colors"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
{mobileMenuOpen ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
)}
|
||||
</svg>
|
||||
{mobileMenuOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Drawer */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden border-t border-[var(--border-color)] bg-[var(--bg-surface)] px-4 py-3 space-y-1 animate-fade-in shadow-lg">
|
||||
{sections.map((s) => (
|
||||
<Link
|
||||
key={s.id}
|
||||
href={`/${s.id}`}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<span>{s.icon}</span>
|
||||
<span>{s.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
<div className="pt-2 border-t border-[var(--border-color)] flex gap-2">
|
||||
<div className="md:hidden border-t border-[var(--border-color)] bg-[var(--bg-surface)] px-4 py-4 space-y-2 animate-fade-in shadow-lg">
|
||||
<div className="text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)] px-1 mb-1">
|
||||
Knowledge Sections
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{sections.map((s) => {
|
||||
const isActive = pathname === `/${s.id}` || pathname.startsWith(`/${s.id}/`);
|
||||
return (
|
||||
<Link
|
||||
key={s.id}
|
||||
href={`/${s.id}`}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-md text-xs font-medium transition-colors ${
|
||||
isActive
|
||||
? "bg-[var(--bg-elevated)] text-[var(--text-primary)] border border-[var(--border-color)] font-semibold"
|
||||
: "text-[var(--text-secondary)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
<span>{s.icon}</span>
|
||||
<span className="truncate">{s.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-[var(--border-color)] flex gap-2">
|
||||
<Link
|
||||
href="/search"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex-1 text-center py-2 text-xs bg-[var(--bg-elevated)] text-[var(--text-secondary)] rounded-lg font-medium border border-[var(--border-color)]"
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs bg-[var(--bg-elevated)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-md font-medium border border-[var(--border-color)]"
|
||||
>
|
||||
Search
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
<span>Search</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/create"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex-1 text-center py-2 text-xs bg-[var(--brand)] text-white rounded-lg font-medium"
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs bg-[var(--brand)] text-[var(--brand-fg)] rounded-md font-semibold shadow-xs"
|
||||
>
|
||||
+ Create
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Create Doc</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Cpu,
|
||||
Database,
|
||||
Layers,
|
||||
Sparkles,
|
||||
Search,
|
||||
Globe,
|
||||
Server,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
|
||||
interface NodeDetail {
|
||||
id: string;
|
||||
title: string;
|
||||
badge: string;
|
||||
type: "client" | "protocol" | "core" | "storage";
|
||||
icon: any;
|
||||
desc: string;
|
||||
metrics: { label: string; value: string }[];
|
||||
features: string[];
|
||||
docLink?: string;
|
||||
}
|
||||
|
||||
const NODES: NodeDetail[] = [
|
||||
{
|
||||
id: "ai-agents",
|
||||
title: "AI Coding Agents",
|
||||
badge: "MCP::CLIENT",
|
||||
type: "client",
|
||||
icon: Sparkles,
|
||||
desc: "Claude Desktop, Cursor, Antigravity, and Zed connect via Model Context Protocol to read and write technical knowledge directly.",
|
||||
metrics: [
|
||||
{ label: "Transport", value: "Stdio / HTTP SSE" },
|
||||
{ label: "Tools Exposed", value: "13 Native Tools" },
|
||||
{ label: "Authentication", value: "x-webhook-secret" },
|
||||
],
|
||||
features: [
|
||||
"Semantic similarity search over chunks",
|
||||
"Direct document CRUD into Postgres",
|
||||
"Context retrieval for debugging & CTF writeups",
|
||||
],
|
||||
docLink: "/docs",
|
||||
},
|
||||
{
|
||||
id: "web-ui",
|
||||
title: "Human Web Interface",
|
||||
badge: "UI::NEXTJS",
|
||||
type: "client",
|
||||
icon: Globe,
|
||||
desc: "Fast, accessible developer web interface with dark/light themes, keyboard shortcuts, and instant search.",
|
||||
metrics: [
|
||||
{ label: "Framework", value: "Next.js 16.3" },
|
||||
{ label: "Styling", value: "Tailwind CSS v4" },
|
||||
{ label: "Command Menu", value: "⌘K Omnisearch" },
|
||||
],
|
||||
features: [
|
||||
"Hierarchical folder directory tree",
|
||||
"Publication-grade PDF export generator",
|
||||
"Live markdown editor with revision rollback",
|
||||
],
|
||||
docLink: "/docs",
|
||||
},
|
||||
{
|
||||
id: "mcp-server",
|
||||
title: "MCP Server Gateway",
|
||||
badge: "PORT::4021",
|
||||
type: "protocol",
|
||||
icon: Server,
|
||||
desc: "Streamable Model Context Protocol server exposing tool endpoints and resource URIs.",
|
||||
metrics: [
|
||||
{ label: "Port", value: ":4021" },
|
||||
{ label: "Protocol", value: "MCP 2024-11-05" },
|
||||
{ label: "Runtime", value: "Bun Engine" },
|
||||
],
|
||||
features: [
|
||||
"get_document / create_document tools",
|
||||
"search_hybrid / search_semantic tools",
|
||||
"list_sections & list_revisions resources",
|
||||
],
|
||||
docLink: "/api",
|
||||
},
|
||||
{
|
||||
id: "core-engine",
|
||||
title: "@mcpedia/core",
|
||||
badge: "CORE::PACKAGE",
|
||||
type: "core",
|
||||
icon: Layers,
|
||||
desc: "Unified business logic layer shared across Web, MCP Server, REST API, and Background Workers.",
|
||||
metrics: [
|
||||
{ label: "Architecture", value: "Monorepo Package" },
|
||||
{ label: "Validation", value: "Strict Type Safety" },
|
||||
{ label: "Ranking", value: "RRF Hybrid Fusion" },
|
||||
],
|
||||
features: [
|
||||
"Path classification & folder hierarchy",
|
||||
"Markdown document chunking & tokenization",
|
||||
"Drizzle ORM transactional schema mapping",
|
||||
],
|
||||
docLink: "/research",
|
||||
},
|
||||
{
|
||||
id: "search-engine",
|
||||
title: "Multi-Modal Search Engine",
|
||||
badge: "SEARCH::RRF",
|
||||
type: "core",
|
||||
icon: Search,
|
||||
desc: "Reciprocal Rank Fusion uniting Postgres Full-Text Search tsvectors with vector embeddings.",
|
||||
metrics: [
|
||||
{ label: "FTS Algorithm", value: "tsvector + GIN" },
|
||||
{ label: "Vector Search", value: "Cosine Similarity" },
|
||||
{ label: "Fusion Constant", value: "k = 60" },
|
||||
],
|
||||
features: [
|
||||
"Sub-millisecond keyword retrieval",
|
||||
"Conceptual similarity matching",
|
||||
"Instant snippet generation with highlight markers",
|
||||
],
|
||||
docLink: "/search",
|
||||
},
|
||||
{
|
||||
id: "postgres-db",
|
||||
title: "PostgreSQL 16 + pgvector",
|
||||
badge: "DB::POSTGRES",
|
||||
type: "storage",
|
||||
icon: Database,
|
||||
desc: "ACID-compliant relational database storing document bodies, revision trees, vector embeddings, and full-text indexes.",
|
||||
metrics: [
|
||||
{ label: "Extension", value: "vector (1536 dim)" },
|
||||
{ label: "Backups", value: "Auto .md to disk" },
|
||||
{ label: "Isolation", value: "Transactional" },
|
||||
],
|
||||
features: [
|
||||
"HNSW vector index for embeddings",
|
||||
"Complete revision history preservation",
|
||||
"Instant document schema migrations",
|
||||
],
|
||||
docLink: "/docs",
|
||||
},
|
||||
];
|
||||
|
||||
export default function InteractiveTopology() {
|
||||
const [activeNodeId, setActiveNodeId] = useState<string>("core-engine");
|
||||
|
||||
const activeNode = NODES.find((n) => n.id === activeNodeId) || NODES[0];
|
||||
const ActiveIcon = activeNode.icon;
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl overflow-hidden shadow-xs">
|
||||
{/* Header Bar */}
|
||||
<div className="p-4 sm:p-5 border-b border-[var(--border-color)] bg-[var(--bg-elevated)] flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
<span className="text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)]">
|
||||
Architecture Schematic
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-base sm:text-lg font-bold text-[var(--text-primary)] tracking-tight">
|
||||
System Topology: Single Core Architecture
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-mono text-[var(--text-muted)] bg-[var(--bg-surface)] border border-[var(--border-color)] px-2 py-1 rounded">
|
||||
Click nodes to inspect
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid Layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12">
|
||||
{/* Visual Map Column (Left) */}
|
||||
<div className="lg:col-span-7 p-5 border-b lg:border-b-0 lg:border-r border-[var(--border-color)] bg-[var(--bg-app)] flex flex-col justify-between space-y-5">
|
||||
{/* Layer 1: Clients */}
|
||||
<div>
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest text-[var(--text-muted)] mb-2 block">
|
||||
1. Ingestion & Access Layer
|
||||
</span>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||
{NODES.filter((n) => n.type === "client").map((node) => {
|
||||
const Icon = node.icon;
|
||||
const isSelected = activeNodeId === node.id;
|
||||
return (
|
||||
<button
|
||||
key={node.id}
|
||||
onClick={() => setActiveNodeId(node.id)}
|
||||
type="button"
|
||||
className={`p-3 rounded-lg border text-left transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? "bg-[var(--bg-surface)] border-[var(--text-primary)] shadow-xs ring-1 ring-[var(--text-primary)]"
|
||||
: "bg-[var(--bg-surface)] border-[var(--border-color)] hover:border-[var(--text-muted)]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className={`p-1.5 rounded ${isSelected ? "bg-[var(--brand)] text-[var(--brand-fg)]" : "bg-[var(--bg-elevated)] text-[var(--text-muted)]"}`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-[var(--bg-elevated)] text-[var(--text-muted)] border border-[var(--border-color)]">
|
||||
{node.badge}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-semibold text-xs text-[var(--text-primary)]">{node.title}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flow Connector Arrow */}
|
||||
<div className="flex justify-center text-[var(--text-dim)]">
|
||||
<span className="text-[10px] font-mono px-2.5 py-0.5 rounded bg-[var(--bg-surface)] border border-[var(--border-color)]">
|
||||
↓ Protocol Gateway
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Layer 2: Protocol & Core Services */}
|
||||
<div>
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest text-[var(--text-muted)] mb-2 block">
|
||||
2. Core Engine & Protocol Layer
|
||||
</span>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5">
|
||||
{NODES.filter((n) => n.type === "protocol" || n.type === "core").map((node) => {
|
||||
const Icon = node.icon;
|
||||
const isSelected = activeNodeId === node.id;
|
||||
return (
|
||||
<button
|
||||
key={node.id}
|
||||
onClick={() => setActiveNodeId(node.id)}
|
||||
type="button"
|
||||
className={`p-3 rounded-lg border text-left transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? "bg-[var(--bg-surface)] border-[var(--text-primary)] shadow-xs ring-1 ring-[var(--text-primary)]"
|
||||
: "bg-[var(--bg-surface)] border-[var(--border-color)] hover:border-[var(--text-muted)]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className={`p-1.5 rounded ${isSelected ? "bg-[var(--brand)] text-[var(--brand-fg)]" : "bg-[var(--bg-elevated)] text-[var(--text-muted)]"}`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-semibold text-xs text-[var(--text-primary)] leading-tight mb-0.5 truncate">
|
||||
{node.title}
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--text-muted)] font-mono truncate">
|
||||
{node.badge}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flow Connector Arrow */}
|
||||
<div className="flex justify-center text-[var(--text-dim)]">
|
||||
<span className="text-[10px] font-mono px-2.5 py-0.5 rounded bg-[var(--bg-surface)] border border-[var(--border-color)]">
|
||||
↓ Persistent Storage Layer
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Layer 3: Storage */}
|
||||
<div>
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest text-[var(--text-muted)] mb-2 block">
|
||||
3. Database (Source of Truth)
|
||||
</span>
|
||||
{NODES.filter((n) => n.type === "storage").map((node) => {
|
||||
const Icon = node.icon;
|
||||
const isSelected = activeNodeId === node.id;
|
||||
return (
|
||||
<button
|
||||
key={node.id}
|
||||
onClick={() => setActiveNodeId(node.id)}
|
||||
type="button"
|
||||
className={`w-full p-3 rounded-lg border text-left transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? "bg-[var(--bg-surface)] border-[var(--text-primary)] shadow-xs ring-1 ring-[var(--text-primary)]"
|
||||
: "bg-[var(--bg-surface)] border-[var(--border-color)] hover:border-[var(--text-muted)]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-1.5 rounded ${isSelected ? "bg-[var(--brand)] text-[var(--brand-fg)]" : "bg-[var(--bg-elevated)] text-[var(--text-muted)]"}`}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-xs text-[var(--text-primary)]">{node.title}</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)]">Source of truth for all content & embeddings</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-[var(--bg-elevated)] text-[var(--text-muted)] border border-[var(--border-color)]">
|
||||
{node.badge}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node Inspector Details Column (Right) */}
|
||||
<div className="lg:col-span-5 p-5 bg-[var(--bg-surface)] flex flex-col justify-between space-y-5 animate-fade-in">
|
||||
<div>
|
||||
{/* Inspector Header */}
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-9 h-9 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-primary)] flex items-center justify-center">
|
||||
<ActiveIcon className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-[var(--text-primary)]">{activeNode.title}</h4>
|
||||
<span className="text-[10px] font-mono text-[var(--text-muted)]">
|
||||
{activeNode.badge}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-relaxed mb-5">
|
||||
{activeNode.desc}
|
||||
</p>
|
||||
|
||||
{/* Telemetry Metrics */}
|
||||
<div className="mb-5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)] font-mono mb-2 block">
|
||||
Specifications
|
||||
</span>
|
||||
<div className="space-y-1.5">
|
||||
{activeNode.metrics.map((m) => (
|
||||
<div
|
||||
key={m.label}
|
||||
className="flex items-center justify-between p-2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-xs"
|
||||
>
|
||||
<span className="text-[var(--text-muted)] text-[11px]">{m.label}</span>
|
||||
<span className="font-mono font-medium text-[var(--text-primary)] text-[11px]">{m.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Capabilities */}
|
||||
<div>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)] font-mono mb-2 block">
|
||||
Capabilities
|
||||
</span>
|
||||
<ul className="space-y-1.5">
|
||||
{activeNode.features.map((feat, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2 text-xs text-[var(--text-secondary)]">
|
||||
<span className="text-emerald-500 shrink-0 font-mono text-[11px]">✓</span>
|
||||
<span>{feat}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link to Documentation */}
|
||||
{activeNode.docLink && (
|
||||
<div className="pt-3 border-t border-[var(--border-color)]">
|
||||
<Link
|
||||
href={activeNode.docLink}
|
||||
className="inline-flex items-center justify-center gap-1.5 w-full py-2 bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-xs font-semibold text-[var(--text-primary)] rounded-lg transition-colors group"
|
||||
>
|
||||
<span>Read documentation</span>
|
||||
<ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-0.5 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,15 @@ import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeSlug from "rehype-slug";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
Info,
|
||||
Lightbulb,
|
||||
AlertTriangle,
|
||||
Flame,
|
||||
ShieldAlert,
|
||||
} from "lucide-react";
|
||||
|
||||
function extractText(node: React.ReactNode): string {
|
||||
if (node === null || node === undefined || typeof node === "boolean") {
|
||||
@@ -42,16 +51,16 @@ function PreBlock({ children, ...props }: React.HTMLAttributes<HTMLPreElement>)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative group my-5 rounded-xl overflow-hidden border border-[var(--border-code)] bg-[var(--code-block-bg)] shadow-xs">
|
||||
<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="flex items-center justify-between px-4 py-2 bg-[var(--code-header-bg)] border-b border-[var(--border-code)] text-xs font-mono select-none">
|
||||
<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="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-400/80 dark:bg-rose-500/60 inline-block" />
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-amber-400/80 dark:bg-amber-500/60 inline-block" />
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-emerald-400/80 dark:bg-emerald-500/60 inline-block" />
|
||||
<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-medium uppercase tracking-wider text-[var(--text-muted)] group-hover:text-[var(--text-secondary)] 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>
|
||||
@@ -60,27 +69,23 @@ function PreBlock({ children, ...props }: React.HTMLAttributes<HTMLPreElement>)
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
aria-label="Copy code to clipboard"
|
||||
className="flex items-center gap-1.5 text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] bg-[var(--code-button-bg)] hover:bg-[var(--code-button-hover-bg)] px-2.5 py-1 rounded-md border border-[var(--border-code)] transition-all font-sans shadow-xs cursor-pointer"
|
||||
className="no-print flex items-center gap-1.5 text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] bg-[var(--code-button-bg)] hover:bg-[var(--code-button-hover-bg)] px-2.5 py-1 rounded-md border border-[var(--border-code)] transition-all font-sans shadow-xs cursor-pointer"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<Check className="w-3.5 h-3.5 text-emerald-500" />
|
||||
<span className="text-emerald-500 font-medium">Copied!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
<span>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<pre className="!m-0 !bg-transparent p-4 overflow-x-auto text-[13.5px] text-[var(--pre-text)] 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>
|
||||
@@ -91,16 +96,16 @@ export default function Markdown({ source }: { source: string }) {
|
||||
return (
|
||||
<div
|
||||
className="prose max-w-none
|
||||
prose-headings:text-[var(--text-primary)] prose-headings:font-semibold 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-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)] 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]}
|
||||
@@ -117,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}
|
||||
@@ -125,13 +130,53 @@ 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>
|
||||
),
|
||||
blockquote: ({ children }) => {
|
||||
const rawText = extractText(children).trim();
|
||||
const alertMatch = rawText.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/i);
|
||||
|
||||
if (alertMatch) {
|
||||
const alertType = alertMatch[1].toUpperCase();
|
||||
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 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 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 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 print:bg-rose-50 print:text-rose-950";
|
||||
Icon = ShieldAlert;
|
||||
title = "Caution";
|
||||
}
|
||||
|
||||
return (
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
|
||||
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-lg 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>
|
||||
);
|
||||
|
||||
@@ -2,11 +2,20 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Markdown from "@/components/Markdown";
|
||||
import {
|
||||
Sparkles,
|
||||
Terminal,
|
||||
Server,
|
||||
Copy,
|
||||
Check,
|
||||
Laptop,
|
||||
} from "lucide-react";
|
||||
|
||||
const CONFIGS = [
|
||||
{
|
||||
id: "claude",
|
||||
label: "Claude Desktop",
|
||||
icon: Sparkles,
|
||||
lang: "json",
|
||||
filename: "~/Library/Application Support/Claude/claude_desktop_config.json",
|
||||
content: `{
|
||||
@@ -15,7 +24,7 @@ const CONFIGS = [
|
||||
"command": "bun",
|
||||
"args": ["run", "/path/to/mcpedia/apps/mcp/src/index.ts"],
|
||||
"env": {
|
||||
"DATABASE_URL": "postgresql://...",
|
||||
"DATABASE_URL": "postgresql://postgres:password@localhost:5432/mcpedia",
|
||||
"OPENROUTER_API_KEY": "sk-or-v1-..."
|
||||
}
|
||||
}
|
||||
@@ -23,10 +32,29 @@ const CONFIGS = [
|
||||
}`,
|
||||
},
|
||||
{
|
||||
id: "http",
|
||||
label: "Remote HTTP (SSE)",
|
||||
id: "cursor",
|
||||
label: "Cursor AI",
|
||||
icon: Laptop,
|
||||
lang: "json",
|
||||
filename: "Streamable HTTP Endpoint",
|
||||
filename: ".cursor/mcp.json",
|
||||
content: `{
|
||||
"mcpServers": {
|
||||
"mcpedia": {
|
||||
"command": "bun",
|
||||
"args": ["run", "/path/to/mcpedia/apps/mcp/src/index.ts"],
|
||||
"env": {
|
||||
"DATABASE_URL": "postgresql://postgres:password@localhost:5432/mcpedia"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
id: "http",
|
||||
label: "Streamable HTTP (SSE)",
|
||||
icon: Server,
|
||||
lang: "json",
|
||||
filename: "Streamable HTTP Client Config",
|
||||
content: `{
|
||||
"mcpServers": {
|
||||
"mcpedia-remote": {
|
||||
@@ -40,19 +68,21 @@ const CONFIGS = [
|
||||
},
|
||||
{
|
||||
id: "cli",
|
||||
label: "CLI Client",
|
||||
label: "CLI & Scripts",
|
||||
icon: Terminal,
|
||||
lang: "bash",
|
||||
filename: "Terminal One-Shot / REPL",
|
||||
content: `# One-shot question
|
||||
bun run apps/mcp-client/src/ask.ts "How does ret2win stack alignment work?"
|
||||
filename: "Terminal Subprocess / REPL",
|
||||
content: `# Semantic question answering via MCP client
|
||||
bun run apps/mcp-client/src/ask.ts "How does ret2win stack alignment work in x86_64?"
|
||||
|
||||
# Interactive REPL
|
||||
# Interactive REPL with tool discovery
|
||||
bun run apps/mcp-client/src/index.ts`,
|
||||
},
|
||||
];
|
||||
|
||||
export default function McpConfigSnippet() {
|
||||
const [activeTab, setActiveTab] = useState("claude");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const current = CONFIGS.find((c) => c.id === activeTab) || CONFIGS[0];
|
||||
|
||||
@@ -60,45 +90,79 @@ export default function McpConfigSnippet() {
|
||||
${current.content}
|
||||
\`\`\``;
|
||||
|
||||
function handleCopyAll() {
|
||||
navigator.clipboard.writeText(current.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl overflow-hidden shadow-xs">
|
||||
{/* Header bar with tabs */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--bg-elevated)] border-b border-[var(--border-color)] flex-wrap gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{CONFIGS.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setActiveTab(c.id)}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg font-medium transition-all cursor-pointer ${
|
||||
activeTab === c.id
|
||||
? "bg-[var(--brand)] text-white shadow-xs"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-surface)]"
|
||||
}`}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{CONFIGS.map((c) => {
|
||||
const Icon = c.icon;
|
||||
const isSelected = activeTab === c.id;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setActiveTab(c.id)}
|
||||
type="button"
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md font-medium transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? "bg-[var(--brand)] text-[var(--brand-fg)] font-semibold shadow-xs"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-surface)]"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<span>{c.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-[var(--text-dim)] font-mono hidden sm:block">
|
||||
{current.filename}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopyAll}
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-[var(--text-primary)] rounded-md transition-colors shadow-xs cursor-pointer font-medium"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3.5 h-3.5 text-emerald-500" />
|
||||
<span className="text-emerald-500">Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
<span>Copy Config</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Code preview via Markdown syntax highlighter */}
|
||||
<div className="p-4 [&>.prose>.group]:my-0 [&>.prose]:max-w-none">
|
||||
{/* Code preview */}
|
||||
<div className="p-4 sm:p-5 [&>.prose>.group]:my-0 [&>.prose]:max-w-none">
|
||||
<Markdown source={markdownSnippet} />
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2 bg-[var(--bg-elevated)] border-t border-[var(--border-color)] text-[11px] text-[var(--text-dim)] flex items-center justify-between flex-wrap gap-2">
|
||||
<span>
|
||||
Config target: <span className="font-mono text-[var(--text-secondary)]">{current.filename}</span>
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--text-muted)]">
|
||||
Format: <span className="uppercase font-mono font-semibold">{current.lang}</span>
|
||||
</span>
|
||||
{/* Footer Info */}
|
||||
<div className="px-4 py-2.5 bg-[var(--bg-elevated)] border-t border-[var(--border-color)] text-xs text-[var(--text-dim)] flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono uppercase text-[10px] text-[var(--text-muted)]">
|
||||
Target File:
|
||||
</span>
|
||||
<span className="font-mono text-[var(--text-secondary)] text-[11px]">
|
||||
{current.filename}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-[11px] font-mono">
|
||||
<span className="flex items-center gap-1 text-[var(--text-muted)]">
|
||||
<span>[ 13_TOOLS_ACTIVE ]</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,15 +7,22 @@ import {
|
||||
extractDocCategory,
|
||||
extractDocPoints,
|
||||
extractDocDifficulty,
|
||||
extractDocSolved,
|
||||
sortExportDocuments,
|
||||
compileExportMarkdown,
|
||||
type Document,
|
||||
type ExportData,
|
||||
type ExportSortOption,
|
||||
type ExportCategorySummary,
|
||||
} from "@mcpedia/core/export-helpers";
|
||||
import { getSectionMeta } from "@mcpedia/config/sections";
|
||||
import {
|
||||
Printer,
|
||||
FileDown,
|
||||
FileText,
|
||||
Copy,
|
||||
Check,
|
||||
ArrowLeft,
|
||||
BookOpen,
|
||||
} from "lucide-react";
|
||||
|
||||
interface PdfExportViewProps {
|
||||
exportData: ExportData;
|
||||
@@ -28,11 +35,23 @@ export default function PdfExportView({
|
||||
}: PdfExportViewProps) {
|
||||
const { summary, documents: initialDocs } = exportData;
|
||||
|
||||
// Filter out any leftover index / meta files
|
||||
const cleanDocs = useMemo(() => {
|
||||
return initialDocs.filter((d) => {
|
||||
const slug = d.slug.toLowerCase();
|
||||
return (
|
||||
!slug.endsWith("/_index") &&
|
||||
!slug.endsWith("/index") &&
|
||||
slug !== "_index" &&
|
||||
slug !== "index" &&
|
||||
d.type !== "writeup-index"
|
||||
);
|
||||
});
|
||||
}, [initialDocs]);
|
||||
|
||||
// Configuration and interactive state
|
||||
const [sortBy, setSortBy] = useState<ExportSortOption>("category_points");
|
||||
const [showCover, setShowCover] = useState(true);
|
||||
const [showScoreboard, setShowScoreboard] = useState(initialDocs.length > 1);
|
||||
const [showTOC, setShowTOC] = useState(initialDocs.length > 1);
|
||||
const [showTOC, setShowTOC] = useState(cleanDocs.length > 1);
|
||||
const [pageBreaks, setPageBreaks] = useState(true);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
||||
const [copiedMd, setCopiedMd] = useState(false);
|
||||
@@ -42,22 +61,22 @@ export default function PdfExportView({
|
||||
// Available categories for filtering
|
||||
const allCategories = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const doc of initialDocs) {
|
||||
for (const doc of cleanDocs) {
|
||||
set.add(extractDocCategory(doc));
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
}, [initialDocs]);
|
||||
}, [cleanDocs]);
|
||||
|
||||
// Filtered & Sorted documents
|
||||
const filteredAndSortedDocs = useMemo(() => {
|
||||
let docs = [...initialDocs];
|
||||
let docs = [...cleanDocs];
|
||||
if (selectedCategory !== "all") {
|
||||
docs = docs.filter(
|
||||
(d) => extractDocCategory(d).toLowerCase() === selectedCategory.toLowerCase(),
|
||||
);
|
||||
}
|
||||
return sortExportDocuments(docs, sortBy);
|
||||
}, [initialDocs, selectedCategory, sortBy]);
|
||||
}, [cleanDocs, selectedCategory, sortBy]);
|
||||
|
||||
// Calculate dynamic stats for currently filtered view
|
||||
const currentTotalPoints = useMemo(() => {
|
||||
@@ -101,76 +120,55 @@ export default function PdfExportView({
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function getCategoryBadgeClass(category: string): string {
|
||||
const cat = category.toLowerCase();
|
||||
if (cat.includes("web")) {
|
||||
return "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/30";
|
||||
}
|
||||
if (cat.includes("crypto")) {
|
||||
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/30";
|
||||
}
|
||||
if (cat.includes("pwn") || cat.includes("binary")) {
|
||||
return "bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-500/30";
|
||||
}
|
||||
if (cat.includes("reverse") || cat.includes("rev")) {
|
||||
return "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/30";
|
||||
}
|
||||
if (cat.includes("forensic") || cat.includes("dfir")) {
|
||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30";
|
||||
}
|
||||
if (cat.includes("osint")) {
|
||||
return "bg-teal-500/10 text-teal-600 dark:text-teal-400 border-teal-500/30";
|
||||
}
|
||||
return "bg-[var(--brand)]/10 text-[var(--brand)] dark:text-[var(--accent)] border-[var(--brand)]/30";
|
||||
}
|
||||
const [downloadingDocx, setDownloadingDocx] = useState(false);
|
||||
|
||||
function getCategoryIcon(category: string): string {
|
||||
const cat = category.toLowerCase();
|
||||
if (cat.includes("web")) return "🌐";
|
||||
if (cat.includes("crypto")) return "🔐";
|
||||
if (cat.includes("pwn") || cat.includes("binary")) return "⚡";
|
||||
if (cat.includes("reverse") || cat.includes("rev")) return "⚙️";
|
||||
if (cat.includes("forensic") || cat.includes("dfir")) return "🔍";
|
||||
if (cat.includes("osint")) return "🛰️";
|
||||
if (cat.includes("mobile")) return "📱";
|
||||
if (cat.includes("hardware")) return "🔌";
|
||||
return "🚩";
|
||||
}
|
||||
|
||||
function getDifficultyBadgeClass(difficulty: string): string {
|
||||
const diff = difficulty.toLowerCase();
|
||||
if (/easy|simple|beginner/i.test(diff)) {
|
||||
return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/30";
|
||||
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);
|
||||
}
|
||||
if (/medium|intermediate/i.test(diff)) {
|
||||
return "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30";
|
||||
}
|
||||
if (/hard|expert|advanced|insane/i.test(diff)) {
|
||||
return "bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-500/30";
|
||||
}
|
||||
return "bg-[var(--bg-elevated)] text-[var(--text-muted)] border-[var(--border-color)]";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-16 print:pb-0">
|
||||
{/* =========================================================================
|
||||
Export Control Toolbar (Hidden in Print)
|
||||
========================================================================= */}
|
||||
<aside className="no-print export-toolbar sticky top-16 z-30 mb-8 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-2xl p-4 sm:p-5 shadow-md">
|
||||
{/* Export Control Toolbar (Screen Only — Hidden in Print) */}
|
||||
<aside className="no-print export-toolbar sticky top-16 z-30 mb-8 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-4 sm:p-5 shadow-xs">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
||||
{/* Left info & Back button */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href={backHref}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] rounded-lg text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-all shadow-xs"
|
||||
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)] rounded-md text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors shadow-xs"
|
||||
>
|
||||
<span>←</span>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>Back</span>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-[var(--brand)] dark:text-[var(--accent)] font-semibold uppercase">
|
||||
{sectionMeta.icon} {sectionMeta.label}
|
||||
<span className="text-xs font-mono text-[var(--text-primary)] font-bold uppercase">
|
||||
{sectionMeta.label}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-dim)]">·</span>
|
||||
<span className="text-xs text-[var(--text-muted)] font-mono">
|
||||
@@ -190,17 +188,18 @@ export default function PdfExportView({
|
||||
<button
|
||||
onClick={handleCopyMarkdown}
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-xs font-medium bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-lg transition-all shadow-xs cursor-pointer"
|
||||
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"
|
||||
title="Copy compiled markdown document"
|
||||
>
|
||||
{copiedMd ? (
|
||||
<>
|
||||
<span className="text-emerald-500 font-bold">✓ Copied</span>
|
||||
<Check className="w-3.5 h-3.5 text-emerald-500" />
|
||||
<span className="text-emerald-500 font-bold">Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>📋</span>
|
||||
<span>Copy Markdown</span>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
<span>Copy MD</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -209,50 +208,53 @@ export default function PdfExportView({
|
||||
<button
|
||||
onClick={handleDownloadMarkdown}
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-xs font-medium bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-lg transition-all shadow-xs cursor-pointer"
|
||||
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"
|
||||
title="Download compiled .md file"
|
||||
>
|
||||
<span>💾</span>
|
||||
<FileDown className="w-3.5 h-3.5" />
|
||||
<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}
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-xs font-semibold bg-[var(--brand)] hover:bg-[var(--brand-hover)] text-white rounded-lg shadow-sm transition-all cursor-pointer hover:shadow-md active:scale-98"
|
||||
className="inline-flex items-center gap-2 px-3.5 py-1.5 text-xs font-bold bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] rounded-md shadow-xs transition-all cursor-pointer"
|
||||
title="Open browser print dialog to save as PDF"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Print / Save as PDF</span>
|
||||
<Printer className="w-4 h-4" />
|
||||
<span>Print / Save PDF</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options & Filters Bar */}
|
||||
<div className="mt-4 pt-3.5 border-t border-[var(--border-color)] flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs">
|
||||
<div className="mt-3.5 pt-3 border-t border-[var(--border-color)] flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs">
|
||||
{/* Sort Order Selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[var(--text-muted)] font-medium">Sort by:</span>
|
||||
<span className="text-[var(--text-muted)] font-mono text-[10px] uppercase">Sort:</span>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as ExportSortOption)}
|
||||
aria-label="Sort documents"
|
||||
className="bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-primary)] rounded-md px-2.5 py-1 text-xs focus:outline-none focus:border-[var(--brand)] cursor-pointer"
|
||||
className="bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded px-2.5 py-1 text-xs text-[var(--text-primary)] focus:outline-none"
|
||||
>
|
||||
<option value="category_points">Category & Points (CTF Standard)</option>
|
||||
<option value="category_points">Category & Points (Standard)</option>
|
||||
<option value="points_desc">Points (Highest First)</option>
|
||||
<option value="difficulty">Difficulty (Easy to Hard)</option>
|
||||
<option value="title">Challenge Title (A-Z)</option>
|
||||
@@ -261,84 +263,63 @@ export default function PdfExportView({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Toggle Switches */}
|
||||
<div className="flex items-center gap-4 flex-wrap text-[var(--text-muted)]">
|
||||
<label className="inline-flex items-center gap-1.5 cursor-pointer hover:text-[var(--text-primary)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showCover}
|
||||
onChange={(e) => setShowCover(e.target.checked)}
|
||||
className="rounded border-[var(--border-color)] text-[var(--brand)] focus:ring-[var(--brand)]"
|
||||
/>
|
||||
<span>Cover Page</span>
|
||||
</label>
|
||||
|
||||
{initialDocs.length > 1 && (
|
||||
<>
|
||||
<label className="inline-flex items-center gap-1.5 cursor-pointer hover:text-[var(--text-primary)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showScoreboard}
|
||||
onChange={(e) => setShowScoreboard(e.target.checked)}
|
||||
className="rounded border-[var(--border-color)] text-[var(--brand)] focus:ring-[var(--brand)]"
|
||||
/>
|
||||
<span>Scoreboard Table</span>
|
||||
</label>
|
||||
|
||||
<label className="inline-flex items-center gap-1.5 cursor-pointer hover:text-[var(--text-primary)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showTOC}
|
||||
onChange={(e) => setShowTOC(e.target.checked)}
|
||||
className="rounded border-[var(--border-color)] text-[var(--brand)] focus:ring-[var(--brand)]"
|
||||
/>
|
||||
<span>Table of Contents</span>
|
||||
</label>
|
||||
</>
|
||||
{/* Section toggles */}
|
||||
<div className="flex items-center gap-3 flex-wrap text-[var(--text-secondary)]">
|
||||
{cleanDocs.length > 1 && (
|
||||
<label className="inline-flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showTOC}
|
||||
onChange={(e) => setShowTOC(e.target.checked)}
|
||||
className="rounded border-[var(--border-color)]"
|
||||
/>
|
||||
<span>Table of Contents</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="inline-flex items-center gap-1.5 cursor-pointer hover:text-[var(--text-primary)]">
|
||||
<label className="inline-flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pageBreaks}
|
||||
onChange={(e) => setPageBreaks(e.target.checked)}
|
||||
className="rounded border-[var(--border-color)] text-[var(--brand)] focus:ring-[var(--brand)]"
|
||||
className="rounded border-[var(--border-color)]"
|
||||
/>
|
||||
<span>Page Break per Ch</span>
|
||||
<span>Page Break per Challenge</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filter Chips */}
|
||||
{/* Category filter pills */}
|
||||
{allCategories.length > 1 && (
|
||||
<div className="mt-3 pt-3 border-t border-[var(--border-color)] flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[11px] text-[var(--text-muted)] font-medium mr-1">Category:</span>
|
||||
<div className="mt-3 pt-3 border-t border-[var(--border-color)] flex items-center gap-1.5 flex-wrap text-xs">
|
||||
<span className="text-[var(--text-muted)] font-mono text-[10px] uppercase mr-1">Filter:</span>
|
||||
<button
|
||||
onClick={() => setSelectedCategory("all")}
|
||||
className={`px-2 py-0.5 rounded text-xs font-mono transition-colors ${
|
||||
type="button"
|
||||
className={`px-2.5 py-0.5 rounded text-xs font-mono transition-all cursor-pointer ${
|
||||
selectedCategory === "all"
|
||||
? "bg-[var(--brand)] text-white font-medium"
|
||||
? "bg-[var(--brand)] text-[var(--brand-fg)] font-bold"
|
||||
: "bg-[var(--bg-elevated)] text-[var(--text-muted)] hover:text-[var(--text-primary)] border border-[var(--border-color)]"
|
||||
}`}
|
||||
>
|
||||
All ({initialDocs.length})
|
||||
All ({cleanDocs.length})
|
||||
</button>
|
||||
{allCategories.map((cat: string) => {
|
||||
const count = initialDocs.filter((d: Document) => extractDocCategory(d) === cat).length;
|
||||
const isActive = selectedCategory.toLowerCase() === cat.toLowerCase();
|
||||
{allCategories.map((cat) => {
|
||||
const count = cleanDocs.filter(
|
||||
(d) => extractDocCategory(d).toLowerCase() === cat.toLowerCase(),
|
||||
).length;
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setSelectedCategory(isActive ? "all" : cat)}
|
||||
className={`px-2 py-0.5 rounded text-xs font-mono transition-colors flex items-center gap-1 ${
|
||||
isActive
|
||||
? "bg-[var(--brand)] text-white font-medium"
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
type="button"
|
||||
className={`px-2.5 py-0.5 rounded text-xs font-mono transition-all cursor-pointer ${
|
||||
selectedCategory.toLowerCase() === cat.toLowerCase()
|
||||
? "bg-[var(--brand)] text-[var(--brand-fg)] font-bold"
|
||||
: "bg-[var(--bg-elevated)] text-[var(--text-muted)] hover:text-[var(--text-primary)] border border-[var(--border-color)]"
|
||||
}`}
|
||||
>
|
||||
<span>{getCategoryIcon(cat)}</span>
|
||||
<span>{cat}</span>
|
||||
<span className="text-[10px] opacity-75 font-sans">({count})</span>
|
||||
{cat} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -346,374 +327,80 @@ export default function PdfExportView({
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* =========================================================================
|
||||
Main Printable Document
|
||||
========================================================================= */}
|
||||
<div className="print-container bg-[var(--bg-surface)] print:bg-white border border-[var(--border-color)] print:border-none rounded-2xl print:rounded-none p-6 sm:p-10 print:p-0 shadow-sm print:shadow-none space-y-12 print:space-y-0">
|
||||
{/* 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: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="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 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>
|
||||
)}
|
||||
<span>· {new Date(summary.generatedAt).toLocaleDateString(undefined, { dateStyle: "long" })}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* =======================================================================
|
||||
1. Cover Page
|
||||
======================================================================= */}
|
||||
{showCover && (
|
||||
<section className="cover-page flex flex-col justify-between min-h-[600px] print:min-h-[92vh] p-8 sm:p-12 print:p-10 border border-[var(--border-color)] print:border-none rounded-xl print:rounded-none bg-[var(--bg-elevated)] print:bg-transparent">
|
||||
<div>
|
||||
{/* Header Badge */}
|
||||
<div className="flex items-center justify-between gap-4 mb-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-[var(--brand)] text-white flex items-center justify-center font-bold text-sm">
|
||||
M
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-[var(--text-primary)]">
|
||||
MCPedia
|
||||
</span>
|
||||
<p className="text-[10px] text-[var(--text-dim)] font-mono">
|
||||
Knowledge Base Documentation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-mono font-semibold bg-[var(--brand)]/15 text-[var(--brand)] dark:text-[var(--accent)] border border-[var(--brand)]/30">
|
||||
<span>{sectionMeta.icon}</span>
|
||||
<span className="uppercase">{sectionMeta.label}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title and Subtitle */}
|
||||
<div className="space-y-3 mb-10 pt-4">
|
||||
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-extrabold text-[var(--text-primary)] tracking-tight leading-tight">
|
||||
{summary.title}
|
||||
</h1>
|
||||
<p className="text-sm sm:text-base text-[var(--text-muted)] leading-relaxed max-w-2xl">
|
||||
Comprehensive challenge solutions, technical writeups, and security research findings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Metric Cards Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 my-8">
|
||||
<div className="p-4 bg-[var(--bg-surface)] print:bg-slate-50 border border-[var(--border-color)] rounded-xl">
|
||||
<div className="text-2xl font-bold text-[var(--text-primary)] font-mono">
|
||||
{filteredAndSortedDocs.length}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-0.5">
|
||||
Challenges / Ch
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentTotalPoints > 0 && (
|
||||
<div className="p-4 bg-[var(--bg-surface)] print:bg-slate-50 border border-[var(--border-color)] rounded-xl">
|
||||
<div className="text-2xl font-bold text-[var(--brand)] dark:text-[var(--accent)] font-mono">
|
||||
{currentTotalPoints.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-0.5">
|
||||
Total Points
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 bg-[var(--bg-surface)] print:bg-slate-50 border border-[var(--border-color)] rounded-xl">
|
||||
<div className="text-2xl font-bold text-[var(--text-primary)] font-mono">
|
||||
{summary.categories.length}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-0.5">
|
||||
Categories
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-[var(--bg-surface)] print:bg-slate-50 border border-[var(--border-color)] rounded-xl">
|
||||
<div className="text-2xl font-bold text-[var(--text-primary)] font-mono">
|
||||
{summary.authors.length || 1}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-0.5">
|
||||
Contributors
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories Distribution */}
|
||||
{summary.categories.length > 0 && (
|
||||
<div className="space-y-2 mt-6">
|
||||
<span className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider">
|
||||
Categories Covered:
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{summary.categories.map((cat: ExportCategorySummary) => (
|
||||
<span
|
||||
key={cat.name}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-mono border ${getCategoryBadgeClass(cat.name)}`}
|
||||
>
|
||||
<span>{getCategoryIcon(cat.name)}</span>
|
||||
<span className="font-semibold">{cat.name}:</span>
|
||||
<span>{cat.count} chall{cat.count !== 1 ? "s" : ""}</span>
|
||||
{cat.points > 0 && <span className="opacity-75 font-sans">({cat.points} pts)</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cover Footer */}
|
||||
<div className="pt-8 border-t border-[var(--border-color)] flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs text-[var(--text-muted)]">
|
||||
<div>
|
||||
{summary.authors.length > 0 && (
|
||||
<p>
|
||||
<span className="font-medium text-[var(--text-secondary)]">Authors:</span>{" "}
|
||||
{summary.authors.map((a: string) => `@${a}`).join(", ")}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-0.5 font-mono text-[11px] text-[var(--text-dim)]">
|
||||
Path: {summary.scope}
|
||||
</p>
|
||||
</div>
|
||||
<div className="font-mono text-right sm:text-right">
|
||||
<p>
|
||||
{new Date(summary.generatedAt).toLocaleDateString(undefined, {
|
||||
dateStyle: "long",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-[10px] text-[var(--text-dim)]">Generated by MCPedia</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* =======================================================================
|
||||
2. Scoreboard / Challenge Matrix Table
|
||||
======================================================================= */}
|
||||
{showScoreboard && filteredAndSortedDocs.length > 1 && (
|
||||
<section className="scoreboard-page py-6 print:py-8 border-t border-[var(--border-color)] print:border-none">
|
||||
<div className="flex items-center justify-between gap-4 mb-4">
|
||||
<h2 className="text-lg font-bold text-[var(--text-primary)] tracking-tight flex items-center gap-2">
|
||||
<span>📊</span>
|
||||
<span>Challenge Overview & Scoreboard</span>
|
||||
</h2>
|
||||
<span className="text-xs font-mono text-[var(--text-muted)]">
|
||||
{filteredAndSortedDocs.length} Total Challenges
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto border border-[var(--border-color)] rounded-xl shadow-xs">
|
||||
<table className="w-full text-xs text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-[var(--bg-elevated)] print:bg-slate-100 border-b border-[var(--border-color)] text-[var(--text-muted)] font-mono uppercase">
|
||||
<th className="py-2.5 px-3 w-12 text-center">#</th>
|
||||
<th className="py-2.5 px-3">Challenge</th>
|
||||
<th className="py-2.5 px-3">Category</th>
|
||||
<th className="py-2.5 px-3">Difficulty</th>
|
||||
<th className="py-2.5 px-3 text-right">Points</th>
|
||||
<th className="py-2.5 px-3">Author</th>
|
||||
<th className="py-2.5 px-3 text-center">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-color)]">
|
||||
{filteredAndSortedDocs.map((doc, idx) => {
|
||||
const chNum = idx + 1;
|
||||
const cat = extractDocCategory(doc);
|
||||
const pts = extractDocPoints(doc);
|
||||
const diff = extractDocDifficulty(doc);
|
||||
const isSolved = extractDocSolved(doc);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={doc.slug}
|
||||
className="hover:bg-[var(--bg-elevated)]/50 transition-colors"
|
||||
>
|
||||
<td className="py-2.5 px-3 text-center font-mono text-[var(--text-dim)]">
|
||||
{chNum}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 font-medium text-[var(--text-primary)]">
|
||||
<a
|
||||
href={`#ch-${chNum}`}
|
||||
className="hover:text-[var(--brand)] dark:hover:text-[var(--accent)] hover:underline"
|
||||
>
|
||||
{doc.title}
|
||||
</a>
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-mono border ${getCategoryBadgeClass(cat)}`}
|
||||
>
|
||||
<span>{getCategoryIcon(cat)}</span>
|
||||
<span>{cat}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
{diff ? (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium border ${getDifficultyBadgeClass(diff)}`}
|
||||
>
|
||||
{diff}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[var(--text-dim)]">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-right font-mono font-semibold text-[var(--brand)] dark:text-[var(--accent)]">
|
||||
{pts > 0 ? `${pts} pts` : "-"}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-[var(--text-secondary)]">
|
||||
{doc.author ? `@${doc.author}` : "-"}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-center">
|
||||
{isSolved ? (
|
||||
<span className="text-emerald-600 font-bold" title="Solved">
|
||||
✓
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[var(--text-dim)]">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* =======================================================================
|
||||
3. Table of Contents (TOC)
|
||||
======================================================================= */}
|
||||
{/* Table of Contents */}
|
||||
{showTOC && filteredAndSortedDocs.length > 1 && (
|
||||
<section className="toc-page py-6 print:py-8 border-t border-[var(--border-color)] print:border-none">
|
||||
<h2 className="text-lg font-bold text-[var(--text-primary)] tracking-tight mb-4 flex items-center gap-2">
|
||||
<span>📑</span>
|
||||
<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>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2.5">
|
||||
<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 chNum = idx + 1;
|
||||
const cat = extractDocCategory(doc);
|
||||
const pts = extractDocPoints(doc);
|
||||
|
||||
return (
|
||||
<a
|
||||
key={doc.slug}
|
||||
href={`#ch-${chNum}`}
|
||||
className="group flex items-center justify-between p-3 rounded-lg bg-[var(--bg-elevated)] print:bg-slate-50 hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] text-xs transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0 pr-2">
|
||||
<span className="font-mono text-[11px] font-bold text-[var(--brand)] dark:text-[var(--accent)] bg-[var(--brand)]/10 px-1.5 py-0.5 rounded shrink-0">
|
||||
Ch {chNum}
|
||||
</span>
|
||||
<span className="font-medium text-[var(--text-primary)] group-hover:text-[var(--brand)] dark:group-hover:text-[var(--accent)] truncate">
|
||||
{doc.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0 font-mono text-[11px]">
|
||||
<span className="text-[var(--text-muted)]">{cat}</span>
|
||||
{pts > 0 && (
|
||||
<span className="text-[var(--brand)] dark:text-[var(--accent)] font-semibold">
|
||||
· {pts}p
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
<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 print:ml-1">
|
||||
[{cat}{pts > 0 ? ` · ${pts} pts` : ""}]
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* =======================================================================
|
||||
4. Challenge Chapters ("Urutan Rapih Per Ch")
|
||||
======================================================================= */}
|
||||
<div className="space-y-12 print:space-y-0">
|
||||
{/* Challenges Flow */}
|
||||
<div className="challenges-flow space-y-10 print:space-y-0">
|
||||
{filteredAndSortedDocs.map((doc, idx) => {
|
||||
const chNum = idx + 1;
|
||||
const cat = extractDocCategory(doc);
|
||||
const pts = extractDocPoints(doc);
|
||||
const diff = extractDocDifficulty(doc);
|
||||
const isSolved = extractDocSolved(doc);
|
||||
const extra = doc.extraFields || {};
|
||||
|
||||
return (
|
||||
<article
|
||||
key={doc.slug}
|
||||
id={`ch-${chNum}`}
|
||||
className={`chapter-page ${
|
||||
className={`challenge-entry ${
|
||||
pageBreaks && idx > 0 ? "page-break-before" : ""
|
||||
} pt-8 first:pt-0 print:pt-6`}
|
||||
} pt-6 first:pt-0 print:pt-2.5 print:pb-2 print:border-b print:border-slate-200 print:last:border-none`}
|
||||
>
|
||||
{/* Chapter Header Card */}
|
||||
<div className="challenge-header-card bg-[var(--bg-elevated)] print:bg-slate-50 border border-[var(--border-color)] print:border-slate-300 rounded-xl p-5 sm:p-6 mb-6 shadow-xs">
|
||||
{/* Chapter index label */}
|
||||
<div className="flex items-center justify-between gap-3 mb-2 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 rounded text-xs font-mono font-bold bg-[var(--brand)] text-white">
|
||||
CHAPTER {chNum}
|
||||
</span>
|
||||
<span className="text-xs font-mono text-[var(--text-dim)]">
|
||||
Challenge {chNum} of {filteredAndSortedDocs.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-xs font-mono text-[var(--text-dim)]">
|
||||
{doc.slug}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Challenge Title */}
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-[var(--text-primary)] tracking-tight leading-snug mb-4">
|
||||
{doc.title}
|
||||
{/* Challenge Title & Metadata */}
|
||||
<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 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>}
|
||||
|
||||
{/* Metadata Chips Bar */}
|
||||
<div className="badge-container flex flex-wrap items-center gap-2 pt-3 border-t border-[var(--border-color)]">
|
||||
{/* Category Chip */}
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-mono font-semibold border ${getCategoryBadgeClass(cat)}`}
|
||||
>
|
||||
<span>{getCategoryIcon(cat)}</span>
|
||||
<span>Category: {cat}</span>
|
||||
</span>
|
||||
|
||||
{/* Points Chip */}
|
||||
{pts > 0 && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-mono font-semibold bg-[var(--brand)]/10 text-[var(--brand)] dark:text-[var(--accent)] border border-[var(--brand)]/30">
|
||||
<span>🎯</span>
|
||||
<span>{pts} pts</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Difficulty Chip */}
|
||||
{diff && (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium border ${getDifficultyBadgeClass(diff)}`}
|
||||
>
|
||||
<span>Difficulty: {diff}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Solved Status */}
|
||||
{isSolved && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30">
|
||||
<span>✓</span>
|
||||
<span>Solved</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Author Chip */}
|
||||
{doc.author && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs bg-[var(--bg-surface)] border border-[var(--border-color)] text-[var(--text-secondary)]">
|
||||
<span>👤</span>
|
||||
<span>@{doc.author}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Date Chip */}
|
||||
{doc.updatedAt && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs bg-[var(--bg-surface)] border border-[var(--border-color)] text-[var(--text-dim)] font-mono">
|
||||
<span>📅</span>
|
||||
<span>{new Date(doc.updatedAt).toLocaleDateString()}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Custom Extra Fields badges (excluding already rendered keys) */}
|
||||
{/* Extra fields */}
|
||||
{Object.entries(extra).map(([k, v]) => {
|
||||
const lowerKey = k.toLowerCase();
|
||||
if (
|
||||
@@ -725,7 +412,12 @@ export default function PdfExportView({
|
||||
"difficulty",
|
||||
"solved",
|
||||
"author",
|
||||
"authors",
|
||||
"scope",
|
||||
"slug",
|
||||
"event",
|
||||
"challenge",
|
||||
"path",
|
||||
].includes(lowerKey) ||
|
||||
v === null ||
|
||||
v === undefined ||
|
||||
@@ -734,32 +426,26 @@ export default function PdfExportView({
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
key={k}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs bg-[var(--bg-surface)] border border-[var(--border-color)] text-[var(--text-muted)] font-mono"
|
||||
>
|
||||
<span className="text-[var(--text-dim)]">{k}:</span>
|
||||
<span className="text-[var(--text-secondary)] font-semibold">
|
||||
{String(v)}
|
||||
</span>
|
||||
<span key={k}>
|
||||
<strong>{k}:</strong> {String(v)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Markdown Writeup Body */}
|
||||
<div className="prose-content mb-10">
|
||||
{/* Markdown Writeup Content */}
|
||||
<div className="prose prose-slate max-w-none text-slate-900 mb-8 print:mb-1">
|
||||
<Markdown source={doc.body} />
|
||||
</div>
|
||||
|
||||
{/* Chapter End Divider */}
|
||||
<div className="print:hidden border-b border-[var(--border-color)] my-8" />
|
||||
{/* Divider between challenges (screen only) */}
|
||||
<hr className="print:hidden border-slate-200 my-8" />
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,15 @@ import { useEffect, useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { getSectionMeta } from "@mcpedia/config/sections";
|
||||
import {
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FileText,
|
||||
ChevronRight,
|
||||
Search,
|
||||
X,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
|
||||
interface Doc {
|
||||
slug: string;
|
||||
@@ -102,13 +111,13 @@ function TreeNodeItem({
|
||||
const isCollapsed = collapsedMap[node.slug] ?? false;
|
||||
|
||||
return (
|
||||
<li className={`select-none ${depth > 0 ? "ml-3 pl-2 border-l border-[var(--border-color)]" : ""}`}>
|
||||
<li className={`select-none ${depth > 0 ? "ml-3.5 pl-2 border-l border-[var(--border-color)]" : ""}`}>
|
||||
<div
|
||||
className={`group flex items-center justify-between gap-1.5 text-xs py-1.5 px-2 rounded-md transition-all ${
|
||||
className={`group flex items-center justify-between gap-1 text-xs py-1.5 px-2 rounded-md transition-all ${
|
||||
isActive
|
||||
? "text-[var(--brand)] dark:text-[var(--accent)] bg-[var(--brand)]/15 font-semibold border border-[var(--brand)]/30 shadow-sm"
|
||||
? "text-[var(--text-primary)] bg-[var(--bg-elevated)] font-semibold border border-[var(--border-color)] shadow-xs"
|
||||
: isParentOfActive
|
||||
? "text-[var(--text-primary)] bg-[var(--bg-elevated)]"
|
||||
? "text-[var(--text-primary)] bg-[var(--bg-elevated)]/60"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
|
||||
}`}
|
||||
>
|
||||
@@ -117,15 +126,23 @@ function TreeNodeItem({
|
||||
className="flex items-center gap-2 min-w-0 flex-1 py-0.5"
|
||||
title={node.title || node.name}
|
||||
>
|
||||
<span className="text-xs shrink-0 opacity-80 group-hover:opacity-100">
|
||||
{isFolder ? "📁" : "📄"}
|
||||
<span className="shrink-0 text-[var(--text-dim)] group-hover:text-[var(--text-primary)] transition-colors">
|
||||
{isFolder ? (
|
||||
isCollapsed ? (
|
||||
<Folder className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<FolderOpen className="w-3.5 h-3.5 text-[var(--text-primary)]" />
|
||||
)
|
||||
) : (
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate">{node.title || node.name}</span>
|
||||
</Link>
|
||||
|
||||
{isFolder && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className="text-[10px] text-[var(--text-muted)] bg-[var(--bg-surface)] border border-[var(--border-color)] px-1 py-0.2 rounded font-mono">
|
||||
<span className="text-[10px] text-[var(--text-dim)] bg-[var(--bg-surface)] border border-[var(--border-color)] px-1.5 py-0.2 rounded font-mono">
|
||||
{node.docCount}
|
||||
</span>
|
||||
<button
|
||||
@@ -135,24 +152,19 @@ function TreeNodeItem({
|
||||
e.stopPropagation();
|
||||
toggleCollapse(node.slug);
|
||||
}}
|
||||
className="p-0.5 text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded hover:bg-[var(--bg-elevated-hover)] transition-transform"
|
||||
className="p-1 text-[var(--text-dim)] hover:text-[var(--text-primary)] rounded hover:bg-[var(--bg-elevated-hover)] transition-transform cursor-pointer"
|
||||
aria-label={isCollapsed ? "Expand folder" : "Collapse folder"}
|
||||
>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform duration-200 ${isCollapsed ? "-rotate-90" : "rotate-0"}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<ChevronRight
|
||||
className={`w-3 h-3 transition-transform duration-200 ${isCollapsed ? "rotate-0" : "rotate-90"}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isFolder && !isCollapsed && (
|
||||
<ul className="mt-0.5 space-y-0.5 animate-fade-in">
|
||||
<ul className="mt-1 space-y-0.5 animate-fade-in">
|
||||
{node.children.map((child) => (
|
||||
<TreeNodeItem
|
||||
key={child.slug}
|
||||
@@ -212,7 +224,7 @@ export default function Sidebar() {
|
||||
}, [filteredDocs]);
|
||||
|
||||
return (
|
||||
<nav className="h-full flex flex-col py-4 px-3">
|
||||
<nav className="h-full flex flex-col py-4 px-3 select-none">
|
||||
{/* Sidebar search filter */}
|
||||
<div className="mb-3">
|
||||
<div className="relative">
|
||||
@@ -220,31 +232,25 @@ export default function Sidebar() {
|
||||
type="text"
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
placeholder="Filter docs..."
|
||||
className="w-full pl-7 pr-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-md text-xs text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--brand)] transition-colors"
|
||||
placeholder="Filter index..."
|
||||
className="w-full pl-8 pr-7 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-md text-xs text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--text-primary)] transition-all"
|
||||
/>
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-[var(--text-muted)] absolute left-2 top-2.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<Search className="w-3.5 h-3.5 text-[var(--text-dim)] absolute left-2.5 top-2.5 pointer-events-none" />
|
||||
{filterText && (
|
||||
<button
|
||||
onClick={() => setFilterText("")}
|
||||
className="absolute right-2 top-2 text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
className="absolute right-2 top-2 p-0.5 text-[var(--text-dim)] hover:text-[var(--text-primary)] rounded"
|
||||
aria-label="Clear filter"
|
||||
>
|
||||
✕
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary count */}
|
||||
<div className="flex items-center justify-between px-1 mb-3 text-[11px] text-[var(--text-dim)]">
|
||||
<span className="uppercase tracking-wider font-mono">Knowledge Base</span>
|
||||
<div className="flex items-center justify-between px-1 mb-2 text-[10px] text-[var(--text-muted)] font-mono uppercase tracking-wider">
|
||||
<span>Knowledge Index</span>
|
||||
<span>{filteredDocs.length} items</span>
|
||||
</div>
|
||||
|
||||
@@ -261,17 +267,17 @@ export default function Sidebar() {
|
||||
<div key={id} className="border-b border-[var(--border-color)] pb-3 last:border-0">
|
||||
<Link
|
||||
href={`/${id}`}
|
||||
className={`flex items-center justify-between gap-1.5 px-1 py-1 mb-1 rounded text-xs font-semibold uppercase tracking-wider transition-colors ${
|
||||
className={`flex items-center justify-between gap-1.5 px-2 py-1 mb-1 rounded-md text-xs font-semibold uppercase tracking-wider transition-colors ${
|
||||
isSectionActive
|
||||
? "text-[var(--brand)] dark:text-[var(--accent)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
? "text-[var(--text-primary)] bg-[var(--bg-elevated)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>{icon}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs">{icon}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--text-dim)] font-normal font-mono">
|
||||
<span className="text-[10px] text-[var(--text-muted)] font-normal font-mono bg-[var(--bg-surface)] px-1.5 py-0.2 rounded border border-[var(--border-color)]">
|
||||
{sectionDocs.length}
|
||||
</span>
|
||||
</Link>
|
||||
@@ -291,15 +297,22 @@ export default function Sidebar() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredDocs.length === 0 && (
|
||||
<div className="text-center py-8 text-xs text-[var(--text-dim)] font-mono">
|
||||
No matching documents
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom quick links */}
|
||||
<div className="pt-3 border-t border-[var(--border-color)] mt-auto">
|
||||
<Link
|
||||
href="/create"
|
||||
className="flex items-center justify-center gap-1.5 w-full py-1.5 text-xs 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 font-medium"
|
||||
className="flex items-center justify-center gap-2 w-full py-2 text-xs bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] text-[var(--text-primary)] rounded-md transition-all font-medium shadow-xs"
|
||||
>
|
||||
<span>+ Create New Document</span>
|
||||
<Plus className="w-3.5 h-3.5 text-[var(--text-muted)]" />
|
||||
<span>Create Document</span>
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import GithubSlugger from "github-slugger";
|
||||
import { AlignLeft, ArrowUp, Hash } from "lucide-react";
|
||||
|
||||
interface TOCEntry {
|
||||
id: string;
|
||||
@@ -58,18 +59,29 @@ export default function TOC({ source }: { source: string }) {
|
||||
|
||||
if (toc.length === 0) return null;
|
||||
|
||||
function scrollToTop() {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="text-xs">
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-[var(--border-color)]">
|
||||
<svg className="w-3.5 h-3.5 text-[var(--brand)] dark:text-[var(--accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h7" />
|
||||
</svg>
|
||||
<span className="font-semibold uppercase tracking-wider text-[var(--text-muted)] text-[11px]">
|
||||
On this page
|
||||
</span>
|
||||
<nav className="text-xs space-y-4">
|
||||
<div className="flex items-center justify-between pb-2 border-b border-[var(--border-color)]">
|
||||
<div className="flex items-center gap-1.5 font-semibold uppercase tracking-wider text-[var(--text-muted)] text-[11px] font-mono">
|
||||
<AlignLeft className="w-3.5 h-3.5 text-[var(--brand)] dark:text-[var(--accent)]" />
|
||||
<span>On This Page</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
type="button"
|
||||
className="text-[10px] text-[var(--text-dim)] hover:text-[var(--text-primary)] flex items-center gap-0.5 hover:underline"
|
||||
title="Scroll to top"
|
||||
>
|
||||
<ArrowUp className="w-3 h-3" />
|
||||
<span>Top</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1.5 border-l border-[var(--border-color)] pl-2.5">
|
||||
<ul className="space-y-1.5 border-l border-[var(--border-color)] pl-3">
|
||||
{toc.map((entry) => {
|
||||
const isActive = activeId === entry.id;
|
||||
return (
|
||||
@@ -90,7 +102,7 @@ export default function TOC({ source }: { source: string }) {
|
||||
history.pushState(null, "", `#${entry.id}`);
|
||||
}
|
||||
}}
|
||||
className={`block py-0.5 transition-colors line-clamp-1 ${
|
||||
className={`block py-0.5 transition-all line-clamp-1 ${
|
||||
isActive
|
||||
? "text-[var(--brand)] dark:text-[var(--accent)] font-semibold translate-x-0.5"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Sun, Moon } from "lucide-react";
|
||||
|
||||
const STORAGE_KEY = "mcpedia-theme";
|
||||
|
||||
@@ -36,7 +37,7 @@ export default function ThemeToggle() {
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="w-8 h-8 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border-color)] opacity-50" />
|
||||
<div className="w-8 h-8 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border-color)] opacity-40" />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,16 +47,12 @@ export default function ThemeToggle() {
|
||||
type="button"
|
||||
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
title={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
className="p-1.5 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)] border border-transparent hover:border-[var(--border-color)] transition-all flex items-center justify-center"
|
||||
className="p-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)] border border-transparent hover:border-[var(--border-color)] transition-all flex items-center justify-center cursor-pointer"
|
||||
>
|
||||
{isDark ? (
|
||||
<svg className="w-4 h-4 text-amber-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
<Sun className="w-4 h-4 text-amber-400 hover:rotate-45 transition-transform" />
|
||||
) : (
|
||||
<svg className="w-4 h-4 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
<Moon className="w-4 h-4 text-indigo-600 hover:-rotate-12 transition-transform" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { WEBHOOK_SECRET } from "@mcpedia/config";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import DocForm from "@/components/DocForm";
|
||||
import { Edit3, ArrowLeft, Plus } from "lucide-react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -33,14 +34,16 @@ export default async function CreatePage() {
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-[var(--border-color)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-[var(--brand)]/15 border border-[var(--brand)]/30 flex items-center justify-center text-xl text-[var(--brand)] dark:text-[var(--accent)]">
|
||||
✏️
|
||||
<div className="flex items-center gap-3.5">
|
||||
<div className="w-11 h-11 rounded-2xl bg-[var(--brand)]/15 border border-[var(--brand)]/30 flex items-center justify-center text-[var(--brand)] dark:text-[var(--accent)]">
|
||||
<Plus className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--text-primary)] tracking-tight">Create Document</h1>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-0.5">
|
||||
Writes directly to PostgreSQL, records revision history, and computes vector embeddings.
|
||||
<h1 className="text-2xl sm:text-3xl font-extrabold text-[var(--text-primary)] tracking-tight">
|
||||
Create Knowledge Document
|
||||
</h1>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-0.5 font-mono">
|
||||
Persists directly into PostgreSQL, tracks revision history, and generates vector embeddings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from "react";
|
||||
import React from "next/link";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getExportDocuments, formatScopeTitle, type ExportSortOption } from "@mcpedia/core";
|
||||
import PdfExportView from "@/components/PdfExportView";
|
||||
import { FileText, ArrowLeft } from "lucide-react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -43,14 +44,16 @@ export default async function ExportPdfPage({
|
||||
|
||||
if (exportData.documents.length === 0) {
|
||||
return (
|
||||
<div className="py-16 text-center space-y-4 max-w-lg mx-auto">
|
||||
<div className="text-4xl">📄</div>
|
||||
<div className="py-16 text-center space-y-4 max-w-lg mx-auto bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-3xl p-8 shadow-sm">
|
||||
<div className="w-14 h-14 rounded-2xl bg-[var(--bg-elevated)] text-[var(--text-dim)] flex items-center justify-center mx-auto">
|
||||
<FileText className="w-7 h-7" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
No Documents Found for Export
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--text-muted)]">
|
||||
<p className="text-xs sm:text-sm text-[var(--text-muted)] leading-relaxed">
|
||||
Could not find any published documents matching the path{" "}
|
||||
<code className="text-[var(--brand)] font-mono">
|
||||
<code className="text-[var(--brand)] dark:text-[var(--accent)] font-mono font-semibold">
|
||||
{targetPath || section || "root"}
|
||||
</code>
|
||||
.
|
||||
@@ -58,9 +61,10 @@ export default async function ExportPdfPage({
|
||||
<div className="pt-4">
|
||||
<Link
|
||||
href={backHref}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 bg-[var(--brand)] hover:bg-[var(--brand-hover)] text-white text-xs font-semibold rounded-lg shadow-sm transition-all"
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 bg-[var(--brand)] hover:bg-[var(--brand-hover)] text-white text-xs font-semibold rounded-xl shadow-md shadow-[var(--brand)]/20 transition-all"
|
||||
>
|
||||
<span>← Return to Knowledge Base</span>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>Return to Knowledge Base</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+348
-174
@@ -1,32 +1,42 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
/* Light mode defaults (:root) */
|
||||
/*
|
||||
MCPedia Design Philosophy: Archival Precision & Technical Monolith
|
||||
- Strict monochromatic foundation (Graphite, Zinc, Charcoal)
|
||||
- Zero decorative AI gradients or blurred blobs
|
||||
- Typographic hierarchy: Strong contrast, razor-sharp hairline borders, monospace telemetry
|
||||
- Functional signal indicators (Status Green, Alert Amber, Critical Coral)
|
||||
*/
|
||||
|
||||
/* Light mode defaults (:root) - Technical Paper / Archival Slate */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg-app: #f8fafc;
|
||||
--bg-app: #f9fafb;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-surface-hover: #f1f5f9;
|
||||
--bg-elevated: #f1f5f9;
|
||||
--bg-elevated-hover: #e2e8f0;
|
||||
--border-color: #e2e8f0;
|
||||
--border-subtle: #edf2f7;
|
||||
--border-card: #e2e8f0;
|
||||
--bg-surface-hover: #f3f4f6;
|
||||
--bg-elevated: #f3f4f6;
|
||||
--bg-elevated-hover: #e5e7eb;
|
||||
--border-color: #e5e7eb;
|
||||
--border-subtle: #f3f4f6;
|
||||
--border-card: #e5e7eb;
|
||||
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #334155;
|
||||
--text-muted: #64748b;
|
||||
--text-dim: #94a3b8;
|
||||
--text-primary: #111827;
|
||||
--text-secondary: #374151;
|
||||
--text-muted: #6b7280;
|
||||
--text-dim: #9ca3af;
|
||||
|
||||
--brand: #4f46e5;
|
||||
--brand-hover: #4338ca;
|
||||
--accent: #4f46e5;
|
||||
--accent-hover: #4338ca;
|
||||
/* Accent: Precision Technical Ink */
|
||||
--brand: #18181b;
|
||||
--brand-hover: #27272a;
|
||||
--brand-fg: #ffffff;
|
||||
--accent: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
|
||||
--nav-bg: rgba(255, 255, 255, 0.88);
|
||||
--nav-bg: rgba(255, 255, 255, 0.92);
|
||||
|
||||
/* Code & Syntax variables (Light Mode) */
|
||||
--code-bg: #f1f5f9;
|
||||
/* Code & Syntax (Light Mode) */
|
||||
--code-bg: #f3f4f6;
|
||||
--code-block-bg: #f8fafc;
|
||||
--code-header-bg: #f1f5f9;
|
||||
--border-code: #e2e8f0;
|
||||
@@ -37,15 +47,15 @@
|
||||
--code-inline-text: #0f172a;
|
||||
--code-inline-border: #e2e8f0;
|
||||
|
||||
--table-zebra: rgba(241, 245, 249, 0.6);
|
||||
--scrollbar-thumb: #cbd5e1;
|
||||
--scrollbar-thumb-hover: #94a3b8;
|
||||
--shadow-card: 0 1px 3px 0 rgba(0, 0, 0, 0.05), 0 1px 2px -1px rgba(0, 0, 0, 0.05);
|
||||
--table-zebra: rgba(243, 244, 246, 0.5);
|
||||
--scrollbar-thumb: #d1d5db;
|
||||
--scrollbar-thumb-hover: #9ca3af;
|
||||
--shadow-card: 0 1px 2px 0 rgba(0, 0, 0, 0.04);
|
||||
|
||||
/* Syntax Highlighting (Light Mode) */
|
||||
/* Syntax Highlighting */
|
||||
--hl-keyword: #cf222e;
|
||||
--hl-string: #0a3069;
|
||||
--hl-title: #8250df;
|
||||
--hl-title: #6f42c1;
|
||||
--hl-attr: #953800;
|
||||
--hl-number: #0550ae;
|
||||
--hl-comment: #6e7781;
|
||||
@@ -55,48 +65,50 @@
|
||||
--hl-meta: #6e7781;
|
||||
}
|
||||
|
||||
/* Dark mode overrides (html.dark) */
|
||||
/* Dark mode overrides (html.dark) - Deep Basalt / Precision Carbon */
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
--bg-app: #08090a;
|
||||
--bg-surface: #0f1011;
|
||||
--bg-surface-hover: #131415;
|
||||
--bg-elevated: #141517;
|
||||
--bg-elevated-hover: #1b1d20;
|
||||
--border-color: #1f2022;
|
||||
--border-subtle: rgba(255, 255, 255, 0.06);
|
||||
--border-card: #1f2022;
|
||||
--bg-app: #090a0c;
|
||||
--bg-surface: #101216;
|
||||
--bg-surface-hover: #16181f;
|
||||
--bg-elevated: #161920;
|
||||
--bg-elevated-hover: #1e222b;
|
||||
--border-color: #222630;
|
||||
--border-subtle: #181b22;
|
||||
--border-card: #222630;
|
||||
|
||||
--text-primary: #f7f8f8;
|
||||
--text-secondary: #d0d6e0;
|
||||
--text-muted: #8a8f98;
|
||||
--text-dim: #62666d;
|
||||
--text-primary: #f3f4f6;
|
||||
--text-secondary: #d1d5db;
|
||||
--text-muted: #9ca3af;
|
||||
--text-dim: #6b7280;
|
||||
|
||||
--brand: #5e6ad2;
|
||||
--brand-hover: #7170ff;
|
||||
--accent: #7170ff;
|
||||
--accent-hover: #828fff;
|
||||
/* Accent: Monolithic Snow Ink */
|
||||
--brand: #f3f4f6;
|
||||
--brand-hover: #ffffff;
|
||||
--brand-fg: #090a0c;
|
||||
--accent: #60a5fa;
|
||||
--accent-hover: #93c5fd;
|
||||
|
||||
--nav-bg: rgba(8, 9, 10, 0.85);
|
||||
--nav-bg: rgba(9, 10, 12, 0.90);
|
||||
|
||||
/* Code & Syntax variables (Dark Mode) */
|
||||
--code-bg: #141517;
|
||||
--code-block-bg: #0d1117;
|
||||
--code-header-bg: #161b22;
|
||||
--border-code: #30363d;
|
||||
--code-button-bg: #21262d;
|
||||
--code-button-hover-bg: #30363d;
|
||||
--pre-text: #e6edf3;
|
||||
--code-inline-bg: #161b22;
|
||||
--code-inline-text: #e6edf3;
|
||||
--code-inline-border: #30363d;
|
||||
/* Code & Syntax (Dark Mode) */
|
||||
--code-bg: #0c0e12;
|
||||
--code-block-bg: #0c0e12;
|
||||
--code-header-bg: #14171d;
|
||||
--border-code: #222630;
|
||||
--code-button-bg: #181b22;
|
||||
--code-button-hover-bg: #222630;
|
||||
--pre-text: #e5e7eb;
|
||||
--code-inline-bg: #161920;
|
||||
--code-inline-text: #e5e7eb;
|
||||
--code-inline-border: #222630;
|
||||
|
||||
--table-zebra: rgba(255, 255, 255, 0.02);
|
||||
--scrollbar-thumb: #23252a;
|
||||
--scrollbar-thumb-hover: #383b42;
|
||||
--table-zebra: rgba(255, 255, 255, 0.015);
|
||||
--scrollbar-thumb: #222630;
|
||||
--scrollbar-thumb-hover: #374151;
|
||||
--shadow-card: none;
|
||||
|
||||
/* Syntax Highlighting (Dark Mode) */
|
||||
/* Syntax Highlighting */
|
||||
--hl-keyword: #ff7b72;
|
||||
--hl-string: #a5d6ff;
|
||||
--hl-title: #d2a8ff;
|
||||
@@ -110,7 +122,7 @@ html.dark {
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
||||
|
||||
--color-background: var(--bg-app);
|
||||
@@ -141,16 +153,17 @@ body {
|
||||
background-color: var(--bg-app);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-sans);
|
||||
font-feature-settings: "cv01", "ss03";
|
||||
font-feature-settings: "cv01", "ss03", "zero", "calt";
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
/* Custom hairline scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@@ -159,49 +172,88 @@ body {
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 9999px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
/* Glassmorphism helpers */
|
||||
.glass-panel {
|
||||
background: var(--bg-surface);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Glass navigation */
|
||||
.glass-nav {
|
||||
background: var(--nav-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
/* Prose markdown typography for both Light and Dark modes */
|
||||
/* Tactical Architectural Callouts */
|
||||
.callout {
|
||||
border-left-width: 3px;
|
||||
border-radius: 0 0.375rem 0.375rem 0;
|
||||
padding: 0.875rem 1.125rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.callout-note {
|
||||
border-left-color: var(--text-muted);
|
||||
background-color: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.callout-tip {
|
||||
border-left-color: #10b981;
|
||||
background-color: rgba(16, 185, 129, 0.06);
|
||||
}
|
||||
|
||||
.callout-important {
|
||||
border-left-color: #3b82f6;
|
||||
background-color: rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
|
||||
.callout-warning {
|
||||
border-left-color: #f59e0b;
|
||||
background-color: rgba(245, 158, 11, 0.06);
|
||||
}
|
||||
|
||||
.callout-caution {
|
||||
border-left-color: #ef4444;
|
||||
background-color: rgba(239, 68, 68, 0.06);
|
||||
}
|
||||
|
||||
/* Prose Typography */
|
||||
.prose {
|
||||
color: var(--text-secondary) !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.prose h1, .prose h2, .prose h3, .prose h4, .prose strong {
|
||||
color: var(--text-primary) !important;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.prose a {
|
||||
color: var(--accent) !important;
|
||||
color: var(--text-primary) !important;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--border-color);
|
||||
text-underline-offset: 3px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.prose a:hover {
|
||||
text-decoration-color: var(--text-primary);
|
||||
}
|
||||
|
||||
.prose table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
margin: 1.75rem 0;
|
||||
margin: 2rem 0;
|
||||
font-size: 0.875rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.prose th,
|
||||
@@ -215,9 +267,10 @@ body {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
letter-spacing: 0.08em;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.prose td {
|
||||
@@ -228,6 +281,10 @@ body {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.prose tbody tr:hover {
|
||||
background: var(--bg-elevated-hover);
|
||||
}
|
||||
|
||||
.prose tbody tr:nth-child(even) {
|
||||
background: var(--table-zebra);
|
||||
}
|
||||
@@ -236,11 +293,12 @@ body {
|
||||
background: var(--code-block-bg) !important;
|
||||
color: var(--pre-text) !important;
|
||||
border: 1px solid var(--border-code);
|
||||
border-radius: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem 1.25rem;
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
font-family: var(--font-mono);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.prose code {
|
||||
@@ -252,8 +310,8 @@ body {
|
||||
background: var(--code-inline-bg);
|
||||
color: var(--code-inline-text);
|
||||
border: 1px solid var(--code-inline-border);
|
||||
padding: 0.15rem 0.375rem;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.15rem 0.35rem;
|
||||
border-radius: 0.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -263,13 +321,13 @@ body {
|
||||
}
|
||||
|
||||
.prose blockquote {
|
||||
border-left: 3px solid var(--brand);
|
||||
background: rgba(94, 106, 210, 0.08);
|
||||
border-left: 2px solid var(--text-muted);
|
||||
background: var(--bg-elevated);
|
||||
border-radius: 0 0.375rem 0.375rem 0;
|
||||
margin: 1.5em 0;
|
||||
padding: 0.75em 1.25em;
|
||||
font-style: normal;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.prose hr {
|
||||
@@ -277,7 +335,7 @@ body {
|
||||
margin: 2.5rem 0;
|
||||
}
|
||||
|
||||
/* Syntax Highlighting Tokens (Highlight.js) */
|
||||
/* Syntax Highlighting */
|
||||
.hljs {
|
||||
color: var(--pre-text);
|
||||
background: transparent;
|
||||
@@ -346,14 +404,14 @@ body {
|
||||
}
|
||||
|
||||
.hljs-addition {
|
||||
color: #1a7f37;
|
||||
background-color: rgba(46, 160, 67, 0.15);
|
||||
color: #10b981;
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.hljs-deletion {
|
||||
color: #cf222e;
|
||||
background-color: rgba(248, 81, 73, 0.15);
|
||||
color: #ef4444;
|
||||
background-color: rgba(239, 68, 68, 0.15);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@@ -365,46 +423,46 @@ body {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
/* Transitions */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.2s ease-out forwards;
|
||||
animation: fadeIn 0.18s ease-out forwards;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Print & PDF Export Stylesheet
|
||||
Optimized for publication-grade PDF generation via browser / window.print()
|
||||
========================================================================== */
|
||||
/* Print & PDF Export Stylesheet */
|
||||
@media print {
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 15mm 15mm 15mm 15mm;
|
||||
margin: 10mm 12mm 10mm 12mm;
|
||||
}
|
||||
|
||||
/* Force exact background colors and graphics */
|
||||
*, *::before, *::after {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
/* Reset document canvas to white for crisp printing & ink economy */
|
||||
html, body {
|
||||
background-color: #ffffff !important;
|
||||
color: #0f172a !important;
|
||||
font-size: 10.5pt !important;
|
||||
line-height: 1.5 !important;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
|
||||
font-size: 8.5pt !important;
|
||||
line-height: 1.35 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Hide app navigation, headers, sidebars, interactive controls */
|
||||
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,
|
||||
.export-toolbar,
|
||||
@@ -412,107 +470,223 @@ body {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Full width container in print */
|
||||
main,
|
||||
.print-container {
|
||||
.print-container,
|
||||
main > div {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Page break rules */
|
||||
/* Explicit page break classes */
|
||||
.page-break-before,
|
||||
.chapter-page {
|
||||
break-before: page !important;
|
||||
page-break-before: always !important;
|
||||
}
|
||||
|
||||
.page-break-after,
|
||||
.cover-page,
|
||||
.scoreboard-page,
|
||||
.toc-page {
|
||||
break-after: page !important;
|
||||
page-break-after: always !important;
|
||||
}
|
||||
|
||||
/* Prevent awkward mid-element page breaks */
|
||||
.avoid-break-inside,
|
||||
.challenge-header-card,
|
||||
.badge-container,
|
||||
.code-block-wrapper,
|
||||
.code-block-container,
|
||||
.callout,
|
||||
blockquote,
|
||||
table,
|
||||
pre,
|
||||
blockquote {
|
||||
tr,
|
||||
img {
|
||||
break-inside: avoid !important;
|
||||
page-break-inside: avoid !important;
|
||||
}
|
||||
|
||||
/* Code block wrapping and styling in print */
|
||||
/* 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 #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 #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: 3pt 5pt !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
pre,
|
||||
pre code,
|
||||
code {
|
||||
white-space: pre-wrap !important;
|
||||
word-break: break-all !important;
|
||||
overflow-wrap: anywhere !important;
|
||||
font-size: 8.5pt !important;
|
||||
line-height: 1.4 !important;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: #f8fafc !important;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
|
||||
font-size: 7.5pt !important;
|
||||
line-height: 1.3 !important;
|
||||
color: #0f172a !important;
|
||||
border: 1px solid #cbd5e1 !important;
|
||||
border-radius: 6px !important;
|
||||
padding: 8pt 10pt !important;
|
||||
margin: 10pt 0 !important;
|
||||
}
|
||||
|
||||
/* Typography tweaks for high-contrast print */
|
||||
.prose {
|
||||
color: #1e293b !important;
|
||||
font-size: 10.5pt !important;
|
||||
}
|
||||
|
||||
.prose h1,
|
||||
.prose h2,
|
||||
.prose h3,
|
||||
.prose h4 {
|
||||
color: #0f172a !important;
|
||||
page-break-after: avoid !important;
|
||||
break-after: avoid !important;
|
||||
}
|
||||
|
||||
.prose p,
|
||||
.prose li {
|
||||
color: #334155 !important;
|
||||
}
|
||||
|
||||
.prose a {
|
||||
color: #312e81 !important;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
/* Links display clean href only if needed or retain normal link text */
|
||||
.prose blockquote {
|
||||
:not(pre) > code {
|
||||
padding: 0.5pt 2pt !important;
|
||||
font-size: 7.5pt !important;
|
||||
background-color: #f1f5f9 !important;
|
||||
border-left: 3.5pt solid #4f46e5 !important;
|
||||
color: #334155 !important;
|
||||
padding: 6pt 10pt !important;
|
||||
}
|
||||
|
||||
.prose table {
|
||||
border: 1px solid #cbd5e1 !important;
|
||||
font-size: 9pt !important;
|
||||
}
|
||||
|
||||
.prose th {
|
||||
background-color: #f1f5f9 !important;
|
||||
color: #0f172a !important;
|
||||
border-bottom: 1.5px solid #94a3b8 !important;
|
||||
}
|
||||
|
||||
.prose td {
|
||||
border-bottom: 1px solid #e2e8f0 !important;
|
||||
border: 1px solid #e2e8f0 !important;
|
||||
border-radius: 2px !important;
|
||||
}
|
||||
}
|
||||
+33
-5
@@ -2,11 +2,13 @@ import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import Header from "@/components/Header";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import Link from "next/link";
|
||||
import { Database } from "lucide-react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "MCPedia — Knowledge base for humans and AI agents",
|
||||
title: "MCPedia — Archival Knowledge Base for Humans & AI Agents",
|
||||
description:
|
||||
"A content-first knowledge base. Humans read the Web UI; AI agents use the MCP server. Both share one Core.",
|
||||
"A content-first knowledge base. Humans read the Web UI; AI agents use the Model Context Protocol (MCP). Both share one Core.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -35,7 +37,7 @@ export default function RootLayout({
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-screen bg-[var(--bg-app)] text-[var(--text-secondary)] font-sans antialiased selection:bg-[var(--brand)]/30 selection:text-white flex flex-col transition-colors duration-150">
|
||||
<body className="min-h-screen bg-[var(--bg-app)] text-[var(--text-secondary)] font-sans antialiased selection:bg-[var(--text-primary)] selection:text-[var(--bg-app)] flex flex-col transition-colors duration-150">
|
||||
<div hidden>
|
||||
<div id="a11y-live-region" aria-live="polite" aria-atomic="true" />
|
||||
</div>
|
||||
@@ -44,16 +46,42 @@ 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global Precision Status Footer */}
|
||||
<footer className="no-print border-t border-[var(--border-color)] bg-[var(--bg-surface)] py-4 text-xs text-[var(--text-muted)]">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-8 flex flex-col sm:flex-row items-center justify-between gap-3 font-mono">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
<span className="text-[var(--text-primary)] font-semibold">MCPedia</span>
|
||||
<span className="text-[var(--border-color)]">/</span>
|
||||
<span>PostgreSQL 16 Core</span>
|
||||
<span className="text-[var(--border-color)]">/</span>
|
||||
<span>pgvector</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-[11px] text-[var(--text-muted)]">
|
||||
<Link href="/search" className="hover:text-[var(--text-primary)] transition-colors">
|
||||
RRF Hybrid Search
|
||||
</Link>
|
||||
<span>·</span>
|
||||
<Link href="/export/pdf" className="hover:text-[var(--text-primary)] transition-colors">
|
||||
Publication PDF
|
||||
</Link>
|
||||
<span>·</span>
|
||||
<span>HTTP :4021</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+25
-24
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Lock, Key, Eye, EyeOff, ArrowLeft } from "lucide-react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -38,47 +39,46 @@ export default function LoginPage() {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[65vh] items-center justify-center px-4">
|
||||
<div className="w-full max-w-md bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-2xl p-6 sm:p-8 shadow-2xl relative overflow-hidden">
|
||||
{/* Top decorative glow */}
|
||||
<div className="absolute -top-12 left-1/2 -translate-x-1/2 w-48 h-24 bg-[var(--brand)]/20 blur-2xl pointer-events-none rounded-full" />
|
||||
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-12 h-12 rounded-xl bg-[var(--brand)] text-white font-bold text-xl flex items-center justify-center mx-auto mb-3 shadow-lg shadow-[var(--brand)]/25">
|
||||
M
|
||||
<div className="w-full max-w-md bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-6 sm:p-8 shadow-xs relative">
|
||||
<div className="text-center mb-7">
|
||||
<div className="w-10 h-10 rounded-lg bg-[var(--brand)] text-[var(--brand-fg)] font-bold text-sm flex items-center justify-center mx-auto mb-3 shadow-xs font-mono">
|
||||
<Lock className="w-4 h-4" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-[var(--text-primary)] tracking-tight">Admin Authentication</h1>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1.5">
|
||||
Enter your admin password to create, edit, or delete documents.
|
||||
<h1 className="text-xl font-bold text-[var(--text-primary)] tracking-tight">Admin Authentication</h1>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1">
|
||||
Enter administrative password to authorize document mutations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3.5 mb-6 rounded-lg border border-rose-500/30 bg-rose-500/10 text-rose-500 dark:text-rose-400 text-xs">
|
||||
<div className="p-3 mb-5 rounded-lg border border-rose-500/30 bg-rose-500/10 text-rose-500 dark:text-rose-400 text-xs font-medium animate-fade-in">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||
Admin Password
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1.5 font-mono">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter password..."
|
||||
className="w-full px-3.5 py-2.5 bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-lg text-sm text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--brand)] focus:ring-1 focus:ring-[var(--brand)] transition-colors"
|
||||
placeholder="Enter admin password..."
|
||||
className="w-full pl-9 pr-10 py-2.5 bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-lg text-sm text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--text-primary)] transition-colors font-mono"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Key className="w-3.5 h-3.5 text-[var(--text-dim)] absolute left-3 top-3.5 pointer-events-none" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-2.5 text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
className="absolute right-3 top-2.5 text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] p-1 cursor-pointer"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? "Hide" : "Show"}
|
||||
{showPassword ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,23 +86,24 @@ export default function LoginPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 bg-[var(--brand)] hover:bg-[var(--brand-hover)] text-white rounded-lg transition-all font-medium text-sm shadow-md shadow-[var(--brand)]/20 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
className="w-full py-2.5 bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] rounded-lg transition-all font-semibold text-xs shadow-xs disabled:opacity-50 flex items-center justify-center gap-2 cursor-pointer uppercase tracking-wider font-mono"
|
||||
>
|
||||
{loading && (
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<div className="w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
<span>{loading ? "Authenticating..." : "Sign In"}</span>
|
||||
<span>{loading ? "AUTHENTICATING..." : "AUTHORIZE_ACCESS"}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-[var(--border-color)] flex items-center justify-between text-xs text-[var(--text-muted)]">
|
||||
<div className="mt-7 pt-5 border-t border-[var(--border-color)] flex items-center justify-between text-xs text-[var(--text-muted)]">
|
||||
<Link
|
||||
href="/"
|
||||
className="hover:text-[var(--text-primary)] transition-colors"
|
||||
className="hover:text-[var(--text-primary)] transition-colors flex items-center gap-1"
|
||||
>
|
||||
← Back to Home
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>Return to Home</span>
|
||||
</Link>
|
||||
<span className="text-[11px] text-[var(--text-dim)]">HTTP-only Secure Cookie</span>
|
||||
<span className="text-[10px] text-[var(--text-dim)] font-mono">HTTP Cookie</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+128
-83
@@ -2,6 +2,19 @@ import Link from "next/link";
|
||||
import { listDocuments, listSections } from "@mcpedia/core";
|
||||
import { getSectionMeta, type SectionConfig } from "@mcpedia/config";
|
||||
import McpConfigSnippet from "@/components/McpConfigSnippet";
|
||||
import InteractiveTopology from "@/components/InteractiveTopology";
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
BookOpen,
|
||||
ArrowRight,
|
||||
Database,
|
||||
Layers,
|
||||
Folder,
|
||||
FileText,
|
||||
Clock,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -22,7 +35,6 @@ function slugToTitle(segment: string): string {
|
||||
|
||||
function buildFolderTree(docs: { slug: string; title: string }[], section: string): TreeNode[] {
|
||||
const tree: TreeNode[] = [];
|
||||
const treeIndex = new Map<string, TreeNode>();
|
||||
|
||||
for (const doc of docs) {
|
||||
const parts = doc.slug.split("/");
|
||||
@@ -44,7 +56,6 @@ function buildFolderTree(docs: { slug: string; title: string }[], section: strin
|
||||
isLeaf: false,
|
||||
};
|
||||
current.push(node);
|
||||
treeIndex.set(currentPath, node);
|
||||
}
|
||||
|
||||
if (isLeaf) {
|
||||
@@ -77,26 +88,28 @@ function buildFolderTree(docs: { slug: string; title: string }[], section: strin
|
||||
return sortAll(tree);
|
||||
}
|
||||
|
||||
function renderTree(nodes: TreeNode[], pathname: string) {
|
||||
function renderTree(nodes: TreeNode[]) {
|
||||
return (
|
||||
<ul className="space-y-1">
|
||||
{nodes.map((node) => (
|
||||
{nodes.slice(0, 5).map((node) => (
|
||||
<li key={node.slug}>
|
||||
<Link
|
||||
href={`/${node.slug}`}
|
||||
className={`flex items-center gap-1.5 text-xs py-1 px-1.5 rounded transition-all ${
|
||||
pathname === `/${node.slug}`
|
||||
? "text-[var(--brand)] dark:text-[var(--accent)] bg-[var(--brand)]/15 font-medium"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
|
||||
}`}
|
||||
className="flex items-center gap-2 text-xs py-1 px-2 rounded-md text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-elevated)] transition-colors group"
|
||||
title={node.title}
|
||||
>
|
||||
<span className="opacity-80">{node.isLeaf && node.children.length === 0 ? "📄" : "📁"}</span>
|
||||
<span className="shrink-0 text-[var(--text-dim)] group-hover:text-[var(--text-primary)] transition-colors">
|
||||
{node.isLeaf && node.children.length === 0 ? (
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Folder className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate">{node.title || node.name}</span>
|
||||
</Link>
|
||||
{node.children.length > 0 && (
|
||||
<div className="ml-3.5 border-l border-[var(--border-color)] pl-2 mt-0.5">
|
||||
{renderTree(node.children, pathname)}
|
||||
<div className="ml-3 border-l border-[var(--border-color)] pl-2 mt-0.5">
|
||||
{renderTree(node.children)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
@@ -105,13 +118,7 @@ function renderTree(nodes: TreeNode[], pathname: string) {
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionTreeProps {
|
||||
section: string;
|
||||
docs: { slug: string; title: string }[];
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
function SectionTree({ section, docs, pathname }: SectionTreeProps) {
|
||||
function SectionTree({ section, docs }: { section: string; docs: { slug: string; title: string }[] }) {
|
||||
const tree = buildFolderTree(
|
||||
docs.filter((d) => d.slug.startsWith(`${section}/`)),
|
||||
section,
|
||||
@@ -120,9 +127,9 @@ function SectionTree({ section, docs, pathname }: SectionTreeProps) {
|
||||
return (
|
||||
<div>
|
||||
{tree.length > 0 ? (
|
||||
renderTree(tree, pathname)
|
||||
renderTree(tree)
|
||||
) : (
|
||||
<p className="text-xs text-[var(--text-dim)] pl-1">No documents</p>
|
||||
<p className="text-xs text-[var(--text-dim)] pl-2">No documents</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -144,76 +151,110 @@ export default async function HomePage() {
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
{/* Hero Section */}
|
||||
<section className="relative pt-4 pb-2">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-[var(--brand)]/10 border border-[var(--brand)]/25 text-[var(--brand)] dark:text-[var(--accent)] text-xs font-medium mb-6">
|
||||
<span className="w-2 h-2 rounded-full bg-[var(--brand)] animate-pulse" />
|
||||
<span>Model Context Protocol + PostgreSQL Core</span>
|
||||
<section className="relative pt-2 pb-4">
|
||||
{/* Status indicator stamp */}
|
||||
<div className="inline-flex items-center gap-2 px-2.5 py-1 rounded-md bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-secondary)] text-xs font-mono mb-6">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
<span>PostgreSQL 16 Core · Model Context Protocol</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold text-[var(--text-primary)] tracking-tight mb-5 leading-tight">
|
||||
{/* Primary Title with strong monolithic weight */}
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-extrabold text-[var(--text-primary)] tracking-tight mb-5 leading-[1.1]">
|
||||
Knowledge Base for <br />
|
||||
<span className="bg-gradient-to-r from-[var(--brand)] via-[var(--accent)] to-[var(--accent-hover)] dark:to-white bg-clip-text text-transparent">
|
||||
Humans and AI Agents
|
||||
</span>
|
||||
Humans and AI Agents.
|
||||
</h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p className="text-base sm:text-lg text-[var(--text-muted)] max-w-2xl leading-relaxed mb-8">
|
||||
A high-performance, database-backed documentation system. Browse hierarchical notes and technical writeups in your browser, or connect AI coding assistants directly via native MCP tools.
|
||||
A high-performance technical knowledge repository. Humans read the web interface; AI assistants connect directly via native MCP tools. Both share one unified database source of truth.
|
||||
</p>
|
||||
|
||||
{/* Quick action buttons & stats */}
|
||||
{/* Quick Action Buttons */}
|
||||
<div className="flex flex-wrap items-center gap-3 mb-10">
|
||||
<Link
|
||||
href="/docs"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-[var(--brand)] hover:bg-[var(--brand-hover)] text-white rounded-lg font-medium text-sm transition-all shadow-md shadow-[var(--brand)]/20"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-[var(--brand)] hover:opacity-90 text-[var(--brand-fg)] rounded-lg font-semibold text-sm transition-all shadow-xs cursor-pointer"
|
||||
>
|
||||
<BookOpen className="w-4 h-4" />
|
||||
<span>Browse Documentation</span>
|
||||
<span>→</span>
|
||||
<ArrowRight className="w-4 h-4 ml-0.5" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/search"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--brand)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] rounded-lg font-medium text-sm transition-all shadow-sm"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--text-muted)] text-[var(--text-primary)] rounded-lg font-semibold text-sm transition-colors shadow-xs"
|
||||
>
|
||||
<svg className="w-4 h-4 text-[var(--text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<Search className="w-4 h-4 text-[var(--text-muted)]" />
|
||||
<span>Hybrid Search</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/create"
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-lg text-sm transition-colors shadow-sm"
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 bg-[var(--bg-surface)] hover:bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)] rounded-lg text-sm transition-colors shadow-xs"
|
||||
>
|
||||
<span>+ Create Document</span>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Create Document</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 p-4 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl max-w-3xl shadow-sm">
|
||||
<div>
|
||||
<div className="text-xl font-bold text-[var(--text-primary)]">{all.length}</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">Documents in DB</div>
|
||||
{/* Live Telemetry Metric Strip */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 p-4 sm:p-5 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-xs">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-[var(--bg-elevated)] text-[var(--text-primary)] border border-[var(--border-color)]">
|
||||
<Database className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-[var(--text-primary)] font-mono">{all.length}</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)] font-mono">DB Documents</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-[var(--text-primary)]">{sections.length}</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">Active sections</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-[var(--bg-elevated)] text-[var(--text-primary)] border border-[var(--border-color)]">
|
||||
<Layers className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-[var(--text-primary)] font-mono">{sections.length}</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)] font-mono">Active Sections</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-[var(--brand)] dark:text-[var(--accent)]">RRF Hybrid</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">FTS + Cosine Vectors</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-[var(--bg-elevated)] text-[var(--text-primary)] border border-[var(--border-color)]">
|
||||
<Search className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-[var(--text-primary)] font-mono">RRF</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)] font-mono">FTS + Cosine Vectors</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-emerald-600 dark:text-emerald-400">13 MCP Tools</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">Streamable HTTP :4021</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-[var(--bg-elevated)] text-[var(--text-primary)] border border-[var(--border-color)]">
|
||||
<Terminal className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-[var(--text-primary)] font-mono">13 Tools</div>
|
||||
<div className="text-[11px] text-[var(--text-muted)] font-mono">HTTP :4021 MCP</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Browse by Section */}
|
||||
{/* Interactive System Architecture & Topology */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider">
|
||||
Browse by Section
|
||||
</h2>
|
||||
<InteractiveTopology />
|
||||
</section>
|
||||
|
||||
{/* Browse Knowledge by Section */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="w-4 h-4 text-[var(--text-muted)]" />
|
||||
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase tracking-wider font-mono">
|
||||
Knowledge Sections
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
@@ -222,19 +263,19 @@ export default async function HomePage() {
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
className="group flex flex-col justify-between bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-xl p-5 transition-all shadow-sm"
|
||||
className="group flex flex-col justify-between bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-xl p-5 transition-all shadow-xs"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<span className="text-2xl">{s.icon}</span>
|
||||
<Link
|
||||
href={`/${s.id}`}
|
||||
className="text-xs font-mono text-[var(--brand)] dark:text-[var(--accent)] hover:underline"
|
||||
className="text-xs font-mono text-[var(--text-muted)] hover:text-[var(--text-primary)] font-semibold"
|
||||
>
|
||||
{sectionDocs.length} docs →
|
||||
</Link>
|
||||
</div>
|
||||
<h3 className="font-semibold text-base text-[var(--text-primary)] group-hover:text-[var(--brand)] dark:group-hover:text-[var(--accent)] transition-colors mb-1.5">
|
||||
<h3 className="font-bold text-sm text-[var(--text-primary)] mb-1">
|
||||
{s.label}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--text-muted)] mb-4 line-clamp-2 leading-relaxed">
|
||||
@@ -242,12 +283,11 @@ export default async function HomePage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Inline folder tree */}
|
||||
{/* Inline folder directory preview */}
|
||||
<div className="pt-3 border-t border-[var(--border-color)]">
|
||||
<SectionTree
|
||||
section={s.id}
|
||||
docs={all.map((d) => ({ slug: d.slug, title: d.title }))}
|
||||
pathname="/"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -256,33 +296,38 @@ export default async function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Recent Documents */}
|
||||
{/* Recently Updated Knowledge */}
|
||||
{recent.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider">
|
||||
Recently Updated Knowledge
|
||||
</h2>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-[var(--text-muted)]" />
|
||||
<h2 className="text-xs font-bold text-[var(--text-muted)] uppercase tracking-wider font-mono">
|
||||
Recently Updated
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
href="/docs"
|
||||
className="text-xs text-[var(--brand)] dark:text-[var(--accent)] hover:underline font-medium"
|
||||
className="text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] font-semibold flex items-center gap-1 font-mono"
|
||||
>
|
||||
View all ({all.length}) →
|
||||
<span>View all ({all.length})</span>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5">
|
||||
{recent.map((d) => {
|
||||
const sInfo = getSectionMeta(d.section);
|
||||
return (
|
||||
<Link
|
||||
key={d.slug}
|
||||
href={`/${d.slug}`}
|
||||
className="group block bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-xl p-4 transition-all shadow-sm"
|
||||
className="group block bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-xl p-4.5 transition-all shadow-xs"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<span className="text-xs font-mono text-[var(--brand)] dark:text-[var(--accent)] uppercase">
|
||||
{sInfo?.icon} {d.section}
|
||||
<span className="text-[11px] font-mono font-semibold text-[var(--text-muted)] uppercase flex items-center gap-1">
|
||||
<span>{sInfo?.icon}</span>
|
||||
<span>{d.section}</span>
|
||||
</span>
|
||||
<time className="text-[11px] text-[var(--text-dim)] font-mono">
|
||||
{new Date(d.updatedAt).toLocaleDateString(undefined, {
|
||||
@@ -291,15 +336,15 @@ export default async function HomePage() {
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
<h3 className="font-medium text-sm text-[var(--text-primary)] group-hover:text-[var(--brand)] dark:group-hover:text-[var(--accent)] transition-colors line-clamp-1 mb-2">
|
||||
<h3 className="font-semibold text-sm text-[var(--text-primary)] group-hover:underline line-clamp-1 mb-2">
|
||||
{d.title}
|
||||
</h3>
|
||||
{d.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<div className="flex flex-wrap gap-1 mt-2.5">
|
||||
{d.tags.slice(0, 3).map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="text-[10px] px-1.5 py-0.25 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)]"
|
||||
className="text-[10px] px-1.5 py-0.2 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded text-[var(--text-muted)] font-mono"
|
||||
>
|
||||
#{t}
|
||||
</span>
|
||||
@@ -313,17 +358,17 @@ export default async function HomePage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* MCP Agent Integration Showcase */}
|
||||
{/* Connect AI Assistant Showcase */}
|
||||
<section className="border-t border-[var(--border-color)] pt-12 pb-6">
|
||||
<div className="mb-6">
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] text-xs font-mono mb-2">
|
||||
<span>⚡ Native Model Context Protocol (MCP)</span>
|
||||
<span>[ MCP_INTEGRATION ]</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-[var(--text-primary)] tracking-tight mb-2">
|
||||
Connect AI Coding Assistants
|
||||
<h2 className="text-2xl font-bold text-[var(--text-primary)] tracking-tight mb-1.5">
|
||||
Model Context Protocol Configuration
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--text-muted)] max-w-xl">
|
||||
Give Claude Desktop, Cursor, Antigravity, or Zed direct access to your knowledge base via semantic search, read resources, and document tools.
|
||||
<p className="text-xs sm:text-sm text-[var(--text-muted)] max-w-xl leading-relaxed">
|
||||
Equip Claude Desktop, Cursor, Antigravity, or custom agents with direct access to your knowledge base via semantic search, read resources, and document tools.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useState, useEffect, useCallback, Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { DEFAULT_SECTIONS, type SectionConfig } from "@mcpedia/config/sections";
|
||||
import {
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
interface DocHit {
|
||||
slug: string;
|
||||
@@ -78,73 +82,68 @@ function SearchContent() {
|
||||
}
|
||||
}
|
||||
|
||||
const filteredResults = selectedSection === "all"
|
||||
? results
|
||||
: results.filter((r) => r.section === selectedSection);
|
||||
const filteredResults =
|
||||
selectedSection === "all"
|
||||
? results
|
||||
: results.filter((r) => r.section === selectedSection);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-[var(--text-primary)] tracking-tight mb-2">
|
||||
Search Knowledge Base
|
||||
<h1 className="text-3xl font-extrabold text-[var(--text-primary)] tracking-tight mb-2">
|
||||
Multi-Modal Search Console
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--text-muted)]">
|
||||
Find answers across all documentation, CTF writeups, research notes, and semantic vector chunks.
|
||||
<p className="text-xs sm:text-sm text-[var(--text-muted)] leading-relaxed">
|
||||
Query across knowledge documents and CTF writeups using PostgreSQL Full-Text Search (tsvector), Cosine Vector Embeddings, or Reciprocal Rank Fusion (RRF).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search Box & Controls */}
|
||||
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-4 sm:p-5 shadow-sm space-y-4">
|
||||
{/* Search Input */}
|
||||
<div className="bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl p-4 sm:p-5 shadow-xs space-y-4">
|
||||
{/* Search Input Bar */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={handleChange}
|
||||
placeholder="Search by keywords, challenge names, tools, concepts..."
|
||||
className="w-full pl-11 pr-10 py-3 bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--brand)] focus:ring-2 focus:ring-[var(--brand)]/20 text-base transition-all"
|
||||
className="w-full pl-10 pr-9 py-3 bg-[var(--bg-elevated)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-dim)] focus:outline-none focus:border-[var(--text-primary)] text-sm transition-all"
|
||||
autoFocus
|
||||
/>
|
||||
<svg
|
||||
className="w-5 h-5 text-[var(--brand)] dark:text-[var(--accent)] absolute left-3.5 top-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<Search className="w-4 h-4 text-[var(--text-muted)] absolute left-3.5 top-3.5 pointer-events-none" />
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
}}
|
||||
className="absolute right-3.5 top-3.5 text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] p-0.5"
|
||||
className="absolute right-3 top-3 p-0.5 text-[var(--text-dim)] hover:text-[var(--text-primary)] rounded"
|
||||
aria-label="Clear query"
|
||||
>
|
||||
✕
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Mode & Section Filters */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-2 border-t border-[var(--border-color)] text-xs">
|
||||
{/* Algorithm & Filter Controls */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-2.5 border-t border-[var(--border-color)] text-xs">
|
||||
{/* Mode Selector */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[var(--text-muted)] mr-1">Algorithm:</span>
|
||||
<span className="text-[var(--text-dim)] font-mono uppercase text-[10px] mr-1">Algorithm:</span>
|
||||
{[
|
||||
{ id: "hybrid", label: "⚡ Hybrid (RRF)", desc: "FTS + Vector Fusion" },
|
||||
{ id: "keyword", label: "🔍 Keyword", desc: "PostgreSQL FTS" },
|
||||
{ id: "semantic", label: "🧠 Semantic", desc: "Embeddings Cosine" },
|
||||
{ id: "hybrid", label: "Hybrid (RRF)", desc: "Reciprocal Rank Fusion (FTS + Vector)" },
|
||||
{ id: "keyword", label: "Keyword (FTS)", desc: "PostgreSQL GIN tsvector" },
|
||||
{ id: "semantic", label: "Semantic Vector", desc: "Cosine Similarity Embeddings" },
|
||||
].map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => handleModeChange(m.id as SearchMode)}
|
||||
title={m.desc}
|
||||
className={`px-3 py-1 rounded-md border font-medium transition-all ${
|
||||
className={`px-3 py-1 rounded-md border text-xs font-medium transition-all cursor-pointer ${
|
||||
mode === m.id
|
||||
? "bg-[var(--brand)] border-[var(--brand)] text-white shadow-sm"
|
||||
? "bg-[var(--brand)] border-[var(--brand)] text-[var(--brand-fg)] font-semibold shadow-xs"
|
||||
: "bg-[var(--bg-elevated)] border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
@@ -158,22 +157,22 @@ function SearchContent() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedSection("all")}
|
||||
className={`px-2.5 py-1 rounded-md text-xs transition-colors ${
|
||||
className={`px-2.5 py-1 rounded-md text-xs font-medium transition-colors ${
|
||||
selectedSection === "all"
|
||||
? "bg-[var(--bg-elevated-hover)] text-[var(--text-primary)] font-medium"
|
||||
? "bg-[var(--bg-elevated-hover)] text-[var(--text-primary)] font-semibold"
|
||||
: "text-[var(--text-dim)] hover:text-[var(--text-muted)]"
|
||||
}`}
|
||||
>
|
||||
All Sections
|
||||
All
|
||||
</button>
|
||||
{sections.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedSection(s.id)}
|
||||
className={`px-2.5 py-1 rounded-md text-xs transition-colors ${
|
||||
className={`px-2.5 py-1 rounded-md text-xs font-medium transition-colors ${
|
||||
selectedSection === s.id
|
||||
? "bg-[var(--bg-elevated-hover)] text-[var(--text-primary)] font-medium"
|
||||
? "bg-[var(--bg-elevated-hover)] text-[var(--text-primary)] font-semibold"
|
||||
: "text-[var(--text-dim)] hover:text-[var(--text-muted)]"
|
||||
}`}
|
||||
>
|
||||
@@ -184,38 +183,45 @@ function SearchContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{/* Loading Spinner */}
|
||||
{loading && (
|
||||
<div className="flex items-center gap-3 py-8 text-[var(--text-muted)] text-sm">
|
||||
<div className="w-5 h-5 border-2 border-[var(--brand)] border-t-transparent rounded-full animate-spin" />
|
||||
<span>Searching knowledge base...</span>
|
||||
<div className="flex items-center justify-center gap-3 py-10 text-[var(--text-muted)] text-xs font-mono">
|
||||
<div className="w-4 h-4 border-2 border-[var(--text-muted)] border-t-transparent rounded-full animate-spin" />
|
||||
<span>SEARCHING_VECTOR_SPACE...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && query && filteredResults.length === 0 && (
|
||||
<div className="text-center py-16 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-sm">
|
||||
<div className="text-3xl mb-3">🔍</div>
|
||||
<h3 className="text-base font-semibold text-[var(--text-primary)] mb-1">No matching documents</h3>
|
||||
<div className="text-center py-12 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl shadow-xs space-y-2 animate-fade-in">
|
||||
<h3 className="text-sm font-bold text-[var(--text-primary)]">No matching documents found</h3>
|
||||
<p className="text-xs text-[var(--text-muted)] max-w-sm mx-auto">
|
||||
Try switching algorithm mode to <strong>Hybrid</strong> or <strong>Semantic</strong>, or search with different keywords.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Initial Hint */}
|
||||
{/* Initial Suggestions */}
|
||||
{!loading && !query && (
|
||||
<div className="p-8 text-center bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl space-y-4 shadow-sm">
|
||||
<p className="text-xs text-[var(--text-muted)]">Popular search queries:</p>
|
||||
<div className="p-6 text-center bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-xl space-y-3 shadow-xs">
|
||||
<p className="text-[10px] font-mono uppercase tracking-wider text-[var(--text-muted)]">Popular Queries:</p>
|
||||
<div className="flex justify-center flex-wrap gap-2 text-xs">
|
||||
{["stack alignment", "ret2win", "bullmq worker", "streamable http", "full-text search", "caddy reverse proxy"].map((term) => (
|
||||
{[
|
||||
"stack alignment",
|
||||
"ret2win exploit",
|
||||
"bullmq worker",
|
||||
"streamable http",
|
||||
"full-text search",
|
||||
"caddy reverse proxy",
|
||||
"pgvector cosine",
|
||||
].map((term) => (
|
||||
<button
|
||||
key={term}
|
||||
onClick={() => {
|
||||
setQuery(term);
|
||||
handleSearch(term, mode);
|
||||
}}
|
||||
className="px-3 py-1 bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-md text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
className="px-3 py-1 bg-[var(--bg-elevated)] hover:bg-[var(--bg-elevated-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-md text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-all font-mono text-xs cursor-pointer"
|
||||
>
|
||||
{term}
|
||||
</button>
|
||||
@@ -226,40 +232,40 @@ function SearchContent() {
|
||||
|
||||
{/* Results List */}
|
||||
{!loading && filteredResults.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between text-xs text-[var(--text-dim)]">
|
||||
<div className="space-y-3 animate-fade-in">
|
||||
<div className="flex items-center justify-between text-xs text-[var(--text-dim)] font-mono">
|
||||
<span>Found {filteredResults.length} result{filteredResults.length !== 1 ? "s" : ""}</span>
|
||||
<span className="font-mono">Ranked by {mode} score</span>
|
||||
<span>Ranked by {mode} score</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2.5">
|
||||
{filteredResults.map((r) => (
|
||||
<Link
|
||||
key={r.slug}
|
||||
href={`/${r.slug}`}
|
||||
className="group block bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--brand)] rounded-xl p-4 sm:p-5 transition-all shadow-sm"
|
||||
className="group block bg-[var(--bg-surface)] hover:bg-[var(--bg-surface-hover)] border border-[var(--border-color)] hover:border-[var(--text-muted)] rounded-xl p-4.5 transition-all shadow-xs"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono px-2 py-0.5 rounded bg-[var(--brand)]/10 border border-[var(--brand)]/30 text-[var(--brand)] dark:text-[var(--accent)] uppercase">
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-muted)] uppercase font-semibold">
|
||||
{r.section}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-dim)] font-mono truncate">
|
||||
/{r.slug}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-[var(--text-muted)] bg-[var(--bg-elevated)] border border-[var(--border-color)] px-2 py-0.5 rounded">
|
||||
Score: {r.score.toFixed(3)}
|
||||
<span className="text-xs font-mono text-[var(--text-dim)] bg-[var(--bg-elevated)] px-2 py-0.5 rounded">
|
||||
{r.score.toFixed(3)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-base font-semibold text-[var(--text-primary)] group-hover:text-[var(--brand)] dark:group-hover:text-[var(--accent)] transition-colors mb-2">
|
||||
<h2 className="text-sm font-semibold text-[var(--text-primary)] group-hover:underline mb-1.5">
|
||||
{r.title}
|
||||
</h2>
|
||||
|
||||
{r.snippet && (
|
||||
<p
|
||||
className="text-xs text-[var(--text-muted)] leading-relaxed line-clamp-2 [&>mark]:bg-[var(--brand)]/30 [&>mark]:text-[var(--text-primary)] [&>mark]:px-1 [&>mark]:rounded [&>mark]:font-medium"
|
||||
className="text-xs text-[var(--text-muted)] leading-relaxed line-clamp-2 [&>mark]:bg-[var(--bg-elevated-hover)] [&>mark]:text-[var(--text-primary)] [&>mark]:font-semibold"
|
||||
dangerouslySetInnerHTML={{ __html: r.snippet }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"@mcpedia/core": "workspace:*",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"github-slugger": "^2.0.0",
|
||||
"next": "16.3.1",
|
||||
"lucide-react": "^1.33.0",
|
||||
"next": "16.3.3",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-markdown": "^9.0.0",
|
||||
@@ -25,11 +26,11 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/hast": "^3.0.5",
|
||||
"@types/node": "^20",
|
||||
"@types/node": "^26",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.1",
|
||||
"eslint-config-next": "16.3.3",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"zod": "^4.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/node": "^26",
|
||||
"typescript": "^5.6.0",
|
||||
},
|
||||
},
|
||||
@@ -60,7 +60,7 @@
|
||||
"zod": "^4.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/node": "^26",
|
||||
"typescript": "^5.6.0",
|
||||
},
|
||||
},
|
||||
@@ -72,7 +72,8 @@
|
||||
"@mcpedia/core": "workspace:*",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"github-slugger": "^2.0.0",
|
||||
"next": "16.3.1",
|
||||
"lucide-react": "^1.33.0",
|
||||
"next": "16.3.3",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-markdown": "^9.0.0",
|
||||
@@ -83,11 +84,11 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/hast": "^3.0.5",
|
||||
"@types/node": "^20",
|
||||
"@types/node": "^26",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.1",
|
||||
"eslint-config-next": "16.3.3",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5.6.0",
|
||||
},
|
||||
@@ -122,7 +123,8 @@
|
||||
"@mcpedia/parser": "workspace:*",
|
||||
"@mcpedia/search": "workspace:*",
|
||||
"@mcpedia/types": "workspace:*",
|
||||
"drizzle-orm": "^0.38.0",
|
||||
"docx": "^9.7.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
},
|
||||
},
|
||||
"packages/db": {
|
||||
@@ -130,12 +132,12 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@mcpedia/config": "workspace:*",
|
||||
"drizzle-orm": "^0.38.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"postgres": "^3.4.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"drizzle-kit": "^0.30.0",
|
||||
"@types/node": "^26",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
},
|
||||
},
|
||||
"packages/embeddings": {
|
||||
@@ -178,7 +180,7 @@
|
||||
"@mcpedia/db": "workspace:*",
|
||||
"@mcpedia/embeddings": "workspace:*",
|
||||
"@mcpedia/types": "workspace:*",
|
||||
"drizzle-orm": "^0.38.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
},
|
||||
},
|
||||
"packages/types": {
|
||||
@@ -196,7 +198,7 @@
|
||||
"@mcpedia/parser": "workspace:*",
|
||||
"@mcpedia/queue": "workspace:*",
|
||||
"@mcpedia/search": "workspace:*",
|
||||
"drizzle-orm": "^0.38.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"postgres": "^3.4.5",
|
||||
},
|
||||
},
|
||||
@@ -248,51 +250,57 @@
|
||||
|
||||
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="],
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="],
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.19.12", "", { "os": "android", "cpu": "arm64" }, "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA=="],
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.19.12", "", { "os": "android", "cpu": "x64" }, "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew=="],
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.19.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g=="],
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.19.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A=="],
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.19.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA=="],
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.19.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg=="],
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.19.12", "", { "os": "linux", "cpu": "arm" }, "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w=="],
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.19.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA=="],
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.19.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA=="],
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA=="],
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w=="],
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.19.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg=="],
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg=="],
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.19.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg=="],
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.19.12", "", { "os": "linux", "cpu": "x64" }, "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg=="],
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.19.12", "", { "os": "none", "cpu": "x64" }, "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA=="],
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.19.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw=="],
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.19.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA=="],
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.19.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A=="],
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.19.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ=="],
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="],
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="],
|
||||
|
||||
@@ -434,25 +442,25 @@
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="],
|
||||
|
||||
"@next/env": ["@next/env@16.3.1", "", {}, "sha512-35G3xwkQUb2oETSDjFXGrVugknoayLFBh7vSE+yAcl9IP2zT9wyGwq7297AYHR11kJld807t5f8AJBs6WBzXsQ=="],
|
||||
"@next/env": ["@next/env@16.3.3", "", {}, "sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g=="],
|
||||
|
||||
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.3.1", "", { "dependencies": { "@eslint-community/eslint-utils": "4.9.1", "fast-glob": "3.3.1" } }, "sha512-B4SznlXwVpaLDa7Tbi6zLuueria2d/PmFDhXyDymPGrk2r1n/RMJmcn5FZq1L64k+Jsyte1lKvOr7lP9Xo80mQ=="],
|
||||
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.3.3", "", { "dependencies": { "@eslint-community/eslint-utils": "4.9.1", "fast-glob": "3.3.1" } }, "sha512-pbEh30vvjKpDoTAmo1v3q2uM4JUi8QaEBpbmjWvGfoec2jLghy/WNtvzAT0bk+Ik9oz6etjt4YjXEk4BQnicCw=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ABMIu2zQ7cnNIHm5ivKGwZwUrm0pAai3yiJ/gK/rF1c1VP9UOnj7XECbMKFdVKp9I9eMYq9NoDs1WXOoowxzJw=="],
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-gNG21e/UnrroeScbY/QndUEdl0mF1FRibW7BBeYUz/5ABCepjqDdEdgr592vpzMtCn/m7FTjYq3TN4TpyDnutw=="],
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-6B6Lw016iwNUQuaJoraMMTLh6TwHzFUtxipSScD1F3YyymcrRWkobodRS2ftIOkF5vrs4zNlyUrTC5YZQ9Lz5w=="],
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JUiPXZKK9wOhjf4MgDiH29GZLxfqOesbLtHq2pDxwH/WwscTRV2ToymnOTh1egzaZf0ueUf8T2+CeYTGHjW0Iw=="],
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Uog9jsrmIRIL/lfvIp9htmskSNC7JcQsMVucXL2V2YY1y/D9IUN3LPEafqy0zRJ2cIU1SQ0V6F6TlffQ+pLAGg=="],
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-6yy3FT13KgUFOj5H8bl8w/6nKiJwHIvbtwh1V+1acsu+7y4tJjnemSa6mhsh53BeoVrlozE+fMgZhXH46WmjMA=="],
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-iOoN1QecUoGNZik536U/vtK43YwgyrCsGIkth52yIkl612n+0C9MjSnJbQAikISpb+WYRooBVhaDlUW7iZoKog=="],
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.1", "", { "os": "win32", "cpu": "x64" }, "sha512-d/k+PpAriUPaeMJJOG7HUSdqfEX46FEPWU1p3/nm2ACmXhj9hFEWdFODUBIpkuijXYkfL90qZzTqVPRp4BW/hw=="],
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
@@ -462,8 +470,6 @@
|
||||
|
||||
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
|
||||
|
||||
"@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
|
||||
|
||||
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="],
|
||||
@@ -718,6 +724,8 @@
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
"cron-parser": ["cron-parser@5.10.0", "", { "dependencies": { "luxon": "^3.7.2" } }, "sha512-izNAxJyRWUP8ljBoDSub5WyrVOUlT4SLGShswE7eoRBpp6QUsSycYxLBMJlbshgPBMcPT/nrfgjNY2918ayv2A=="],
|
||||
@@ -758,9 +766,11 @@
|
||||
|
||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@0.30.6", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g=="],
|
||||
"docx": ["docx@9.7.1", "", { "dependencies": { "@types/node": "^25.2.3", "hash.js": "^1.1.7", "jszip": "^3.10.1", "nanoid": "^5.1.3", "xml": "^1.0.1", "xml-js": "^1.6.8" } }, "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@0.38.4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/react": "Line truncated
|
||||
"drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.jsLine truncated
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
@@ -774,8 +784,6 @@
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
|
||||
|
||||
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||
|
||||
"es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name":Line truncated
|
||||
|
||||
"es-abstract-get": ["es-abstract-get@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "es-object-atoms": "^1.1.2", "is-callable": "^1.2.7", "object-inspect": "^1.13.4" } }, "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg=="],
|
||||
@@ -794,9 +802,7 @@
|
||||
|
||||
"es-to-primitive": ["es-to-primitive@1.3.4", "", { "dependencies": { "es-abstract-get": "^1.0.0", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "is-callable": "^1.2.7", "is-date-object": "^1.1.0", "is-symbol": "^1.1.1" } }, "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw=="],
|
||||
|
||||
"esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/lLine truncated
|
||||
|
||||
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
|
||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/lLine truncated
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
@@ -806,7 +812,7 @@
|
||||
|
||||
"eslint": ["eslint@9.39.5", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.6", "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "crosLine truncated
|
||||
|
||||
"eslint-config-next": ["eslint-config-next@16.3.1", "", { "dependencies": { "@next/eslint-plugin-next": "16.3.1", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^7.0.0", "globals": "16.4.0", "typescript-eslint": "^8.46.0" }, "peerDependencies": { "eslint": ">=9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typeLine truncated
|
||||
"eslint-config-next": ["eslint-config-next@16.3.3", "", { "dependencies": { "@next/eslint-plugin-next": "16.3.3", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^7.0.0", "globals": "16.4.0", "typescript-eslint": "^8.46.0" }, "peerDependencies": { "eslint": ">=9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typeLine truncated
|
||||
|
||||
"eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.10", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.16.1", "resolve": "^2.0.0-next.6" } }, "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ=="],
|
||||
|
||||
@@ -886,14 +892,14 @@
|
||||
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"function.prototype.name": ["function.prototype.name@1.2.0", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2", "hasown": "^2.0.4", "is-callable": "^1.2.7", "is-document.all": "^1.0.0" } }, "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew=="],
|
||||
|
||||
"functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
|
||||
|
||||
"gel": ["gel@2.2.0", "", { "dependencies": { "@petamoriken/float16": "^3.8.7", "debug": "^4.3.4", "env-paths": "^3.0.0", "semver": "^7.6.2", "shell-quote": "^1.8.1", "which": "^4.0.0" }, "bin": { "gel": "dist/cli.mjs" } }, "sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ=="],
|
||||
|
||||
"generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
@@ -932,6 +938,8 @@
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
||||
"hash.js": ["hash.js@1.1.7", "", { "dependencies": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" } }, "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA=="],
|
||||
|
||||
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||
|
||||
"hast-util-heading-rank": ["hast-util-heading-rank@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA=="],
|
||||
@@ -962,6 +970,8 @@
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
@@ -1046,9 +1056,9 @@
|
||||
|
||||
"is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="],
|
||||
|
||||
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
|
||||
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
||||
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="],
|
||||
|
||||
@@ -1074,6 +1084,8 @@
|
||||
|
||||
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
|
||||
|
||||
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
|
||||
@@ -1084,6 +1096,8 @@
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcsLine truncated
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
@@ -1120,6 +1134,8 @@
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lucide-react": ["lucide-react@1.33.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-MTRwMy0ZlL8Ur/vOAiJ9XGHE+kFPC7brq6MxAm0GiGXEBj0qy0jA/pG4N675oSzciO/UCdX8T+5yUQdmDeTLxg=="],
|
||||
|
||||
"luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
@@ -1226,6 +1242,8 @@
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
@@ -1236,7 +1254,7 @@
|
||||
|
||||
"msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "Line truncated
|
||||
|
||||
"nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
|
||||
"nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
|
||||
|
||||
"napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="],
|
||||
|
||||
@@ -1244,7 +1262,7 @@
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"next": ["next@16.3.1", "", { "dependencies": { "@next/env": "16.3.1", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.1", "@next/swc-darwin-x64": "16.3.1", "@next/swc-linux-arm64-gnu": "16.3.1", "@next/swc-linux-arm64-musl": "16.3.1", "@next/swc-linux-x64-gnu": "16.3.1", "@next/swc-linux-x64-musl": "16.3.1", "@next/swc-win32-arm64-msvc": Line truncated
|
||||
"next": ["next@16.3.3", "", { "dependencies": { "@next/env": "16.3.3", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.3", "@next/swc-darwin-x64": "16.3.3", "@next/swc-linux-arm64-gnu": "16.3.3", "@next/swc-linux-arm64-musl": "16.3.3", "@next/swc-linux-x64-gnu": "16.3.3", "@next/swc-linux-x64-musl": "16.3.3", "@next/swc-win32-arm64-msvc": Line truncated
|
||||
|
||||
"node-abort-controller": ["node-abort-controller@3.1.1", "", {}, "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="],
|
||||
|
||||
@@ -1282,6 +1300,8 @@
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||
@@ -1314,6 +1334,8 @@
|
||||
|
||||
"prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="],
|
||||
|
||||
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
|
||||
@@ -1338,6 +1360,8 @@
|
||||
|
||||
"react-markdown": ["react-markdown@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqePLine truncated
|
||||
|
||||
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
|
||||
|
||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||
@@ -1372,12 +1396,16 @@
|
||||
|
||||
"safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
|
||||
|
||||
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"sax": ["sax@1.6.1", "", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="],
|
||||
@@ -1394,6 +1422,8 @@
|
||||
|
||||
"set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
|
||||
|
||||
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linLine truncated
|
||||
@@ -1402,8 +1432,6 @@
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"shell-quote": ["shell-quote@1.10.0", "", {}, "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
||||
@@ -1442,6 +1470,8 @@
|
||||
|
||||
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
|
||||
@@ -1480,6 +1510,8 @@
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tsx": ["tsx@4.23.12", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q=="],
|
||||
|
||||
"turbo": ["turbo@2.10.11", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.11", "@turbo/darwin-arm64": "2.10.11", "@turbo/linux-64": "2.10.11", "@turbo/linux-arm64": "2.10.11", "@turbo/windows-64": "2.10.11", "@turbo/windows-arm64": "2.10.11" }, "bin": { "turbo": "bin/turbo" } }, "sha512-yQfwQVoRXwOuyX1LxiJFBFNg6VfuYh+/RyZLd82+isgyLkBXw3S5XRRzvcck1FAjSCG5sVyLd+O1eDMvYa3J7g=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
@@ -1532,7 +1564,7 @@
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
||||
|
||||
@@ -1546,6 +1578,10 @@
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="],
|
||||
|
||||
"xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
@@ -1576,18 +1612,10 @@
|
||||
|
||||
"@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||
|
||||
"@mcpedia/db/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
|
||||
|
||||
"@mcpedia/mcp/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
|
||||
|
||||
"@mcpedia/mcp/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"@mcpedia/mcp-client/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
|
||||
|
||||
"@mcpedia/mcp-client/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"@mcpedia/web/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
@@ -1614,7 +1642,7 @@
|
||||
|
||||
"body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
|
||||
|
||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
"docx/@types/node": ["@types/node@25.9.5", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg=="],
|
||||
|
||||
"eslint/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
||||
|
||||
@@ -1642,8 +1670,18 @@
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"postcss/nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
|
||||
|
||||
"safe-array-concat/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
|
||||
|
||||
"safe-push-apply/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
|
||||
|
||||
"tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mipsLine truncated
|
||||
|
||||
"type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
|
||||
|
||||
"which-builtin-type/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
||||
@@ -1692,22 +1730,68 @@
|
||||
|
||||
"@eslint/eslintrc/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"@mcpedia/db/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"@mcpedia/mcp-client/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"@mcpedia/mcp/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"@mcpedia/web/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"@next/eslint-plugin-next/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="],
|
||||
|
||||
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
"docx/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
|
||||
"eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"next/postcss/nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,8 @@
|
||||
"@mcpedia/parser": "workspace:*",
|
||||
"@mcpedia/search": "workspace:*",
|
||||
"@mcpedia/types": "workspace:*",
|
||||
"drizzle-orm": "^0.38.0"
|
||||
"docx": "^9.7.1",
|
||||
"drizzle-orm": "^0.45.2"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
parseInlineMarkdown,
|
||||
convertMarkdownToDocx,
|
||||
generateExportDocx,
|
||||
} from "./export.docx";
|
||||
import type { ExportData } from "@mcpedia/types";
|
||||
|
||||
describe("DOCX Export Generator", () => {
|
||||
it("parseInlineMarkdown: formats plain, bold, italic, inline code, and links", () => {
|
||||
const text = "Hello **world** and *italic* with `const x = 1;` and [Link](https://example.com)";
|
||||
const runs = parseInlineMarkdown(text);
|
||||
expect(runs.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("convertMarkdownToDocx: handles headings, tables, callouts, and code blocks", () => {
|
||||
const markdown = `
|
||||
# Title
|
||||
Some paragraph text with **bold** words.
|
||||
|
||||
## Subsection
|
||||
> [!NOTE]
|
||||
> This is a crucial note for testing.
|
||||
|
||||
\`\`\`python
|
||||
def solve():
|
||||
return "flag{test}"
|
||||
\`\`\`
|
||||
|
||||
| Challenge | Points |
|
||||
| --- | --- |
|
||||
| Chall 1 | 100 |
|
||||
| Chall 2 | 200 |
|
||||
|
||||
- Item 1
|
||||
- Item 2
|
||||
|
||||
1. First
|
||||
2. Second
|
||||
`;
|
||||
const elements = convertMarkdownToDocx(markdown);
|
||||
expect(elements.length).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it("generateExportDocx: builds a valid Word document buffer with OpenXML signature", async () => {
|
||||
const mockExportData: ExportData = {
|
||||
summary: {
|
||||
title: "CTF 2026 Writeups",
|
||||
scope: "ctf/2026",
|
||||
section: "ctf",
|
||||
totalDocuments: 2,
|
||||
totalPoints: 600,
|
||||
categories: [
|
||||
{ name: "Web", count: 1, points: 200 },
|
||||
{ name: "Pwn", count: 1, points: 400 },
|
||||
],
|
||||
authors: ["asepharyana"],
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
documents: [
|
||||
{
|
||||
id: "ctf/2026/web/baby-web",
|
||||
slug: "ctf/2026/web/baby-web",
|
||||
title: "Baby Web",
|
||||
section: "ctf",
|
||||
type: "writeup",
|
||||
status: "published",
|
||||
author: "asepharyana",
|
||||
tags: ["web", "sqli"],
|
||||
path: "ctf/2026/web/baby-web.md",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
extraFields: { category: "Web", points: 200, difficulty: "Easy" },
|
||||
body: "# Baby Web\n\nSolution explanation here.\n\n```sql\nSELECT * FROM users;\n```",
|
||||
},
|
||||
{
|
||||
id: "ctf/2026/pwn/ret2win",
|
||||
slug: "ctf/2026/pwn/ret2win",
|
||||
title: "Ret2Win Challenge",
|
||||
section: "ctf",
|
||||
type: "writeup",
|
||||
status: "published",
|
||||
author: "asepharyana",
|
||||
tags: ["pwn", "rop"],
|
||||
path: "ctf/2026/pwn/ret2win.md",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
extraFields: { category: "Pwn", points: 400, difficulty: "Medium" },
|
||||
body: "## Challenge Overview\n\nStack buffer overflow vulnerability.",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const buffer = await generateExportDocx(mockExportData, { pageBreaks: true });
|
||||
expect(buffer).toBeDefined();
|
||||
expect(buffer.length).toBeGreaterThan(1000);
|
||||
// Standard ZIP/DOCX magic bytes: 0x50, 0x4B, 0x03, 0x04 ('PK\x03\x04')
|
||||
expect(buffer[0]).toBe(0x50);
|
||||
expect(buffer[1]).toBe(0x4b);
|
||||
expect(buffer[2]).toBe(0x03);
|
||||
expect(buffer[3]).toBe(0x04);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,875 @@
|
||||
import {
|
||||
Document as DocxDocument,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
HeadingLevel,
|
||||
AlignmentType,
|
||||
BorderStyle,
|
||||
WidthType,
|
||||
ShadingType,
|
||||
PageBreak,
|
||||
Header,
|
||||
Footer,
|
||||
PageNumber,
|
||||
Packer,
|
||||
ExternalHyperlink,
|
||||
} from "docx";
|
||||
import type {
|
||||
Document as CoreDocument,
|
||||
ExportData,
|
||||
ExportSummary,
|
||||
} from "@mcpedia/types";
|
||||
import {
|
||||
extractDocCategory,
|
||||
extractDocPoints,
|
||||
extractDocDifficulty,
|
||||
} from "./export.helpers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Markdown to DOCX Inlines & Blocks Parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type InlineElement = TextRun | ExternalHyperlink;
|
||||
|
||||
/**
|
||||
* Parse inline markdown formatting (bold, italic, code, links) into docx TextRuns / Hyperlinks.
|
||||
*/
|
||||
export function parseInlineMarkdown(text: string): InlineElement[] {
|
||||
if (!text) return [];
|
||||
|
||||
const elements: InlineElement[] = [];
|
||||
|
||||
// Regex pattern matching:
|
||||
// 1. Links: [text](url)
|
||||
// 2. Inline code: `code`
|
||||
// 3. Bold + Italic: ***text*** or ___text___
|
||||
// 4. Bold: **text** or __text__
|
||||
// 5. Italic: *text* or _text_
|
||||
const inlineRegex =
|
||||
/(\[([^\]]+)\]\(([^)]+)\)|`([^`]+)`|\*\*\*([^*]+)\*\*\*|___([^_]+)___|\*\*([^*]+)\*\*|__([^_]+)__|(?<!\*)\*([^*]+)\*(?!\*)|(?<!_)_([^_]+)_(?!_))/g;
|
||||
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = inlineRegex.exec(text)) !== null) {
|
||||
// Add any preceding plain text
|
||||
if (match.index > lastIndex) {
|
||||
elements.push(
|
||||
new TextRun({
|
||||
text: text.slice(lastIndex, match.index),
|
||||
font: "Inter, Calibri, Arial",
|
||||
size: 20, // 10pt
|
||||
color: "1E293B",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (match[2] && match[3]) {
|
||||
// Hyperlink [text](url)
|
||||
elements.push(
|
||||
new ExternalHyperlink({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: match[2],
|
||||
font: "Inter, Calibri, Arial",
|
||||
size: 20,
|
||||
color: "2563EB",
|
||||
underline: {},
|
||||
}),
|
||||
],
|
||||
link: match[3],
|
||||
}),
|
||||
);
|
||||
} else if (match[4]) {
|
||||
// Inline code `code`
|
||||
elements.push(
|
||||
new TextRun({
|
||||
text: match[4],
|
||||
font: "Consolas, Courier New",
|
||||
size: 18, // 9pt
|
||||
color: "0F172A",
|
||||
shading: {
|
||||
type: ShadingType.CLEAR,
|
||||
fill: "F1F5F9",
|
||||
color: "auto",
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else if (match[5] || match[6]) {
|
||||
// Bold + Italic ***text***
|
||||
elements.push(
|
||||
new TextRun({
|
||||
text: match[5] || match[6],
|
||||
font: "Inter, Calibri, Arial",
|
||||
size: 20,
|
||||
bold: true,
|
||||
italics: true,
|
||||
color: "0F172A",
|
||||
}),
|
||||
);
|
||||
} else if (match[7] || match[8]) {
|
||||
// Bold **text**
|
||||
elements.push(
|
||||
new TextRun({
|
||||
text: match[7] || match[8],
|
||||
font: "Inter, Calibri, Arial",
|
||||
size: 20,
|
||||
bold: true,
|
||||
color: "0F172A",
|
||||
}),
|
||||
);
|
||||
} else if (match[9] || match[10]) {
|
||||
// Italic *text*
|
||||
elements.push(
|
||||
new TextRun({
|
||||
text: match[9] || match[10],
|
||||
font: "Inter, Calibri, Arial",
|
||||
size: 20,
|
||||
italics: true,
|
||||
color: "334155",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
lastIndex = inlineRegex.lastIndex;
|
||||
}
|
||||
|
||||
// Trailing plain text
|
||||
if (lastIndex < text.length) {
|
||||
elements.push(
|
||||
new TextRun({
|
||||
text: text.slice(lastIndex),
|
||||
font: "Inter, Calibri, Arial",
|
||||
size: 20,
|
||||
color: "1E293B",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return elements.length > 0
|
||||
? elements
|
||||
: [
|
||||
new TextRun({
|
||||
text,
|
||||
font: "Inter, Calibri, Arial",
|
||||
size: 20,
|
||||
color: "1E293B",
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse markdown table lines into a Docx Table.
|
||||
*/
|
||||
function parseMarkdownTable(tableLines: string[]): Table | null {
|
||||
if (tableLines.length < 2) return null;
|
||||
|
||||
const rows: string[][] = [];
|
||||
for (const line of tableLines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("|") && !trimmed.endsWith("|")) continue;
|
||||
// Skip separator line (|---|---|)
|
||||
if (/^\|[\s\-:|]+\|$/.test(trimmed)) continue;
|
||||
|
||||
const cells = trimmed
|
||||
.split("|")
|
||||
.slice(1, -1)
|
||||
.map((c) => c.trim());
|
||||
rows.push(cells);
|
||||
}
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const colCount = Math.max(...rows.map((r) => r.length));
|
||||
|
||||
return new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
margins: {
|
||||
top: 100,
|
||||
bottom: 100,
|
||||
left: 140,
|
||||
right: 140,
|
||||
},
|
||||
rows: rows.map((rowCells, rowIdx) => {
|
||||
const isHeader = rowIdx === 0;
|
||||
return new TableRow({
|
||||
tableHeader: isHeader,
|
||||
children: Array.from({ length: colCount }).map((_, colIdx) => {
|
||||
const cellText = rowCells[colIdx] || "";
|
||||
return new TableCell({
|
||||
shading: isHeader
|
||||
? { type: ShadingType.CLEAR, fill: "F1F5F9", color: "auto" }
|
||||
: rowIdx % 2 === 1
|
||||
? { type: ShadingType.CLEAR, fill: "FFFFFF", color: "auto" }
|
||||
: { type: ShadingType.CLEAR, fill: "F8FAFC", color: "auto" },
|
||||
borders: {
|
||||
top: { style: BorderStyle.SINGLE, size: 4, color: "CBD5E1" },
|
||||
bottom: { style: BorderStyle.SINGLE, size: 4, color: "CBD5E1" },
|
||||
left: { style: BorderStyle.SINGLE, size: 4, color: "CBD5E1" },
|
||||
right: { style: BorderStyle.SINGLE, size: 4, color: "CBD5E1" },
|
||||
},
|
||||
children: [
|
||||
new Paragraph({
|
||||
spacing: { before: 40, after: 40 },
|
||||
children: isHeader
|
||||
? [
|
||||
new TextRun({
|
||||
text: cellText,
|
||||
font: "Inter, Calibri, Arial",
|
||||
size: 18,
|
||||
bold: true,
|
||||
color: "0F172A",
|
||||
}),
|
||||
]
|
||||
: parseInlineMarkdown(cellText),
|
||||
}),
|
||||
],
|
||||
});
|
||||
}),
|
||||
});
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a markdown body string into an array of docx Paragraphs and Tables.
|
||||
*/
|
||||
export function convertMarkdownToDocx(markdown: string): (Paragraph | Table)[] {
|
||||
const elements: (Paragraph | Table)[] = [];
|
||||
const lines = markdown.split(/\r?\n/);
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
// 1. Blank lines
|
||||
if (trimmed === "") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Fenced Code Blocks (```lang ... ```)
|
||||
if (trimmed.startsWith("```")) {
|
||||
const lang = trimmed.slice(3).trim();
|
||||
const codeLines: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && !lines[i].trim().startsWith("```")) {
|
||||
codeLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
i++; // skip closing ```
|
||||
|
||||
const codeParagraphs = codeLines.map(
|
||||
(codeLine) =>
|
||||
new Paragraph({
|
||||
spacing: { before: 0, after: 0, line: 240 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: codeLine.replace(/\t/g, " ") || " ",
|
||||
font: "Consolas, Courier New",
|
||||
size: 17, // 8.5pt
|
||||
color: "0F172A",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// Wrap in a shaded callout table for code box look
|
||||
const codeBox = new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
margins: { top: 120, bottom: 120, left: 160, right: 160 },
|
||||
rows: [
|
||||
new TableRow({
|
||||
children: [
|
||||
new TableCell({
|
||||
shading: {
|
||||
type: ShadingType.CLEAR,
|
||||
fill: "F8FAFC",
|
||||
color: "auto",
|
||||
},
|
||||
borders: {
|
||||
top: { style: BorderStyle.SINGLE, size: 6, color: "CBD5E1" },
|
||||
bottom: { style: BorderStyle.SINGLE, size: 6, color: "CBD5E1" },
|
||||
left: { style: BorderStyle.SINGLE, size: 18, color: "64748B" }, // darker left accent
|
||||
right: { style: BorderStyle.SINGLE, size: 6, color: "CBD5E1" },
|
||||
},
|
||||
children: [
|
||||
...(lang
|
||||
? [
|
||||
new Paragraph({
|
||||
spacing: { before: 0, after: 60 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: lang.toUpperCase(),
|
||||
font: "Consolas, Courier New",
|
||||
size: 15,
|
||||
bold: true,
|
||||
color: "64748B",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...codeParagraphs,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
elements.push(codeBox);
|
||||
elements.push(new Paragraph({ spacing: { before: 80, after: 0 } }));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Markdown Tables (| col1 | col2 |)
|
||||
if (trimmed.startsWith("|") && trimmed.endsWith("|")) {
|
||||
const tableLines: string[] = [];
|
||||
while (i < lines.length && lines[i].trim().startsWith("|")) {
|
||||
tableLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
const table = parseMarkdownTable(tableLines);
|
||||
if (table) {
|
||||
elements.push(table);
|
||||
elements.push(new Paragraph({ spacing: { before: 80, after: 0 } }));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. Blockquotes & GitHub Alert Callouts (> [!NOTE], > [!TIP], etc.)
|
||||
if (trimmed.startsWith(">")) {
|
||||
const quoteLines: string[] = [];
|
||||
while (i < lines.length && lines[i].trim().startsWith(">")) {
|
||||
quoteLines.push(lines[i].trim().replace(/^>\s?/, ""));
|
||||
i++;
|
||||
}
|
||||
|
||||
const fullQuote = quoteLines.join("\n");
|
||||
const alertMatch = fullQuote.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/i);
|
||||
|
||||
let borderColor = "3B82F6"; // Blue
|
||||
let fillColor = "EFF6FF";
|
||||
let title = "NOTE";
|
||||
|
||||
if (alertMatch) {
|
||||
const alertType = alertMatch[1].toUpperCase();
|
||||
if (alertType === "TIP") {
|
||||
borderColor = "10B981";
|
||||
fillColor = "ECFDF5";
|
||||
title = "TIP";
|
||||
} else if (alertType === "IMPORTANT") {
|
||||
borderColor = "8B5CF6";
|
||||
fillColor = "F5F3FF";
|
||||
title = "IMPORTANT";
|
||||
} else if (alertType === "WARNING") {
|
||||
borderColor = "F59E0B";
|
||||
fillColor = "FFFBEB";
|
||||
title = "WARNING";
|
||||
} else if (alertType === "CAUTION") {
|
||||
borderColor = "EF4444";
|
||||
fillColor = "FEF2F2";
|
||||
title = "CAUTION";
|
||||
}
|
||||
}
|
||||
|
||||
const contentLines = alertMatch
|
||||
? fullQuote.replace(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*\n?/i, "").split("\n")
|
||||
: quoteLines;
|
||||
|
||||
const calloutTable = new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
margins: { top: 100, bottom: 100, left: 160, right: 160 },
|
||||
rows: [
|
||||
new TableRow({
|
||||
children: [
|
||||
new TableCell({
|
||||
shading: {
|
||||
type: ShadingType.CLEAR,
|
||||
fill: fillColor,
|
||||
color: "auto",
|
||||
},
|
||||
borders: {
|
||||
top: { style: BorderStyle.NONE, size: 0, color: "auto" },
|
||||
bottom: { style: BorderStyle.NONE, size: 0, color: "auto" },
|
||||
left: { style: BorderStyle.SINGLE, size: 24, color: borderColor },
|
||||
right: { style: BorderStyle.NONE, size: 0, color: "auto" },
|
||||
},
|
||||
children: [
|
||||
...(alertMatch
|
||||
? [
|
||||
new Paragraph({
|
||||
spacing: { before: 0, after: 40 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: `[ ${title} ]`,
|
||||
font: "Inter, Calibri, Arial",
|
||||
size: 18,
|
||||
bold: true,
|
||||
color: borderColor,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...contentLines.map(
|
||||
(qLine) =>
|
||||
new Paragraph({
|
||||
spacing: { before: 20, after: 40 },
|
||||
children: parseInlineMarkdown(qLine),
|
||||
}),
|
||||
),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
elements.push(calloutTable);
|
||||
elements.push(new Paragraph({ spacing: { before: 80, after: 0 } }));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 5. Headings
|
||||
if (trimmed.startsWith("#### ")) {
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
heading: HeadingLevel.HEADING_4,
|
||||
spacing: { before: 180, after: 60 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: trimmed.slice(5),
|
||||
font: "Inter, Calibri, Arial",
|
||||
size: 22, // 11pt
|
||||
bold: true,
|
||||
color: "1E293B",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("### ")) {
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
heading: HeadingLevel.HEADING_3,
|
||||
spacing: { before: 220, after: 80 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: trimmed.slice(4),
|
||||
font: "Inter, Calibri, Arial",
|
||||
size: 24, // 12pt
|
||||
bold: true,
|
||||
color: "0F172A",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("## ")) {
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
spacing: { before: 280, after: 100 },
|
||||
border: {
|
||||
bottom: { style: BorderStyle.SINGLE, size: 6, color: "E2E8F0" },
|
||||
},
|
||||
children: [
|
||||
new TextRun({
|
||||
text: trimmed.slice(3),
|
||||
font: "Inter, Calibri, Arial",
|
||||
size: 28, // 14pt
|
||||
bold: true,
|
||||
color: "020617",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("# ")) {
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
spacing: { before: 320, after: 120 },
|
||||
border: {
|
||||
bottom: { style: BorderStyle.SINGLE, size: 8, color: "CBD5E1" },
|
||||
},
|
||||
children: [
|
||||
new TextRun({
|
||||
text: trimmed.slice(2),
|
||||
font: "Inter, Calibri, Arial",
|
||||
size: 34, // 17pt
|
||||
bold: true,
|
||||
color: "020617",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 6. Horizontal Rule (--- or ***)
|
||||
if (/^(\-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
spacing: { before: 120, after: 120 },
|
||||
border: {
|
||||
bottom: { style: BorderStyle.SINGLE, size: 6, color: "CBD5E1" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 7. Bullet Lists (- item or * item)
|
||||
if (/^[-*+]\s+/.test(trimmed)) {
|
||||
const itemText = trimmed.replace(/^[-*+]\s+/, "");
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
bullet: { level: 0 },
|
||||
spacing: { before: 20, after: 40 },
|
||||
children: parseInlineMarkdown(itemText),
|
||||
}),
|
||||
);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 8. Numbered Lists (1. item)
|
||||
if (/^\d+\.\s+/.test(trimmed)) {
|
||||
const numMatch = trimmed.match(/^(\d+)\.\s+(.*)$/);
|
||||
const num = numMatch ? numMatch[1] : "1";
|
||||
const itemText = numMatch ? numMatch[2] : trimmed;
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
spacing: { before: 20, after: 40 },
|
||||
indent: { left: 360 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: `${num}. `,
|
||||
font: "Inter, Calibri, Arial",
|
||||
size: 20,
|
||||
bold: true,
|
||||
color: "475569",
|
||||
}),
|
||||
...parseInlineMarkdown(itemText),
|
||||
],
|
||||
}),
|
||||
);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 9. Standard Paragraph
|
||||
elements.push(
|
||||
new Paragraph({
|
||||
spacing: { before: 40, after: 100, line: 276 }, // 1.15 line spacing
|
||||
children: parseInlineMarkdown(line),
|
||||
}),
|
||||
);
|
||||
i++;
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOCX Document Builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DocxExportOptions {
|
||||
pageBreaks?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a complete, publication-grade Microsoft Word (.docx) document
|
||||
* from MCPedia ExportData.
|
||||
*/
|
||||
export async function generateExportDocx(
|
||||
exportData: ExportData,
|
||||
options: DocxExportOptions = {},
|
||||
): Promise<Buffer> {
|
||||
const { summary, documents: docs } = exportData;
|
||||
const pageBreaks = options.pageBreaks ?? true;
|
||||
|
||||
const docChildren: (Paragraph | Table)[] = [];
|
||||
|
||||
// ==========================================
|
||||
// 1. Document Cover & Header
|
||||
// ==========================================
|
||||
docChildren.push(
|
||||
new Paragraph({
|
||||
heading: HeadingLevel.TITLE,
|
||||
spacing: { before: 0, after: 120 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: summary.title,
|
||||
font: "Inter, Calibri, Arial",
|
||||
size: 44, // 22pt
|
||||
bold: true,
|
||||
color: "020617",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// Metadata subtitle
|
||||
const metaParts: string[] = [
|
||||
`${summary.totalDocuments} Challenge${summary.totalDocuments !== 1 ? "s" : ""}`,
|
||||
];
|
||||
if (summary.totalPoints > 0) {
|
||||
metaParts.push(`${summary.totalPoints.toLocaleString()} Total Points`);
|
||||
}
|
||||
metaParts.push(
|
||||
new Date(summary.generatedAt).toLocaleDateString(undefined, {
|
||||
dateStyle: "long",
|
||||
}),
|
||||
);
|
||||
if (summary.authors && summary.authors.length > 0) {
|
||||
metaParts.push(`Authors: ${summary.authors.join(", ")}`);
|
||||
}
|
||||
|
||||
docChildren.push(
|
||||
new Paragraph({
|
||||
spacing: { before: 0, after: 240 },
|
||||
border: {
|
||||
bottom: { style: BorderStyle.SINGLE, size: 8, color: "CBD5E1" },
|
||||
},
|
||||
children: [
|
||||
new TextRun({
|
||||
text: metaParts.join(" · "),
|
||||
font: "Consolas, Courier New",
|
||||
size: 18, // 9pt
|
||||
color: "64748B",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// ==========================================
|
||||
// 2. Table of Contents / Matrix (if > 1 doc)
|
||||
// ==========================================
|
||||
if (docs.length > 1) {
|
||||
docChildren.push(
|
||||
new Paragraph({
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
spacing: { before: 200, after: 120 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: "Table of Contents & Overview",
|
||||
font: "Inter, Calibri, Arial",
|
||||
size: 26, // 13pt
|
||||
bold: true,
|
||||
color: "0F172A",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const tocRows: string[][] = [
|
||||
["#", "Challenge Title", "Category", "Points", "Difficulty"],
|
||||
];
|
||||
|
||||
docs.forEach((doc, idx) => {
|
||||
const chNum = String(idx + 1);
|
||||
const cat = extractDocCategory(doc);
|
||||
const pts = extractDocPoints(doc);
|
||||
const diff = extractDocDifficulty(doc) || "-";
|
||||
tocRows.push([
|
||||
chNum,
|
||||
doc.title,
|
||||
cat,
|
||||
pts > 0 ? `${pts} pts` : "-",
|
||||
diff,
|
||||
]);
|
||||
});
|
||||
|
||||
const tocTable = parseMarkdownTable(
|
||||
tocRows.map((r) => `| ${r.join(" | ")} |`),
|
||||
);
|
||||
if (tocTable) {
|
||||
docChildren.push(tocTable);
|
||||
}
|
||||
|
||||
docChildren.push(
|
||||
new Paragraph({
|
||||
spacing: { before: 200, after: 200 },
|
||||
border: {
|
||||
bottom: { style: BorderStyle.SINGLE, size: 6, color: "E2E8F0" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 3. Challenge Writeup Chapters
|
||||
// ==========================================
|
||||
docs.forEach((doc, idx) => {
|
||||
const chNum = idx + 1;
|
||||
const cat = extractDocCategory(doc);
|
||||
const pts = extractDocPoints(doc);
|
||||
const diff = extractDocDifficulty(doc);
|
||||
|
||||
// Page break between challenges
|
||||
if (pageBreaks && idx > 0) {
|
||||
docChildren.push(
|
||||
new Paragraph({
|
||||
children: [new PageBreak()],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Chapter Title
|
||||
docChildren.push(
|
||||
new Paragraph({
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
spacing: { before: pageBreaks ? 0 : 280, after: 80 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: `${chNum}. ${doc.title}`,
|
||||
font: "Inter, Calibri, Arial",
|
||||
size: 32, // 16pt
|
||||
bold: true,
|
||||
color: "020617",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// Metadata Badge Ribbon
|
||||
const badgeTextParts: string[] = [`CATEGORY: ${cat.toUpperCase()}`];
|
||||
if (pts > 0) badgeTextParts.push(`POINTS: ${pts} PTS`);
|
||||
if (diff) badgeTextParts.push(`DIFFICULTY: ${diff.toUpperCase()}`);
|
||||
if (doc.author) badgeTextParts.push(`AUTHOR: ${doc.author}`);
|
||||
|
||||
docChildren.push(
|
||||
new Paragraph({
|
||||
spacing: { before: 0, after: 180 },
|
||||
border: {
|
||||
bottom: { style: BorderStyle.SINGLE, size: 6, color: "CBD5E1" },
|
||||
},
|
||||
children: [
|
||||
new TextRun({
|
||||
text: badgeTextParts.join(" | "),
|
||||
font: "Consolas, Courier New",
|
||||
size: 16, // 8pt
|
||||
bold: true,
|
||||
color: "475569",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// Markdown Writeup Content
|
||||
const contentElements = convertMarkdownToDocx(doc.body);
|
||||
docChildren.push(...contentElements);
|
||||
|
||||
// Subtle divider after challenge (when not page breaking)
|
||||
if (!pageBreaks && idx < docs.length - 1) {
|
||||
docChildren.push(
|
||||
new Paragraph({
|
||||
spacing: { before: 180, after: 180 },
|
||||
border: {
|
||||
bottom: { style: BorderStyle.DASHED, size: 6, color: "CBD5E1" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 4. Construct Final DOCX Package
|
||||
// ==========================================
|
||||
const docx = new DocxDocument({
|
||||
creator: "MCPedia Knowledge Base",
|
||||
title: summary.title,
|
||||
description: `Export of ${summary.title} generated by MCPedia`,
|
||||
sections: [
|
||||
{
|
||||
properties: {
|
||||
page: {
|
||||
margin: {
|
||||
top: 1440, // 1 inch (1440 dxa)
|
||||
bottom: 1440,
|
||||
left: 1440,
|
||||
right: 1440,
|
||||
},
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
default: new Header({
|
||||
children: [
|
||||
new Paragraph({
|
||||
alignment: AlignmentType.RIGHT,
|
||||
spacing: { after: 120 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: `MCPedia · ${summary.title}`,
|
||||
font: "Consolas, Courier New",
|
||||
size: 16,
|
||||
color: "94A3B8",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
footers: {
|
||||
default: new Footer({
|
||||
children: [
|
||||
new Paragraph({
|
||||
alignment: AlignmentType.RIGHT,
|
||||
spacing: { before: 120 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: "Page ",
|
||||
font: "Inter, Calibri, Arial",
|
||||
size: 16,
|
||||
color: "94A3B8",
|
||||
}),
|
||||
new TextRun({
|
||||
children: [PageNumber.CURRENT],
|
||||
font: "Inter, Calibri, Arial",
|
||||
size: 16,
|
||||
color: "94A3B8",
|
||||
}),
|
||||
new TextRun({
|
||||
text: " of ",
|
||||
font: "Inter, Calibri, Arial",
|
||||
size: 16,
|
||||
color: "94A3B8",
|
||||
}),
|
||||
new TextRun({
|
||||
children: [PageNumber.TOTAL_PAGES],
|
||||
font: "Inter, Calibri, Arial",
|
||||
size: 16,
|
||||
color: "94A3B8",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
children: docChildren,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await Packer.toBuffer(docx);
|
||||
}
|
||||
@@ -376,7 +376,7 @@ export function compileExportMarkdown(exportData: ExportData): string {
|
||||
// Cover / Header
|
||||
lines.push(`# ${summary.title}`);
|
||||
lines.push("");
|
||||
lines.push(`*Generated from MCPedia Knowledge Base on ${new Date(summary.generatedAt).toLocaleDateString(undefined, { dateStyle: "long" })}*`);
|
||||
lines.push(`*Date: ${new Date(summary.generatedAt).toLocaleDateString(undefined, { dateStyle: "long" })}*`);
|
||||
lines.push("");
|
||||
|
||||
if (summary.totalDocuments > 1) {
|
||||
@@ -416,8 +416,6 @@ export function compileExportMarkdown(exportData: ExportData): string {
|
||||
if (cat) badges.push(`**Category:** \`${cat}\``);
|
||||
if (pts > 0) badges.push(`**Points:** \`${pts} pts\``);
|
||||
if (diff) badges.push(`**Difficulty:** \`${diff}\``);
|
||||
if (doc.author) badges.push(`**Author:** @${doc.author}`);
|
||||
if (doc.slug) badges.push(`**Slug:** \`${doc.slug}\``);
|
||||
|
||||
if (badges.length > 0) {
|
||||
lines.push(badges.join(" · "));
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "./export.helpers";
|
||||
|
||||
export * from "./export.helpers";
|
||||
export * from "./export.docx";
|
||||
|
||||
/**
|
||||
* Fetch all documents for export with full bodies directly from PostgreSQL
|
||||
@@ -104,8 +105,26 @@ export async function getExportDocuments(
|
||||
return { ...toMeta(row), body: body || "" };
|
||||
});
|
||||
|
||||
// Filter out meta index documents (like _index or index) when exporting multiple docs
|
||||
let filteredDocs = loadedDocs;
|
||||
if (loadedDocs.length > 1) {
|
||||
const withoutIndex = loadedDocs.filter((d) => {
|
||||
const slug = d.slug.toLowerCase();
|
||||
return (
|
||||
!slug.endsWith("/_index") &&
|
||||
!slug.endsWith("/index") &&
|
||||
slug !== "_index" &&
|
||||
slug !== "index" &&
|
||||
d.type !== "writeup-index"
|
||||
);
|
||||
});
|
||||
if (withoutIndex.length > 0) {
|
||||
filteredDocs = withoutIndex;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort neatly
|
||||
const sortedDocs = sortExportDocuments(loadedDocs, sortBy);
|
||||
const sortedDocs = sortExportDocuments(filteredDocs, sortBy);
|
||||
const summary = buildExportSummary(sortedDocs, opts.path, opts.section);
|
||||
|
||||
return {
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@mcpedia/config": "workspace:*",
|
||||
"drizzle-orm": "^0.38.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"postgres": "^3.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"drizzle-kit": "^0.30.0",
|
||||
"@types/node": "^20"
|
||||
"@types/node": "^26",
|
||||
"drizzle-kit": "^0.31.10"
|
||||
},
|
||||
"scripts": {
|
||||
"db:generate": "drizzle-kit generate",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"@mcpedia/db": "workspace:*",
|
||||
"@mcpedia/embeddings": "workspace:*",
|
||||
"@mcpedia/types": "workspace:*",
|
||||
"drizzle-orm": "^0.38.0"
|
||||
"drizzle-orm": "^0.45.2"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"@mcpedia/parser": "workspace:*",
|
||||
"@mcpedia/search": "workspace:*",
|
||||
"@mcpedia/queue": "workspace:*",
|
||||
"drizzle-orm": "^0.38.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"postgres": "^3.4.5"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user