noz-studio/src/components/preview/preview.tsx
Bruno Deanoz 177f3d5ab9 Fix bugs and implement Authentik SSO login
- 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)
2026-05-19 09:17:26 +02:00

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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
const target = token.target.replace(/"/g, "&quot;")
const frag = token.fragment.replace(/"/g, "&quot;")
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, "&quot;")}"` : ""
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>
)
}