feat(web): redesign Knowledge as an editable wiki
Replaces the read-only stats dashboard with a three-pane wiki: a navigator tree (group by folder/type/tag/entity), a reader/editor with bare-slug auto-linking and revision history + diff, and a context rail for backlinks and related notes. Adds a Cleanup mode for the drift tools (duplicates, tag manager, orphans, trash) and a Cmd+K quick-open. Also: - Adds a real landing view (hero count, KPI row, Nomos-share meter, recently-updated, busiest tags) in place of the old "Select a note" empty state, and extends the design pass across the tree/reader/rail (kind icons instead of repeated text badges, accent-bar selection, constrained prose measure). - Guards every note-selection path behind a confirm when there's an unsaved edit in progress, so switching notes can no longer silently discard a draft. - Extracts the markdown-rendering CSS duplicated across ChatThread, EntityDetailContent, and the new WikiReader into a shared .markdown-body class in app.css, with ChatThread keeping only its decorative deltas. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
119
web/src/lib/components/knowledge/WikiQuickOpen.svelte
Normal file
119
web/src/lib/components/knowledge/WikiQuickOpen.svelte
Normal file
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
// Cmd/Ctrl+K quick-open over every note title — the fast path once you
|
||||
// already know roughly what you're looking for, as opposed to WikiTree's
|
||||
// browse-by-group path for when you don't. Built on the Dialog primitive
|
||||
// + a plain filtered list rather than shadcn-svelte's `command` component:
|
||||
// that component's interactive CLI installer couldn't be driven
|
||||
// non-interactively in this environment (it prompts to resolve overlapping
|
||||
// dependency files), and re-deriving the same arrow-key/Enter list nav by
|
||||
// hand here is a small amount of code for something this self-contained.
|
||||
import type { KnowledgeListItem } from '$lib/api'
|
||||
import * as Dialog from '$lib/components/ui/dialog'
|
||||
import { Input } from '$lib/components/ui/input'
|
||||
import StatusBadge from '$lib/components/StatusBadge.svelte'
|
||||
import { onDestroy } from 'svelte'
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
items,
|
||||
onSelect
|
||||
}: {
|
||||
open: boolean
|
||||
items: KnowledgeListItem[]
|
||||
onSelect: (slug: string) => void
|
||||
} = $props()
|
||||
|
||||
let query = $state('')
|
||||
let activeIndex = $state(0)
|
||||
let inputEl = $state<HTMLInputElement | null>(null)
|
||||
|
||||
const results = $derived.by(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const pool = q
|
||||
? items.filter(
|
||||
(it) =>
|
||||
it.title.toLowerCase().includes(q) ||
|
||||
it.slug.toLowerCase().includes(q) ||
|
||||
it.tags.some((t) => t.toLowerCase().includes(q))
|
||||
)
|
||||
: items
|
||||
return pool.slice(0, 30) // 102 notes total — cap the render, not the match
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
void results // dependency only — re-run when the result set changes
|
||||
activeIndex = 0
|
||||
})
|
||||
|
||||
// Reset on every open so quick-open never remembers the last search, and
|
||||
// focus the input once the dialog has actually mounted it.
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
query = ''
|
||||
queueMicrotask(() => inputEl?.focus())
|
||||
}
|
||||
})
|
||||
|
||||
function choose(slug: string): void {
|
||||
onSelect(slug)
|
||||
open = false
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent): void {
|
||||
if (!open) return
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
activeIndex = Math.min(activeIndex + 1, results.length - 1)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
activeIndex = Math.max(activeIndex - 1, 0)
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const hit = results[activeIndex]
|
||||
if (hit) choose(hit.slug)
|
||||
}
|
||||
}
|
||||
|
||||
// A window-level listener rather than one on Dialog.Content: bits-ui's
|
||||
// Dialog renders its content through a portal with its own focus-trap
|
||||
// wiring, and an onkeydown prop passed straight through to Content did not
|
||||
// reliably receive ArrowDown/Enter in testing (focus landing inside the
|
||||
// trap didn't guarantee the event reached the element this component
|
||||
// attached the listener to). Capturing at the window and gating on `open`
|
||||
// sidesteps that entirely — Escape-to-close is still bits-ui's own
|
||||
// behavior, this only adds the list-navigation keys.
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
onDestroy(() => window.removeEventListener('keydown', handleKeydown))
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content
|
||||
class="top-[20%] max-w-lg -translate-y-0 gap-0 p-0 sm:max-w-lg"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<Input
|
||||
bind:ref={inputEl}
|
||||
bind:value={query}
|
||||
placeholder="Jump to a note…"
|
||||
class="h-11 rounded-b-none border-0 border-b px-3 text-sm focus-visible:ring-0"
|
||||
/>
|
||||
<div class="max-h-80 overflow-y-auto p-1">
|
||||
{#each results as it, i (it.slug)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm {i ===
|
||||
activeIndex
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'hover:bg-muted/50'}"
|
||||
onclick={() => choose(it.slug)}
|
||||
onmouseenter={() => (activeIndex = i)}
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{it.title}</span>
|
||||
<StatusBadge kind="type" value={it.kind} class="shrink-0 text-[9px]" />
|
||||
</button>
|
||||
{:else}
|
||||
<p class="py-6 text-center text-xs text-muted-foreground">No notes match "{query}".</p>
|
||||
{/each}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
Reference in New Issue
Block a user