Files
oikos/web/src/lib/components/knowledge/WikiQuickOpen.svelte
dtoro 89312a9ce4
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
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>
2026-07-27 22:50:54 +02:00

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>