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>
120 lines
4.2 KiB
Svelte
120 lines
4.2 KiB
Svelte
<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>
|