oikos-web: extract the client stack from dtoro/oikos
Phase 1 of the hexagonal-architecture plan (dtoro/oikos plans/2026-08-15-hexagonal-architecture.md). Moves the delivery stack for the control-room UI into its own repo with its own pipeline: - web/ — Svelte 5 SPA, verbatim (vendor/ included) - desktop/ — Wails v3 wrapper, updateURL repointed to dtoro/oikos-web releases - compose/ — Dockerfile + Caddyfile, verbatim (the /wails/* 404 and asset no-fallback quirks are load-bearing) - docker-compose.yml — single web service, same 8091:80 publish, mem/cpu limits, and restart policy as the oikos stack's web service - scripts/deploy.sh — mirrors oikos deploy essentials: CI-green gate, TOCTOU guard, version-tagged oikos-web:v$VERSION, prune to 3 - cmd/webhook + scripts/install-webhook.sh — standalone push-to-deploy receiver on :9798 (env-only secrets, no Infisical dependency) - CI: the web job from oikos's ci.yml + the desktop build/release workflow, path-adjusted Own VERSION (0.33.0) with the same bump-on-main rule; starts above oikos's 0.32.x so the desktop updater sees an upgrade.
This commit is contained in:
124
web/src/lib/components/knowledge/WikiContextRail.svelte
Normal file
124
web/src/lib/components/knowledge/WikiContextRail.svelte
Normal file
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
// Right pane: the discovery half of the wiki. From any note you can walk
|
||||
// to the entity it's about, and from there sideways to every other note
|
||||
// that concerns the same entity or shares a tag — this is what makes the
|
||||
// knowledge base a graph to browse rather than a flat list to scroll.
|
||||
//
|
||||
// "Related" and "tag neighbours" are derived client-side from the list
|
||||
// already loaded by Knowledge.svelte (KnowledgeListItem carries `about`
|
||||
// and `tags`), not a separate endpoint — with ~100 notes total, filtering
|
||||
// an in-memory array is cheaper and simpler than a bespoke backlinks
|
||||
// query, and it's exactly the same data WikiTree's group-by-entity/tag
|
||||
// modes already use.
|
||||
import type { KnowledgeListItem } from '$lib/api'
|
||||
import { openEntityWindow } from '$lib/stores/windows'
|
||||
import DetailSection from '$lib/components/DetailSection.svelte'
|
||||
import { kindMeta } from './kinds'
|
||||
import LinkIcon from '@lucide/svelte/icons/link'
|
||||
|
||||
let {
|
||||
item,
|
||||
allItems,
|
||||
onSelect
|
||||
}: {
|
||||
item: KnowledgeListItem | null
|
||||
allItems: KnowledgeListItem[]
|
||||
onSelect: (slug: string) => void
|
||||
} = $props()
|
||||
|
||||
const related = $derived.by(() => {
|
||||
if (!item || item.about.length === 0) return []
|
||||
const aboutSet = new Set(item.about)
|
||||
return allItems
|
||||
.filter((it) => it.slug !== item.slug && it.about.some((s) => aboutSet.has(s)))
|
||||
.sort((a, b) => b.updated_at.localeCompare(a.updated_at))
|
||||
})
|
||||
|
||||
const tagNeighbours = $derived.by(() => {
|
||||
if (!item || item.tags.length === 0) return []
|
||||
const tagSet = new Set(item.tags)
|
||||
return allItems
|
||||
.filter((it) => it.slug !== item.slug && it.tags.some((t) => tagSet.has(t)))
|
||||
.sort((a, b) => b.updated_at.localeCompare(a.updated_at))
|
||||
.slice(0, 20) // common tags (e.g. "backup") can otherwise pull in most of the KB
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col gap-2 overflow-y-auto pr-1">
|
||||
{#if !item}
|
||||
<p class="py-8 text-center text-xs text-muted-foreground">Nothing selected.</p>
|
||||
{:else}
|
||||
<DetailSection title="About" count={item.about.length} defaultOpen={true}>
|
||||
{#if item.about.length === 0}
|
||||
<p class="text-xs text-muted-foreground">Not linked to any entity.</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each item.about as slug (slug)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded px-1 py-0.5 text-left font-mono text-xs text-muted-foreground hover:bg-muted/50 hover:text-foreground"
|
||||
onclick={() => openEntityWindow(slug)}
|
||||
>
|
||||
<LinkIcon class="size-3 shrink-0" />
|
||||
<span class="truncate">{slug}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection
|
||||
title="Also about these entities"
|
||||
count={related.length}
|
||||
defaultOpen={related.length > 0}
|
||||
>
|
||||
{#if related.length === 0}
|
||||
<p class="text-xs text-muted-foreground">No other notes share a linked entity.</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each related as it (it.slug)}
|
||||
{@const Icon = kindMeta(it.kind).icon}
|
||||
<button
|
||||
type="button"
|
||||
class="group flex items-center gap-1.5 rounded px-1 py-1 text-left text-xs hover:bg-muted/50"
|
||||
onclick={() => onSelect(it.slug)}
|
||||
title={it.title}
|
||||
>
|
||||
<Icon class="size-3 shrink-0 text-muted-foreground/70" />
|
||||
<span class="min-w-0 flex-1 truncate group-hover:text-primary">{it.title}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</DetailSection>
|
||||
|
||||
<!-- Only auto-open a *tight* neighbour set. A generic tag like "container"
|
||||
is on 20 notes, and expanding all of those by default buries the
|
||||
stronger entity-based links above it under a wall of weak matches;
|
||||
a handful of shared-tag notes is a real cluster worth surfacing. -->
|
||||
<DetailSection
|
||||
title="Tag neighbours"
|
||||
count={tagNeighbours.length}
|
||||
defaultOpen={related.length === 0 && tagNeighbours.length > 0 && tagNeighbours.length <= 6}
|
||||
>
|
||||
{#if tagNeighbours.length === 0}
|
||||
<p class="text-xs text-muted-foreground">No other notes share a tag.</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each tagNeighbours as it (it.slug)}
|
||||
{@const Icon = kindMeta(it.kind).icon}
|
||||
<button
|
||||
type="button"
|
||||
class="group flex items-center gap-1.5 rounded px-1 py-1 text-left text-xs hover:bg-muted/50"
|
||||
onclick={() => onSelect(it.slug)}
|
||||
title={it.title}
|
||||
>
|
||||
<Icon class="size-3 shrink-0 text-muted-foreground/70" />
|
||||
<span class="min-w-0 flex-1 truncate group-hover:text-primary">{it.title}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</DetailSection>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user