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.
125 lines
4.9 KiB
Svelte
125 lines
4.9 KiB
Svelte
<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>
|