284 lines
13 KiB
TypeScript
284 lines
13 KiB
TypeScript
import { useEffect, useMemo, useState } from "react"
|
|
import { Library, FileText, Sparkles, Loader2, FolderOpen, Search } from "lucide-react"
|
|
import { api, type WissenDatei, type WissenFile } from "@/lib/api"
|
|
import { useWissen, useMemoryGraph } from "@/lib/queries"
|
|
import { GraphView } from "./GraphView"
|
|
import { GraphErrorBoundary } from "./GraphErrorBoundary"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
// Wissens-Vault: die nächtlichen Traum-Notizen der Box als klickbares Wiki.
|
|
// [[wiki-links]] springen zur verlinkten Notiz; Markdown wird leichtgewichtig gerendert
|
|
// (bewusst ohne externe Markdown-Lib — Überschriften, Listen, fett/kursiv, Code reichen hier).
|
|
|
|
const DIR_LABEL: Record<string, string> = {
|
|
"": "Allgemein",
|
|
traeume: "Träume",
|
|
muster: "Muster",
|
|
"skill-kandidaten": "Skill-Kandidaten",
|
|
"skill-kandidaten/beauftragt": "Skill-Kandidaten · beauftragt",
|
|
"skill-kandidaten/verworfen": "Skill-Kandidaten · verworfen",
|
|
}
|
|
|
|
export function WissenView() {
|
|
const { data, isLoading } = useWissen()
|
|
const [active, setActive] = useState<string | null>(null)
|
|
const [doc, setDoc] = useState<WissenDatei | null>(null)
|
|
const [docLoading, setDocLoading] = useState(false)
|
|
const [q, setQ] = useState("")
|
|
const [showGraph, setShowGraph] = useState(false)
|
|
|
|
const { data: graphData, isLoading: graphLoading } = useMemoryGraph(showGraph)
|
|
|
|
const files = useMemo(() => data?.files ?? [], [data])
|
|
|
|
// Name → Pfad für [[wiki-links]] (Dateiname ohne .md, case-insensitive).
|
|
const byName = useMemo(() => {
|
|
const m = new Map<string, string>()
|
|
for (const f of files) m.set(f.name.toLowerCase(), f.path)
|
|
return m
|
|
}, [files])
|
|
|
|
const filtered = useMemo(() => {
|
|
if (!q.trim()) return files
|
|
const ql = q.toLowerCase()
|
|
return files.filter((f) => f.title.toLowerCase().includes(ql) || f.path.toLowerCase().includes(ql))
|
|
}, [files, q])
|
|
|
|
const groups = useMemo(() => {
|
|
const g: { dir: string; files: WissenFile[] }[] = []
|
|
for (const f of filtered) {
|
|
const found = g.find((x) => x.dir === f.dir)
|
|
if (found) found.files.push(f)
|
|
else g.push({ dir: f.dir, files: [f] })
|
|
}
|
|
// INDEX zuerst, dann Träume, dann Rest alphabetisch
|
|
return g.sort((a, b) => (a.dir === "" ? -1 : b.dir === "" ? 1 : a.dir.localeCompare(b.dir)))
|
|
}, [filtered])
|
|
|
|
// Beim ersten Laden: INDEX.md öffnen, falls vorhanden.
|
|
useEffect(() => {
|
|
if (!active && files.length) {
|
|
const index = files.find((f) => f.path.toLowerCase() === "index.md")
|
|
setActive(index?.path ?? files[0].path)
|
|
}
|
|
}, [files, active])
|
|
|
|
useEffect(() => {
|
|
if (!active) return
|
|
setDocLoading(true)
|
|
api<WissenDatei>(`/api/wissen/datei?pfad=${encodeURIComponent(active)}`)
|
|
.then(setDoc)
|
|
.catch(() => setDoc({ path: active, content: "*(Notiz nicht ladbar)*", mtime: 0 }))
|
|
.finally(() => setDocLoading(false))
|
|
}, [active])
|
|
|
|
const openWikiLink = (name: string) => {
|
|
const path = byName.get(name.toLowerCase())
|
|
if (path) setActive(path)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent">
|
|
Wissen
|
|
</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Was sich die Box nachts erschließt — Traum-Notizen, Muster und Skill-Ideen aus dem Wissens-Vault, verlinkt wie ein Wiki.
|
|
</p>
|
|
</div>
|
|
{files.length > 0 && (
|
|
<div className="flex rounded-lg border border-border/60 bg-card p-1">
|
|
<button
|
|
onClick={() => setShowGraph(false)}
|
|
className={cn("rounded-md px-3 py-1.5 text-xs font-medium transition-colors", !showGraph ? "bg-primary text-primary-foreground shadow" : "text-muted-foreground hover:bg-muted")}
|
|
>
|
|
Liste
|
|
</button>
|
|
<button
|
|
onClick={() => setShowGraph(true)}
|
|
className={cn("rounded-md px-3 py-1.5 text-xs font-medium transition-colors", showGraph ? "bg-primary text-primary-foreground shadow" : "text-muted-foreground hover:bg-muted")}
|
|
>
|
|
Graph
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{data && !data.available && (
|
|
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/5 p-4 text-xs text-amber-300">
|
|
Der Wissens-Vault liegt auf der Box (~/wissens-vault) — im lokalen Dev-Modus nicht verfügbar.
|
|
</div>
|
|
)}
|
|
|
|
{isLoading && (
|
|
<div className="flex items-center gap-2 rounded-2xl border border-border/60 bg-card/30 p-6 text-xs text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" /> Vault wird geladen …
|
|
</div>
|
|
)}
|
|
|
|
{data?.available && files.length === 0 && (
|
|
<div className="rounded-2xl border border-dashed border-border/60 bg-card/20 p-8 text-center text-xs text-muted-foreground">
|
|
Noch keine Notizen. Der nächtliche Traum (03:15) legt sie an — morgen früh steht hier die erste.
|
|
</div>
|
|
)}
|
|
|
|
{showGraph ? (
|
|
<div className="mc-card flex h-[70vh] flex-col overflow-hidden p-0 relative">
|
|
{graphLoading && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-background/50 z-10">
|
|
<div className="flex items-center gap-2 rounded-2xl border border-border/60 bg-card p-6 text-xs text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" /> Graph wird berechnet …
|
|
</div>
|
|
</div>
|
|
)}
|
|
<GraphErrorBoundary>
|
|
{graphData && (
|
|
<GraphView
|
|
data={graphData}
|
|
selectedNodeId={active}
|
|
onNodeSelect={(id) => {
|
|
if (id) {
|
|
setActive(id)
|
|
setShowGraph(false)
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
</GraphErrorBoundary>
|
|
</div>
|
|
) : files.length > 0 && (
|
|
<div className="flex flex-col gap-4 lg:flex-row">
|
|
{/* Datei-Liste */}
|
|
<aside className="w-full shrink-0 space-y-3 lg:w-72">
|
|
<div className="relative">
|
|
<input value={q} onChange={(e) => setQ(e.target.value)} type="search"
|
|
placeholder="Notizen durchsuchen…" aria-label="Notizen durchsuchen"
|
|
className="h-9 w-full rounded-lg border border-border/60 bg-card/45 pl-8 pr-3 text-xs text-foreground outline-none focus:ring-1 focus:ring-primary/50" />
|
|
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
|
</div>
|
|
<div className="max-h-[70vh] space-y-3 overflow-y-auto pr-1 scrollbar-thin">
|
|
{groups.map(({ dir, files: gf }) => (
|
|
<div key={dir || "__root"}>
|
|
<p className="mb-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60">
|
|
<FolderOpen className="h-3 w-3" /> {DIR_LABEL[dir] ?? dir}
|
|
</p>
|
|
<div className="space-y-0.5">
|
|
{gf.map((f) => (
|
|
<button key={f.path} onClick={() => setActive(f.path)}
|
|
className={cn("flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left text-xs transition-all cursor-pointer",
|
|
active === f.path ? "bg-primary/15 text-primary" : "text-muted-foreground hover:bg-accent hover:text-foreground")}>
|
|
<FileText className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="truncate" title={f.title}>{f.name}</span>
|
|
{f.neu && <span className="ml-auto flex items-center gap-0.5 rounded bg-violet-500/15 px-1 py-0.5 text-[8px] font-bold uppercase text-violet-300"><Sparkles className="h-2.5 w-2.5" />neu</span>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Inhalt */}
|
|
<div className="min-w-0 flex-1 mc-card p-5">
|
|
{docLoading ? (
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" /> Notiz wird geladen …</div>
|
|
) : doc ? (
|
|
<>
|
|
<div className="mb-3 flex items-center justify-between border-b border-border/40 pb-2">
|
|
<span className="flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground"><Library className="h-3.5 w-3.5" /> {doc.path}</span>
|
|
{doc.mtime > 0 && (
|
|
<span className="text-[10px] text-muted-foreground/60">
|
|
Stand {new Date(doc.mtime * 1000).toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<Markdown text={doc.content} onWikiLink={openWikiLink} known={byName} />
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Leichtgewichtiger Markdown-Renderer (Zeilen-basiert, XSS-frei da reines JSX) ──
|
|
function Markdown({ text, onWikiLink, known }: {
|
|
text: string
|
|
onWikiLink: (name: string) => void
|
|
known: Map<string, string>
|
|
}) {
|
|
const lines = text.split("\n")
|
|
const out: React.ReactNode[] = []
|
|
let list: React.ReactNode[] = []
|
|
let code: string[] | null = null
|
|
|
|
const flushList = (key: string) => {
|
|
if (list.length) {
|
|
out.push(<ul key={key} className="mb-3 ml-4 list-disc space-y-1">{list}</ul>)
|
|
list = []
|
|
}
|
|
}
|
|
|
|
lines.forEach((raw, i) => {
|
|
const key = `l${i}`
|
|
if (code !== null) {
|
|
if (raw.trimEnd() === "```") {
|
|
out.push(<pre key={key} className="mb-3 overflow-x-auto rounded-xl border border-border/50 bg-background/50 p-3 font-mono text-[11px] leading-relaxed text-foreground/85">{code.join("\n")}</pre>)
|
|
code = null
|
|
} else code.push(raw)
|
|
return
|
|
}
|
|
if (raw.trimStart().startsWith("```")) { flushList(key); code = []; return }
|
|
|
|
const line = raw.trimEnd()
|
|
if (!line.trim()) { flushList(key); return }
|
|
|
|
const h = line.match(/^(#{1,4})\s+(.*)$/)
|
|
if (h) {
|
|
flushList(key)
|
|
const level = h[1].length
|
|
const cls = level === 1 ? "text-lg font-bold mt-1 mb-3" : level === 2 ? "text-base font-bold mt-4 mb-2" : "text-sm font-bold mt-3 mb-1.5"
|
|
out.push(<p key={key} className={cn(cls, "font-space text-foreground")}>{inline(h[2], onWikiLink, known, key)}</p>)
|
|
return
|
|
}
|
|
const li = line.match(/^\s*[-*•]\s+(.*)$/)
|
|
if (li) {
|
|
list.push(<li key={key} className="text-xs leading-relaxed text-foreground/85">{inline(li[1], onWikiLink, known, key)}</li>)
|
|
return
|
|
}
|
|
flushList(key)
|
|
out.push(<p key={key} className="mb-2 text-xs leading-relaxed text-foreground/85">{inline(line, onWikiLink, known, key)}</p>)
|
|
})
|
|
flushList("end")
|
|
if (code !== null) out.push(<pre key="code-end" className="mb-3 overflow-x-auto rounded-xl border border-border/50 bg-background/50 p-3 font-mono text-[11px]">{(code as string[]).join("\n")}</pre>)
|
|
|
|
return <div className="max-w-3xl">{out}</div>
|
|
}
|
|
|
|
// Inline: [[wiki-links]], **fett**, *kursiv*, `code` — per Regex-Split, reines JSX (kein HTML-Inject).
|
|
function inline(text: string, onWikiLink: (n: string) => void, known: Map<string, string>, keyBase: string): React.ReactNode[] {
|
|
const parts = text.split(/(\[\[[^\]]+\]\]|\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g)
|
|
return parts.map((p, i) => {
|
|
const key = `${keyBase}-${i}`
|
|
const wiki = p.match(/^\[\[([^\]|]+)(?:\|([^\]]+))?\]\]$/)
|
|
if (wiki) {
|
|
const target = wiki[1].trim()
|
|
const label = (wiki[2] ?? wiki[1]).trim()
|
|
const exists = known.has(target.toLowerCase())
|
|
return exists ? (
|
|
<button key={key} onClick={() => onWikiLink(target)}
|
|
className="rounded bg-primary/10 px-1 text-primary underline decoration-primary/40 underline-offset-2 hover:bg-primary/20 cursor-pointer">{label}</button>
|
|
) : (
|
|
<span key={key} className="rounded bg-background/40 px-1 text-muted-foreground" title="Notiz existiert (noch) nicht">{label}</span>
|
|
)
|
|
}
|
|
if (p.startsWith("**") && p.endsWith("**")) return <b key={key} className="font-semibold text-foreground">{p.slice(2, -2)}</b>
|
|
if (p.startsWith("*") && p.endsWith("*") && p.length > 2) return <i key={key}>{p.slice(1, -1)}</i>
|
|
if (p.startsWith("`") && p.endsWith("`")) return <code key={key} className="rounded bg-background/50 px-1 font-mono text-[11px] text-teal-300">{p.slice(1, -1)}</code>
|
|
return <span key={key}>{p}</span>
|
|
})
|
|
}
|