- frontmatter: replace custom YAML parser with gray-matter; add private field (was silently dropped on save) - types: add private? to NoteMeta so the field propagates through NoteContent - use-notes: fix all subtopic traversal bugs (deleteNote, renameNote, toggleDraft, updateActiveNote, loadNotes first-select) using recursive mapTopicNotes/filterTopicTree helpers - api: remove unused moveNote duplicate (moveNoteApi is the correct export) - auth: add setPendingServerUrl/getPendingServerUrl/clearPendingServerUrl for Authentik redirect flow - login: implement Authentik SSO flow — save server URL, redirect to /api/studio/auth/authentik, detect #token= on return; show/hide token vs Authentik sections based on server config - preview: fix useRef type error (missing undefined initializer)
334 lines
12 KiB
TypeScript
334 lines
12 KiB
TypeScript
"use client"
|
|
|
|
import { useMemo, useRef, useEffect, useState } from "react"
|
|
import { Marked, type Tokens } from "marked"
|
|
import katex from "katex"
|
|
import hljs from "highlight.js"
|
|
|
|
/* ── KaTeX extensions ────────────────────────────── */
|
|
|
|
const blockMathExtension = {
|
|
name: "blockMath",
|
|
level: "block" as const,
|
|
start: (src: string) => src.indexOf("$$"),
|
|
tokenizer(src: string) {
|
|
const match = /^\$\$([\s\S]+?)\$\$/.exec(src)
|
|
if (match) return { type: "blockMath", raw: match[0], text: match[1].trim() }
|
|
return undefined
|
|
},
|
|
renderer(token: { text: string }) {
|
|
try {
|
|
return `<div class="math-block">${katex.renderToString(token.text, {
|
|
displayMode: true,
|
|
throwOnError: false,
|
|
output: "html",
|
|
})}</div>\n`
|
|
} catch {
|
|
return `<div class="math-block math-error">$$${token.text}$$</div>\n`
|
|
}
|
|
},
|
|
}
|
|
|
|
const inlineMathExtension = {
|
|
name: "inlineMath",
|
|
level: "inline" as const,
|
|
start: (src: string) => {
|
|
const i = src.indexOf("$")
|
|
return i === -1 ? undefined : i
|
|
},
|
|
tokenizer(src: string) {
|
|
const match = /^\$(?!\$)([^$\n]+?)\$/.exec(src)
|
|
if (match) return { type: "inlineMath", raw: match[0], text: match[1].trim() }
|
|
return undefined
|
|
},
|
|
renderer(token: { text: string }) {
|
|
try {
|
|
return katex.renderToString(token.text, {
|
|
displayMode: false,
|
|
throwOnError: false,
|
|
output: "html",
|
|
})
|
|
} catch {
|
|
return `<span class="math-error">$${token.text}$</span>`
|
|
}
|
|
},
|
|
}
|
|
|
|
/* ── Wikilink extension ──────────────────────────── */
|
|
|
|
const wikilinkExtension = {
|
|
name: "wikilink",
|
|
level: "inline" as const,
|
|
start: (src: string) => src.indexOf("[["),
|
|
tokenizer(src: string) {
|
|
// [[target#fragment|text]] — target and fragment are both optional
|
|
const match = /^\[\[([^\]|#]*?)(?:#([^\]|]*?))?(?:\|([^\]]+?))?\]\]/.exec(src)
|
|
if (!match) return undefined
|
|
const target = match[1].trim()
|
|
const fragment = match[2]?.trim() ?? ""
|
|
const fallback = target + (fragment ? `#${fragment}` : "")
|
|
return {
|
|
type: "wikilink",
|
|
raw: match[0],
|
|
target,
|
|
fragment,
|
|
text: (match[3] ?? fallback).trim(),
|
|
}
|
|
},
|
|
renderer(token: { target: string; fragment: string; text: string }) {
|
|
const escaped = token.text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
const target = token.target.replace(/"/g, """)
|
|
const frag = token.fragment.replace(/"/g, """)
|
|
return `<span class="preview-wikilink" data-wikilink="${target}" data-fragment="${frag}">${escaped}</span>`
|
|
},
|
|
}
|
|
|
|
/* ── Marked instance (isolated, not global) ──────── */
|
|
|
|
const md = new Marked()
|
|
|
|
md.use({
|
|
renderer: {
|
|
code(token: Tokens.Code): string {
|
|
const lang = (token.lang ?? "").split(/\s/)[0]
|
|
if (lang && hljs.getLanguage(lang)) {
|
|
try {
|
|
const highlighted = hljs.highlight(token.text, {
|
|
language: lang,
|
|
ignoreIllegals: true,
|
|
}).value
|
|
return `<pre><code class="hljs language-${lang}">${highlighted}</code></pre>\n`
|
|
} catch { /* fall through */ }
|
|
}
|
|
return `<pre><code class="hljs">${hljs.highlightAuto(token.text).value}</code></pre>\n`
|
|
},
|
|
heading(token: Tokens.Heading): string {
|
|
const raw = token.text.replace(/<[^>]*>/g, "")
|
|
const id = raw.toLowerCase().replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-")
|
|
return `<h${token.depth} id="${id}">${token.text}</h${token.depth}>\n`
|
|
},
|
|
link(token: Tokens.Link): string {
|
|
const href = token.href ?? ""
|
|
const isExt = /^https?:\/\//.test(href)
|
|
const titleAttr = token.title ? ` title="${token.title.replace(/"/g, """)}"` : ""
|
|
const extAttrs = isExt ? ` target="_blank" rel="noopener noreferrer"` : ""
|
|
return `<a href="${href}"${titleAttr}${extAttrs}>${token.text}</a>`
|
|
},
|
|
},
|
|
extensions: [blockMathExtension, inlineMathExtension, wikilinkExtension],
|
|
})
|
|
|
|
/* ── Frontmatter-Parser ────────────────────────────── */
|
|
|
|
interface FmData { title: string; description?: string; status?: string; lang?: string; topic?: string; draft?: boolean }
|
|
|
|
function parseFm(markdown: string): { fm: FmData | null; body: string } {
|
|
if (!markdown.startsWith("---\n")) return { fm: null, body: markdown }
|
|
const end = markdown.indexOf("\n---\n", 4)
|
|
if (end === -1) return { fm: null, body: markdown }
|
|
|
|
const lines = markdown.slice(4, end).split("\n")
|
|
const body = markdown.slice(end + 5)
|
|
const fm: FmData = { title: "" }
|
|
|
|
for (const line of lines) {
|
|
const m = line.match(/^(\w+):\s*(.*)$/)
|
|
if (!m) continue
|
|
const [, key, rawVal] = m
|
|
const val = rawVal.replace(/^["']|["']$/g, "").trim()
|
|
switch (key) {
|
|
case "title": fm.title = val; break
|
|
case "description": fm.description = val; break
|
|
case "status": fm.status = val; break
|
|
case "lang": fm.lang = val; break
|
|
case "topic": fm.topic = val; break
|
|
case "draft": fm.draft = rawVal.trim() === "true"; break
|
|
}
|
|
}
|
|
return { fm: fm.title ? fm : null, body }
|
|
}
|
|
|
|
const STATUS_COLOR: Record<string, string> = {
|
|
seed: "var(--seed)",
|
|
budding: "var(--budding)",
|
|
evergreen: "var(--evergreen)",
|
|
}
|
|
|
|
/* ── Component ────────────────────────────────────── */
|
|
|
|
interface NoteRef { slug: string; title: string }
|
|
|
|
interface Props {
|
|
content: string
|
|
className?: string
|
|
notes?: NoteRef[]
|
|
onSelectNote?: (slug: string) => void
|
|
}
|
|
|
|
export default function Preview({ content, className, notes, onSelectNote }: Props) {
|
|
const { fm, body } = useMemo(() => parseFm(content), [content])
|
|
|
|
const html = useMemo(() => {
|
|
try {
|
|
const result = md.parse(body)
|
|
return typeof result === "string" ? result : ""
|
|
} catch {
|
|
return ""
|
|
}
|
|
}, [body])
|
|
|
|
// Refs so the event listener never goes stale
|
|
const proseRef = useRef<HTMLDivElement>(null)
|
|
const notesRef = useRef<NoteRef[]>(notes ?? [])
|
|
const selectRef = useRef(onSelectNote)
|
|
const pendingFragmentRef = useRef<string | null>(null)
|
|
useEffect(() => { notesRef.current = notes ?? [] }, [notes])
|
|
useEffect(() => { selectRef.current = onSelectNote }, [onSelectNote])
|
|
|
|
// After HTML re-renders, scroll to any pending fragment (cross-note navigation)
|
|
useEffect(() => {
|
|
if (!pendingFragmentRef.current || !proseRef.current) return
|
|
const frag = pendingFragmentRef.current
|
|
pendingFragmentRef.current = null
|
|
const target = proseRef.current.querySelector(`#${CSS.escape(frag)}`)
|
|
if (target) setTimeout(() => target.scrollIntoView({ behavior: "smooth", block: "start" }), 50)
|
|
}, [html])
|
|
|
|
// Toast for unresolvable wikilinks
|
|
const [toast, setToast] = useState<string | null>(null)
|
|
const toastTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
const showToast = (msg: string) => {
|
|
setToast(msg)
|
|
clearTimeout(toastTimer.current)
|
|
toastTimer.current = setTimeout(() => setToast(null), 4000)
|
|
}
|
|
|
|
// Mark broken wikilinks visually after render
|
|
useEffect(() => {
|
|
const el = proseRef.current
|
|
if (!el) return
|
|
el.querySelectorAll<HTMLElement>("[data-wikilink]").forEach(span => {
|
|
const raw = span.dataset.wikilink ?? ""
|
|
const lower = raw.toLowerCase().trim()
|
|
const slug = raw.toLowerCase().normalize("NFD").replace(/\p{M}/gu, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
|
|
const list = notesRef.current
|
|
const found = !!(
|
|
list.find(n => n.title.toLowerCase().trim() === lower) ??
|
|
list.find(n => n.slug.toLowerCase() === lower) ??
|
|
list.find(n => (n.slug.split("/").pop() ?? "") === slug)
|
|
)
|
|
span.dataset.broken = found ? "false" : "true"
|
|
})
|
|
}, [html, notes])
|
|
|
|
useEffect(() => {
|
|
const el = proseRef.current
|
|
if (!el) return
|
|
|
|
const handleClick = (e: MouseEvent) => {
|
|
const t = e.target as HTMLElement
|
|
|
|
const wikiSpan = t.closest<HTMLElement>("[data-wikilink]")
|
|
if (wikiSpan) {
|
|
const raw = wikiSpan.dataset.wikilink ?? ""
|
|
const fragment = wikiSpan.dataset.fragment ?? ""
|
|
|
|
// In-page anchor: [[#abschnitt]]
|
|
if (!raw && fragment) {
|
|
const anchor = proseRef.current?.querySelector(`#${CSS.escape(fragment)}`)
|
|
if (anchor) anchor.scrollIntoView({ behavior: "smooth", block: "start" })
|
|
else showToast(`„#${fragment}" — Abschnitt nicht gefunden`)
|
|
return
|
|
}
|
|
|
|
const lower = raw.toLowerCase().trim()
|
|
const slug = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
|
|
const list = notesRef.current
|
|
const note =
|
|
list.find(n => n.title.toLowerCase().trim() === lower) ??
|
|
list.find(n => n.slug.toLowerCase() === lower) ??
|
|
list.find(n => (n.slug.split("/").pop() ?? "") === slug) ??
|
|
list.find(n => (n.slug.split("/").pop() ?? "").includes(slug.slice(0, 20)))
|
|
if (note) {
|
|
if (fragment) pendingFragmentRef.current = fragment
|
|
selectRef.current?.(note.slug)
|
|
} else {
|
|
showToast(`„${raw.length > 48 ? raw.slice(0, 48) + "…" : raw}" — Notiz nicht gefunden`)
|
|
}
|
|
return
|
|
}
|
|
|
|
const anchor = t.closest<HTMLAnchorElement>("a[href]")
|
|
if (anchor && /^https?:\/\//.test(anchor.href)) {
|
|
e.preventDefault()
|
|
window.open(anchor.href, "_blank", "noopener,noreferrer")
|
|
}
|
|
}
|
|
|
|
el.addEventListener("click", handleClick)
|
|
return () => el.removeEventListener("click", handleClick)
|
|
}, [])
|
|
|
|
return (
|
|
<div className={`relative overflow-auto ${className ?? ""}`}>
|
|
{toast && (
|
|
<div className="preview-toast">
|
|
{toast}
|
|
</div>
|
|
)}
|
|
<div className="px-7 py-10">
|
|
|
|
{/* Frontmatter-Header */}
|
|
{fm && (
|
|
<div className="mb-8 pb-6 border-b border-border/40">
|
|
{fm.topic && (
|
|
<div className="text-[10px] font-mono text-muted-fg/40 uppercase tracking-widest mb-2">
|
|
{fm.topic}
|
|
</div>
|
|
)}
|
|
<h1
|
|
className="text-2xl font-semibold mb-2 leading-snug"
|
|
style={{ fontFamily: "var(--font-newsreader, Georgia, serif)", fontStyle: "italic" }}
|
|
>
|
|
{fm.title}
|
|
</h1>
|
|
{fm.description && (
|
|
<p className="text-sm text-muted-fg mb-3 leading-relaxed">{fm.description}</p>
|
|
)}
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{fm.status && (
|
|
<span
|
|
className="inline-flex items-center gap-1.5 text-[10px] font-mono px-2 py-0.5 rounded-full border"
|
|
style={{
|
|
borderColor: STATUS_COLOR[fm.status] ?? "var(--border)",
|
|
color: STATUS_COLOR[fm.status] ?? "var(--muted-fg)",
|
|
background: `oklch(from ${STATUS_COLOR[fm.status] ?? "var(--muted-fg)"} l c h / 0.08)`,
|
|
}}
|
|
>
|
|
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: STATUS_COLOR[fm.status] ?? "var(--muted-fg)" }} />
|
|
{fm.status}
|
|
</span>
|
|
)}
|
|
{fm.draft && (
|
|
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full border" style={{ borderColor: "var(--draft)", color: "var(--draft)", background: "oklch(from var(--draft) l c h / 0.08)" }}>
|
|
in review
|
|
</span>
|
|
)}
|
|
{fm.lang && (
|
|
<span className="text-[10px] font-mono text-muted-fg/40 border border-border/40 px-2 py-0.5 rounded-full">
|
|
{fm.lang.toUpperCase()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
ref={proseRef}
|
|
className="preview-prose"
|
|
dangerouslySetInnerHTML={{ __html: html }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|