plant: private Cargill Gouda tracker (/trackers/cargill)
All checks were successful
Deploy production / deploy (push) Successful in 1m19s

Owner-only tracker for learning the Cargill Bioindustrial plant in Gouda:
schema plant (entries, links with confidence, pending refs), plant.apply_entries
bulk import, overview with clusters and flow diagram, kind tabs, glossary,
questions, detail pages and owner inline editing. Docs in docs/plant-tracker.md.
This commit is contained in:
2026-10-26 13:42:37 +00:00
parent fda21554cc
commit e24ee7f95d
26 changed files with 48481 additions and 3 deletions

View File

@@ -0,0 +1,11 @@
import { requireOwner } from '#lib/server/owner.js';
import { loadPlant } from '#lib/server/plant.js';
import type { LayoutServerLoad } from './$types';
// /trackers/cargill: owner-only notes on what the Cargill Bioindustrial plant in Gouda does.
// Everyone else gets a 404 (here and in hooks.server.ts via ownerOnlyPrefixes); hooks.server.ts
// also sends cache-control: private, no-store on owner-only paths.
export const load: LayoutServerLoad = async (event) => {
requireOwner(event);
return { ...(await loadPlant()), now: Date.now() };
};

View File

@@ -0,0 +1,394 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import PrivateLock from '#lib/components/PrivateLock.svelte';
import KindIcon from '#lib/plant/KindIcon.svelte';
import {
KIND_LABEL,
KIND_TAB,
PLANT_KINDS,
kindFromSegment,
kindFromTab,
tabHref,
type PlantKind
} from '#lib/plant.js';
import Factory from 'lucide-svelte/icons/factory';
import Search from 'lucide-svelte/icons/search';
import X from 'lucide-svelte/icons/x';
let { data, children } = $props();
const counts = $derived.by(() => {
const c = Object.fromEntries(PLANT_KINDS.map((k) => [k, 0])) as Record<PlantKind, number>;
for (const e of data.entries) c[e.kind]++;
return c;
});
// Roles only show up once there is one (they're the least used kind).
const tabs = $derived(PLANT_KINDS.filter((k) => k !== 'person_role' || counts[k] > 0));
const active = $derived<PlantKind | 'overview'>(
page.params.kind
? (kindFromSegment(page.params.kind) ?? 'overview')
: (kindFromTab(page.url.searchParams.get('tab')) ?? 'overview')
);
const openQuestions = $derived(
data.entries.filter((e) => e.kind === 'question' && !e.answeredAt).length
);
const q = $derived(page.url.searchParams.get('q') ?? '');
let typed = $state<string | null>(null);
const search = (value: string) => {
typed = value;
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, used once
const params = new URLSearchParams();
if (value.trim()) params.set('q', value);
const qs = params.size ? `?${params}` : '';
void goto(resolve(`/trackers/cargill${qs}` as '/'), {
reset: false,
replace: page.url.pathname === '/trackers/cargill'
});
};
$effect(() => {
void page.url;
typed = null;
});
</script>
<div class="plant">
<header class="plant-head">
<a href={resolve('/trackers/cargill')} class="title-link">
<TrackerIcon name="cargill" class="size-10" />
</a>
<div class="title">
<h1>cargill<PrivateLock size={14} /></h1>
<p>What the Cargill Bioindustrial plant in Gouda does: the process, products and words.</p>
</div>
<label class="search">
<Search size={15} />
<input
type="search"
placeholder="search everything"
aria-label="Search the plant notes"
value={typed ?? q}
oninput={(e) => search(e.currentTarget.value)}
/>
{#if typed ?? q}<button
type="button"
class="clear"
aria-label="clear search"
onclick={() => search('')}><X size={14} /></button
>{/if}
</label>
</header>
<nav class="plant-tabs" aria-label="Plant tracker sections">
<a
href={resolve('/trackers/cargill')}
class={{ active: active === 'overview' && !q }}
aria-current={active === 'overview' && !q ? 'page' : undefined}
><span class="tab-icon overview"><Factory size={13} /></span>overview</a
>
{#each tabs as k (k)}
<a
href={resolve(tabHref(k) as '/')}
class={{ active: active === k && !q }}
aria-current={active === k && !q ? 'page' : undefined}
data-tab={KIND_TAB[k]}
><KindIcon kind={k} size={22} />{KIND_LABEL[k].many}<span class="n"
>{k === 'question' ? `${openQuestions}/${counts[k]}` : counts[k]}</span
></a
>
{/each}
</nav>
{@render children()}
</div>
<style>
.plant {
max-width: 72rem;
margin: 0 auto;
padding: 2rem 1rem 3rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.plant-head {
display: flex;
align-items: center;
gap: 0.85rem;
flex-wrap: wrap;
}
.title-link {
display: inline-flex;
}
.title {
flex: 1 1 18rem;
min-width: 0;
}
h1 {
font-size: 2rem;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.1;
}
.title p {
color: var(--site-muted);
font-size: 0.9rem;
}
.search {
display: flex;
align-items: center;
gap: 0.4rem;
flex: 0 1 17rem;
padding: 0 0.6rem;
height: 2.2rem;
border: 1px solid var(--site-border);
border-radius: 999px;
color: var(--site-muted);
background: var(--site-surface);
}
.search:focus-within {
border-color: var(--site-muted);
}
.search input {
flex: 1;
min-width: 0;
background: transparent;
border: 0;
outline: none;
font: inherit;
font-size: 0.85rem;
color: var(--site-text);
}
.search input::-webkit-search-cancel-button {
display: none;
}
.clear {
display: inline-flex;
cursor: pointer;
color: var(--site-muted);
}
.plant-tabs {
display: flex;
gap: 0.35rem;
overflow-x: auto;
scrollbar-width: none;
border-bottom: 1px solid var(--site-border);
padding-bottom: 0.6rem;
}
.plant-tabs a {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.6rem 0.25rem 0.25rem;
border: 1px solid var(--site-border);
border-radius: 999px;
font-size: 0.83rem;
color: var(--site-muted);
white-space: nowrap;
text-decoration: none;
transition:
color 0.15s,
border-color 0.15s,
background 0.15s;
}
.plant-tabs a :global(.kind-icon) {
border-radius: 999px;
}
.plant-tabs a:hover {
color: var(--site-text);
border-color: var(--site-muted);
}
.plant-tabs a.active {
background: var(--site-text);
border-color: var(--site-text);
color: var(--site-bg);
}
.n {
font-size: 0.7rem;
font-variant-numeric: tabular-nums;
opacity: 0.7;
}
.tab-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 999px;
color: #fff;
}
.tab-icon.overview {
background: linear-gradient(135deg, #a3e635, #4d7c0f);
}
/* Shared building blocks for the overview and entry pages. */
.plant :global(.card) {
padding: 0.9rem;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
min-width: 0;
}
.plant :global(.card-title) {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
margin-bottom: 0.6rem;
}
.plant :global(.bar) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
flex-wrap: wrap;
}
.plant :global(.meta),
.plant :global(.empty) {
color: var(--site-muted);
font-size: 0.85rem;
}
.plant :global(.empty) {
padding: 1.5rem 0;
}
.plant :global(button) {
cursor: pointer;
font: inherit;
}
.plant :global(.primary) {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.8rem;
border-radius: 0.5rem;
background: var(--site-text);
color: var(--site-bg);
font-size: 0.83rem;
font-weight: 600;
transition: opacity 0.15s;
}
.plant :global(.primary:hover) {
opacity: 0.85;
}
.plant :global(.ghost) {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.7rem;
border-radius: 0.5rem;
border: 1px solid var(--site-border);
color: var(--site-muted);
font-size: 0.83rem;
}
.plant :global(.ghost:hover) {
color: var(--site-text);
border-color: var(--site-muted);
}
.plant :global(.icon) {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.3rem;
border-radius: 0.4rem;
color: var(--site-muted);
transition:
color 0.15s,
background 0.15s;
}
.plant :global(.icon:hover) {
color: var(--site-text);
background: color-mix(in srgb, var(--site-text) 8%, transparent);
}
.plant :global(.icon.danger:hover) {
color: #f87171;
background: rgb(248 113 113 / 0.1);
}
.plant :global(.tag) {
font-size: 0.68rem;
padding: 0.05rem 0.4rem;
border-radius: 999px;
border: 1px solid var(--site-border);
color: var(--site-muted);
white-space: nowrap;
}
.plant :global(.md) {
font-size: 0.9rem;
line-height: 1.55;
overflow-wrap: anywhere;
}
.plant :global(.md > * + *) {
margin-top: 0.55rem;
}
.plant :global(.md h1),
.plant :global(.md h2),
.plant :global(.md h3) {
font-weight: 600;
font-size: 1rem;
margin-top: 1rem;
}
.plant :global(.md ul) {
list-style: disc;
padding-left: 1.2rem;
}
.plant :global(.md ol) {
list-style: decimal;
padding-left: 1.2rem;
}
.plant :global(.md li + li) {
margin-top: 0.2rem;
}
.plant :global(.md code) {
font-size: 0.82rem;
padding: 0.05rem 0.3rem;
border-radius: 0.3rem;
background: color-mix(in srgb, var(--site-text) 8%, transparent);
}
.plant :global(.md pre) {
padding: 0.6rem 0.75rem;
border-radius: 0.5rem;
overflow-x: auto;
background: color-mix(in srgb, var(--site-text) 6%, transparent);
}
.plant :global(.md pre code) {
background: none;
padding: 0;
}
.plant :global(.md a) {
text-decoration: underline;
}
.plant :global(.md strong) {
font-weight: 600;
}
.plant :global(.md blockquote) {
border-left: 2px solid var(--site-border);
padding-left: 0.7rem;
color: var(--site-muted);
}
.plant :global(.md table) {
border-collapse: collapse;
font-size: 0.85rem;
}
.plant :global(.md th),
.plant :global(.md td) {
border: 1px solid var(--site-border);
padding: 0.25rem 0.5rem;
text-align: left;
}
@media (max-width: 640px) {
.plant {
padding-top: 1.25rem;
}
h1 {
font-size: 1.6rem;
}
.search {
flex: 1 1 100%;
}
}
</style>

View File

@@ -0,0 +1,27 @@
import { requireOwner } from '#lib/server/owner.js';
import { plantActions } from '#lib/server/plant-actions.js';
import { renderBody } from '#lib/server/plant.js';
import { client } from '#lib/server/db/index.js';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
requireOwner(event);
// The overview shows the "factory in one picture" note, and notes tagged "overview" (the
// site overview) at the top.
const [intro] = await client`SELECT body FROM plant.entries
WHERE kind = 'note' AND slug = 'factory-overview'`;
const top = await client`SELECT id, body FROM plant.entries
WHERE kind = 'note' AND 'overview' = ANY(tags) AND slug <> 'factory-overview' AND body IS NOT NULL
ORDER BY sort_order, id`;
return {
introHtml: intro?.body ? renderBody(intro.body) : null,
topNotes: top.map((n) => ({ id: n.id as number, html: renderBody(n.body) })),
seo: {
title: 'cargill',
description: 'What the Cargill Bioindustrial plant in Gouda does.',
index: false
}
};
};
export const actions: Actions = plantActions;

View File

@@ -0,0 +1,978 @@
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms';
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { relativeTime } from '#lib/relative-time.js';
import KindIcon from '#lib/plant/KindIcon.svelte';
import ConfidencePill from '#lib/plant/ConfidencePill.svelte';
import EntryForm from '#lib/plant/EntryForm.svelte';
import FlowDiagram from '#lib/plant/FlowDiagram.svelte';
import {
KIND_LABEL,
PLANT_KINDS,
entryHref,
kindFromTab,
matches,
tabHref,
type PlantKind
} from '#lib/plant.js';
import Plus from 'lucide-svelte/icons/plus';
import Pencil from 'lucide-svelte/icons/pencil';
import Trash2 from 'lucide-svelte/icons/trash-2';
import CircleCheck from 'lucide-svelte/icons/circle-check';
import CircleDashed from 'lucide-svelte/icons/circle-dashed';
import Link2 from 'lucide-svelte/icons/link-2';
import Workflow from 'lucide-svelte/icons/workflow';
import StickyNote from 'lucide-svelte/icons/sticky-note';
import CircleQuestionMark from 'lucide-svelte/icons/circle-question-mark';
import History from 'lucide-svelte/icons/history';
import Factory from 'lucide-svelte/icons/factory';
let { data } = $props();
type Entry = (typeof data.entries)[number];
const kind = $derived(kindFromTab(page.url.searchParams.get('tab')));
const q = $derived((page.url.searchParams.get('q') ?? '').trim());
/** Which inline form is open: 'new' or the id of the entry being edited. */
let editing = $state<'new' | number | null>(null);
$effect(() => {
void kind;
editing = null;
});
let busy = $state(false);
const confirmSubmit =
(text?: string): SubmitFunction =>
({ cancel }) => {
if (text && !confirm(text)) return cancel();
busy = true;
return async ({ update }) => {
await update({ reset: false, navigate: false });
busy = false;
};
};
const byId = $derived(new Map(data.entries.map((e) => [e.id, e])));
const linkCount = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, rebuilt on every derive
const c = new Map<number, number>();
for (const l of data.links) {
c.set(l.fromId, (c.get(l.fromId) ?? 0) + 1);
c.set(l.toId, (c.get(l.toId) ?? 0) + 1);
}
return c;
});
const ofKind = (k: PlantKind) =>
data.entries
.filter((e) => e.kind === k)
.sort((a, b) => a.sortOrder - b.sortOrder || a.title.localeCompare(b.title));
/** Department clusters (FA, EA, DIM, UTIL): tagged "cluster", shown as sections. */
const isCluster = (e: Entry) => e.kind === 'department' && e.tags.includes('cluster');
const byTitle = (a: Entry, b: Entry) => a.title.localeCompare(b.title, 'en', { numeric: true });
const clusters = $derived(
data.entries
.filter((e) => isCluster(e) && !e.parentId)
.sort((a, b) => a.sortOrder - b.sortOrder)
);
/** Every department below a cluster, depth first (MP1, then its 15U units). */
const unitsOf = (c: Entry): Entry[] =>
data.entries
.filter((u) => u.parentId === c.id && u.kind === 'department')
.sort(byTitle)
.flatMap((u) => [u, ...unitsOf(u)]);
/** Units tagged "inactive" are dimmed, and can be hidden. */
const isInactive = (e: Entry) => e.tags.includes('inactive');
let hideInactive = $state(false);
/** Same-kind children (units under a department group like MP1), shown inside the parent card. */
const childrenOf = (e: Entry) =>
data.entries
.filter((c) => c.parentId === e.id && c.kind === e.kind && !(hideInactive && isInactive(c)))
.sort(byTitle);
const results = $derived(q ? data.entries.filter((e) => matches(e, q)) : []);
const confidence = $derived.by(() => {
const c = { confirmed: 0, assumed: 0, open: 0 };
for (const e of data.entries) c[e.confidence]++;
return c;
});
const questions = $derived(ofKind('question'));
const openQuestions = $derived(questions.filter((e) => !e.answeredAt));
const recent = $derived(
[...data.entries].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 6)
);
// Glossary: A–Z with a filter.
let filter = $state('');
const letterOf = (t: string) => {
const c = t
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.charAt(0)
.toUpperCase();
return /[A-Z]/.test(c) ? c : '#';
};
const glossary = $derived(
ofKind('glossary')
.filter((e) => matches(e, filter))
.sort((a, b) => a.title.localeCompare(b.title, 'en', { sensitivity: 'base' }))
);
const letters = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, rebuilt on every derive
const g = new Map<string, Entry[]>();
for (const e of glossary) g.set(letterOf(e.title), [...(g.get(letterOf(e.title)) ?? []), e]);
return g;
});
const AZ = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'];
</script>
{#snippet tools(e: Entry, withEdit = true)}
<span class="tools">
{#if withEdit}<button
type="button"
class="icon"
aria-label="edit {e.title}"
title="edit"
onclick={() => (editing = editing === e.id ? null : e.id)}><Pencil size={14} /></button
>{/if}
<form
method="POST"
action="?/delete"
use:enhance={confirmSubmit(`Delete “${e.title}” and its links?`)}
>
<input type="hidden" name="id" value={e.id} />
<button type="submit" class="icon danger" aria-label="delete {e.title}" title="delete"
><Trash2 size={14} /></button
>
</form>
</span>
{/snippet}
{#snippet answerToggle(e: Entry)}
<form method="POST" action="?/answer" use:enhance={confirmSubmit()}>
<input type="hidden" name="id" value={e.id} />
<input type="hidden" name="answered" value={e.answeredAt ? '0' : '1'} />
<button
type="submit"
class={['toggle', { done: e.answeredAt }]}
disabled={busy}
aria-pressed={!!e.answeredAt}
title={e.answeredAt ? 'answered: mark as open again' : 'mark as answered'}
aria-label={e.answeredAt ? `reopen ${e.title}` : `mark ${e.title} answered`}
>{#if e.answeredAt}<CircleCheck size={18} />{:else}<CircleDashed size={18} />{/if}</button
>
</form>
{/snippet}
{#snippet row(e: Entry, showKind = false)}
{#if editing === e.id}
<EntryForm entry={e} entries={data.entries} onclose={() => (editing = null)} />
{:else}
<div class={['row', { answered: e.answeredAt, inactive: isInactive(e) }]}>
{#if e.kind === 'question'}{@render answerToggle(e)}{:else if showKind}<KindIcon
kind={e.kind}
size={20}
/>{/if}
<div class="row-main">
<a class="row-title" href={resolve(entryHref(e) as '/')}>{e.title}</a>
{#if e.summary}<span class="row-summary">{e.summary}</span>{/if}
</div>
{#if e.kind !== 'glossary' || e.confidence !== 'confirmed'}<ConfidencePill
value={e.confidence}
/>{/if}
{@render tools(e)}
</div>
{/if}
{/snippet}
{#snippet card(e: Entry)}
{#if editing === e.id}
<div class="span-all">
<EntryForm entry={e} entries={data.entries} onclose={() => (editing = null)} />
</div>
{:else}
{@const parent = e.parentId ? byId.get(e.parentId) : null}
{@const kids = childrenOf(e)}
<article class={['card entry-card', { inactive: isInactive(e), group: kids.length }]}>
<header>
<KindIcon kind={e.kind} size={30} />
<div class="ec-title">
<a href={resolve(entryHref(e) as '/')}>{e.title}</a>
{#if parent && parent.kind !== e.kind}<span class="ec-parent"
>in <a href={resolve(entryHref(parent) as '/')}>{parent.title}</a></span
>{/if}
</div>
{@render tools(e)}
</header>
{#if e.summary}<p class="ec-summary">{e.summary}</p>{/if}
<footer>
<ConfidencePill value={e.confidence} />
{#if linkCount.get(e.id)}<span class="tag links"
><Link2 size={11} />{linkCount.get(e.id)}</span
>{/if}
{#each e.tags as t (t)}<span class={['tag', { 'tag-inactive': t === 'inactive' }]}>{t}</span
>{/each}
</footer>
{#if kids.length}
<ul class="kids" aria-label="units in {e.title}">
{#each kids as k (k.id)}
{#if editing === k.id}
<li class="kid-edit">
<EntryForm entry={k} entries={data.entries} onclose={() => (editing = null)} />
</li>
{:else}
<li class={['row', { inactive: isInactive(k) }]}>
<KindIcon kind={k.kind} size={18} />
<div class="row-main">
<a class="row-title" href={resolve(entryHref(k) as '/')}>{k.title}</a>
{#if k.summary}<span class="row-summary">{k.summary}</span>{/if}
<span class="kid-meta">
<ConfidencePill value={k.confidence} />
{#if isInactive(k)}<span class="tag tag-inactive">inactive</span>{/if}
</span>
</div>
{@render tools(k)}
</li>
{/if}
{/each}
</ul>
{/if}
</article>
{/if}
{/snippet}
{#snippet addBar(k: PlantKind | null, meta: string)}
<div class="bar">
<p class="meta">{meta}</p>
<button
type="button"
class="primary"
aria-expanded={editing === 'new'}
onclick={() => (editing = editing === 'new' ? null : 'new')}
><Plus size={15} />add {k ? KIND_LABEL[k].one : 'entry'}</button
>
</div>
{#if editing === 'new'}
<EntryForm
kind={k ?? 'note'}
entries={data.entries}
onclose={() => (editing = null)}
openAfterCreate={!k}
/>
{/if}
{/snippet}
{#if q}
<p class="meta">
{results.length}
{results.length === 1 ? 'match' : 'matches'} for “{q}”
</p>
{#if results.length}
<div class="card rows">
{#each results as e (e.id)}{@render row(e, true)}{/each}
</div>
{:else}
<p class="empty">Nothing found. Try another word, or add it as a question.</p>
{/if}
{:else if !kind}
{#each data.topNotes as n (n.id)}
{@const note = byId.get(n.id)}
{#if note}
<section class="card site-note">
<h2 class="card-title">
<StickyNote size={14} />{note.title}
<a class="more" href={resolve(entryHref(note) as '/')}>open</a>
</h2>
<!-- Safe: rendered server-side by #lib/server/plant (raw HTML escaped). -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="md">{@html n.html}</div>
</section>
{/if}
{/each}
{@render addBar(
null,
`${data.entries.length} entries · ${confidence.confirmed} confirmed, ${confidence.assumed} assumed, ${confidence.open} open`
)}
<div class="kinds">
{#each PLANT_KINDS as k (k)}
{@const n = data.entries.filter((e) => e.kind === k).length}
{#if n || k !== 'person_role'}
<a class="kind-stat" href={resolve(tabHref(k) as '/')}>
<KindIcon kind={k} size={26} />
<span class="ks-n">{n}</span>
<span class="ks-l">{KIND_LABEL[k].many}</span>
</a>
{/if}
{/each}
</div>
{#if clusters.length}
<section class="card clusters-card">
<h2 class="card-title">
<Factory size={14} />departments by cluster
<a class="more" href={resolve(tabHref('department') as '/')}
>all {ofKind('department').length}</a
>
</h2>
<div class="clusters">
{#each clusters as c (c.id)}
<div class="cluster-mini">
<a class="cm-title" href={resolve(entryHref(c) as '/')}
><KindIcon kind="department" size={20} />{c.title}</a
>
{#if c.summary}<p class="cm-summary">{c.summary}</p>{/if}
<div class="chips">
{#each unitsOf(c) as u (u.id)}
<a
class={['chip', { inactive: isInactive(u) }]}
href={resolve(entryHref(u) as '/')}
title={u.summary ?? u.title}>{u.title}</a
>
{/each}
</div>
</div>
{/each}
</div>
</section>
{/if}
<div class="overview">
<section class="card flow-card">
<h2 class="card-title"><Workflow size={14} />how it flows</h2>
<FlowDiagram entries={data.entries} links={data.links} />
<p class="legend">
<span><i class="lg step"></i>process step</span>
<span><i class="lg terminal"></i>in / out</span>
<span><i class="lg dashed"></i>optional or loop</span>
</p>
</section>
<div class="side">
{#if data.introHtml}
<section class="card">
<h2 class="card-title"><StickyNote size={14} />in one picture</h2>
<!-- Safe: rendered server-side by #lib/server/plant (raw HTML escaped). -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="md">{@html data.introHtml}</div>
</section>
{/if}
<section class="card">
<h2 class="card-title">
<CircleQuestionMark size={14} />open questions
<a class="more" href={resolve(tabHref('question') as '/')}>all {questions.length}</a>
</h2>
{#if openQuestions.length}
<div class="rows">
{#each openQuestions as e (e.id)}{@render row(e)}{/each}
</div>
{:else}
<p class="meta">All answered.</p>
{/if}
</section>
<section class="card">
<h2 class="card-title"><History size={14} />recently updated</h2>
<ul class="recent">
{#each recent as e (e.id)}
<li>
<KindIcon kind={e.kind} size={18} />
<a href={resolve(entryHref(e) as '/')}>{e.title}</a>
<time datetime={e.updatedAt}>{relativeTime(e.updatedAt, data.now)}</time>
</li>
{/each}
</ul>
</section>
</div>
</div>
{:else if kind === 'glossary'}
{@render addBar(kind, `${ofKind('glossary').length} words, A–Z`)}
<div class="gloss-tools">
<input
class="filter"
type="search"
placeholder="filter words"
aria-label="Filter the glossary"
bind:value={filter}
/>
<nav class="az" aria-label="Jump to letter">
{#each AZ as l (l)}
{#if letters.has(l)}<a href="#letter-{l}">{l}</a>{:else}<span>{l}</span>{/if}
{/each}
</nav>
</div>
{#if !glossary.length}<p class="empty">No words match.</p>{/if}
<div class="gloss">
{#each [...letters] as [l, words] (l)}
<section class="card letter" id="letter-{l}">
<h2 class="big-letter">{l}</h2>
<div class="rows">
{#each words as e (e.id)}{@render row(e)}{/each}
</div>
</section>
{/each}
</div>
{:else if kind === 'question'}
{@render addBar(
kind,
`${openQuestions.length} open · ${questions.length - openQuestions.length} answered`
)}
<section class="card rows">
{#each openQuestions as e (e.id)}{@render row(e)}{:else}<p class="meta">
No open questions.
</p>{/each}
</section>
{#if questions.length > openQuestions.length}
<h2 class="sub">answered</h2>
<section class="card rows">
{#each questions.filter((e) => e.answeredAt) as e (e.id)}{@render row(e)}{/each}
</section>
{/if}
{:else}
{@const list = ofKind(kind)}
{@const inactiveCount = list.filter(isInactive).length}
{@const top = list.filter(
(e) => (!e.parentId || byId.get(e.parentId)?.kind !== kind) && !(hideInactive && isInactive(e))
)}
{@render addBar(
kind,
`${list.length} ${KIND_LABEL[kind].many}${inactiveCount ? ` · ${inactiveCount} inactive` : ''}`
)}
{#if inactiveCount}
<label class="switch">
<input type="checkbox" bind:checked={hideInactive} />
<span class="knob" aria-hidden="true"></span>hide inactive
</label>
{/if}
{#if kind === 'process_step'}
<section class="card flow-card">
<FlowDiagram entries={data.entries} links={data.links} />
</section>
{/if}
{#if !list.length}
<p class="empty">
Nothing here yet.{kind === 'person_role'
? ' Add roles (operator, shift lead, lab analyst …), not names.'
: ''}
</p>
{/if}
{@const groups = top.filter(isCluster)}
{#if groups.length}
{#each groups as c (c.id)}
{@const units = childrenOf(c)}
<section class={['cluster', { inactive: isInactive(c) }]} aria-labelledby="cluster-{c.id}">
<header class="cluster-head">
<KindIcon kind={c.kind} size={26} />
<div class="ch-text">
<h2 id="cluster-{c.id}"><a href={resolve(entryHref(c) as '/')}>{c.title}</a></h2>
{#if c.summary}<p class="ec-summary">{c.summary}</p>{/if}
</div>
<span class="tag">{units.length} units</span>
<ConfidencePill value={c.confidence} />
{@render tools(c)}
</header>
{#if editing === c.id}
<EntryForm entry={c} entries={data.entries} onclose={() => (editing = null)} />
{/if}
<div class="grid">
{#each units as e (e.id)}{@render card(e)}{/each}
</div>
</section>
{/each}
{@const rest = top.filter((e) => !isCluster(e))}
{#if rest.length}
<section class="cluster" aria-labelledby="cluster-none">
<header class="cluster-head">
<div class="ch-text">
<h2 id="cluster-none">not in a cluster</h2>
<p class="ec-summary">General entries, or units still to place.</p>
</div>
</header>
<div class="grid">
{#each rest as e (e.id)}{@render card(e)}{/each}
</div>
</section>
{/if}
{:else}
<div class="grid">
{#each [...top].sort((a, b) => childrenOf(b).length - childrenOf(a).length) as e (e.id)}{@render card(
e
)}{/each}
</div>
{/if}
{/if}
<style>
.kinds {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
gap: 0.5rem;
}
.kind-stat {
display: grid;
grid-template-columns: auto 1fr;
grid-template-areas: 'icon n' 'icon l';
column-gap: 0.55rem;
align-items: center;
padding: 0.5rem 0.65rem;
border: 1px solid var(--site-border);
border-radius: 0.7rem;
background: var(--site-surface);
text-decoration: none;
transition: border-color 0.15s;
}
.kind-stat:hover {
border-color: var(--site-muted);
}
.kind-stat :global(.kind-icon) {
grid-area: icon;
}
.ks-n {
grid-area: n;
font-weight: 600;
font-size: 1.05rem;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.ks-l {
grid-area: l;
font-size: 0.72rem;
color: var(--site-muted);
}
.overview {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
gap: 0.75rem;
align-items: start;
}
.side {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-width: 0;
}
.flow-card {
overflow-x: auto;
}
.legend {
display: flex;
gap: 0.9rem;
flex-wrap: wrap;
justify-content: center;
margin-top: 0.6rem;
font-size: 0.72rem;
color: var(--site-muted);
}
.legend span {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.lg {
display: inline-block;
width: 1.3rem;
height: 0.75rem;
border: 1.2px solid var(--site-muted);
border-radius: 0.25rem;
}
.lg.terminal {
border-style: dashed;
border-radius: 999px;
}
.lg.dashed {
height: 0;
border-width: 1.2px 0 0;
border-style: dashed;
border-radius: 0;
}
.more {
margin-left: auto;
text-transform: none;
letter-spacing: normal;
font-size: 0.75rem;
color: var(--site-muted);
}
.more:hover {
color: var(--site-text);
}
.recent {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.recent li {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
min-width: 0;
}
.recent a {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recent a:hover,
.row-title:hover,
.ec-title a:hover {
text-decoration: underline;
}
.recent time {
margin-left: auto;
font-size: 0.72rem;
color: var(--site-muted);
white-space: nowrap;
}
.rows {
display: flex;
flex-direction: column;
}
.card.rows {
padding: 0.35rem 0.75rem;
}
.row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.45rem 0;
min-width: 0;
}
.rows > :global(* + *) {
border-top: 1px dashed color-mix(in srgb, var(--site-border) 70%, transparent);
}
.rows > :global(form.edit) {
margin: 0.4rem 0;
}
.row-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.row-title {
font-weight: 500;
font-size: 0.9rem;
}
.row-summary {
font-size: 0.8rem;
color: var(--site-muted);
line-height: 1.35;
}
.row.answered .row-title {
color: var(--site-muted);
text-decoration: line-through;
text-decoration-color: color-mix(in srgb, var(--site-muted) 60%, transparent);
}
.tools {
display: inline-flex;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
}
.row:hover .tools,
.row:focus-within .tools,
.entry-card:hover .tools,
.entry-card:focus-within .tools,
.cluster-head:hover .tools,
.cluster-head:focus-within .tools {
opacity: 1;
}
@media (hover: none) {
.tools {
opacity: 1;
}
}
.toggle {
display: inline-flex;
color: #ec4899;
padding: 0.1rem;
border-radius: 999px;
}
.toggle.done {
color: #10b981;
}
.toggle:hover {
background: color-mix(in srgb, var(--site-text) 8%, transparent);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr));
gap: 0.6rem;
align-items: start;
}
.span-all {
grid-column: 1 / -1;
}
.entry-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
transition: border-color 0.15s;
}
.entry-card:hover {
border-color: var(--site-muted);
}
.entry-card header {
display: flex;
align-items: center;
gap: 0.6rem;
}
.ec-title {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.ec-title > a {
font-weight: 600;
line-height: 1.2;
}
.ec-parent {
font-size: 0.72rem;
color: var(--site-muted);
}
.ec-summary {
font-size: 0.83rem;
color: var(--site-muted);
line-height: 1.4;
}
.entry-card footer {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
align-items: center;
margin-top: auto;
}
.entry-card.inactive,
.row.inactive {
opacity: 0.5;
}
.entry-card.inactive :global(.kind-icon),
.row.inactive :global(.kind-icon) {
filter: grayscale(1);
}
.entry-card.inactive:hover,
.row.inactive:hover {
opacity: 0.85;
}
.tag-inactive {
border-style: dashed;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.kid-meta {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.3rem;
}
.clusters {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 15rem), 1fr));
gap: 0.5rem;
}
.cluster-mini {
border: 1px solid var(--site-border);
border-radius: 0.7rem;
padding: 0.55rem 0.65rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.cm-title {
display: flex;
align-items: center;
gap: 0.45rem;
font-weight: 600;
font-size: 0.9rem;
}
.cm-summary {
margin: 0;
font-size: 0.78rem;
color: var(--site-muted);
line-height: 1.35;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.chip {
font-size: 0.72rem;
padding: 0.1rem 0.45rem;
border-radius: 999px;
border: 1px solid var(--site-border);
white-space: nowrap;
}
.chip:hover {
border-color: var(--site-text);
}
.chip.inactive {
opacity: 0.45;
border-style: dashed;
}
.cluster {
display: flex;
flex-direction: column;
gap: 0.55rem;
margin-top: 0.5rem;
}
.cluster-head {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
padding-bottom: 0.45rem;
border-bottom: 1px solid var(--site-border);
}
.ch-text {
flex: 1;
min-width: 12rem;
}
.ch-text h2 {
margin: 0;
font-size: 1rem;
}
.ch-text .ec-summary {
margin: 0.1rem 0 0;
}
.entry-card.group {
grid-column: 1 / -1;
}
.kids {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 17rem), 1fr));
gap: 0.4rem;
border-top: 1px solid var(--site-border);
padding-top: 0.55rem;
}
.kids .row {
padding: 0.4rem 0.55rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
align-items: flex-start;
}
.kid-edit {
grid-column: 1 / -1;
}
.kids .row-title {
font-size: 0.85rem;
}
.switch {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.8rem;
color: var(--site-muted);
cursor: pointer;
align-self: flex-start;
}
.switch input {
position: absolute;
opacity: 0;
}
.knob {
position: relative;
width: 1.9rem;
height: 1.05rem;
border-radius: 999px;
background: var(--site-border);
transition: background 0.15s;
}
.knob::after {
content: '';
position: absolute;
top: 0.15rem;
left: 0.15rem;
width: 0.75rem;
height: 0.75rem;
border-radius: 999px;
background: var(--site-bg);
transition: transform 0.15s;
}
.switch input:checked + .knob {
background: var(--site-text);
}
.switch input:checked + .knob::after {
transform: translateX(0.85rem);
}
.switch input:focus-visible + .knob {
outline: 2px solid #38bdf8;
outline-offset: 2px;
}
.tag.links {
display: inline-flex;
align-items: center;
gap: 0.2rem;
}
.gloss-tools {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-items: center;
}
.filter {
flex: 0 1 14rem;
height: 2rem;
padding: 0 0.7rem;
font: inherit;
font-size: 0.85rem;
border: 1px solid var(--site-border);
border-radius: 999px;
background: var(--site-surface);
color: var(--site-text);
}
.az {
display: flex;
flex-wrap: wrap;
gap: 0.1rem;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
.az a,
.az span {
display: inline-flex;
justify-content: center;
width: 1.35rem;
padding: 0.1rem 0;
border-radius: 0.3rem;
}
.az a {
font-weight: 600;
}
.az a:hover {
background: color-mix(in srgb, var(--site-text) 10%, transparent);
}
.az span {
color: var(--site-muted);
opacity: 0.45;
}
.gloss {
columns: 2 22rem;
column-gap: 0.6rem;
}
.letter {
break-inside: avoid;
margin-bottom: 0.6rem;
display: grid;
grid-template-columns: 2rem 1fr;
gap: 0.25rem;
padding: 0.35rem 0.75rem;
scroll-margin-top: 5rem;
}
.big-letter {
font-size: 1.3rem;
font-weight: 700;
padding-top: 0.35rem;
background: linear-gradient(135deg, #6ee7b7, #047857);
background-clip: text;
color: transparent;
}
.sub {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
}
@media (max-width: 860px) {
.overview {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

View File

@@ -0,0 +1,34 @@
import { kindFromSegment } from '#lib/plant.js';
import { requireOwner } from '#lib/server/owner.js';
import { plantActions } from '#lib/server/plant-actions.js';
import { renderBody } from '#lib/server/plant.js';
import { client } from '#lib/server/db/index.js';
import { error } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
// One entry: its markdown body, parent, children and links (entries and links come from the
// layout load).
export const load: PageServerLoad = async (event) => {
requireOwner(event);
const kind = kindFromSegment(event.params.kind);
if (!kind) error(404, 'Not found');
const [row] = await client`SELECT id, title, summary, body FROM plant.entries
WHERE kind = ${kind} AND slug = ${event.params.slug}`;
if (!row) error(404, 'Not found');
// Parents and links whose target isn't in the tracker yet (a later import resolves them).
const pending = await client`SELECT id, field, to_ref, label FROM plant.pending
WHERE from_id = ${row.id} ORDER BY id`;
return {
entryId: row.id as number,
pending: pending.map((p) => ({
id: p.id as number,
field: p.field as string,
to: p.to_ref as string,
label: p.label as string | null
})),
bodyHtml: row.body ? renderBody(row.body) : null,
seo: { title: `${row.title} · cargill`, description: row.summary ?? '', index: false }
};
};
export const actions: Actions = plantActions;

View File

@@ -0,0 +1,512 @@
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms';
import { resolve } from '$app/paths';
import { relativeTime } from '#lib/relative-time.js';
import KindIcon from '#lib/plant/KindIcon.svelte';
import ConfidencePill from '#lib/plant/ConfidencePill.svelte';
import EntryForm from '#lib/plant/EntryForm.svelte';
import {
KIND_LABEL,
PLANT_KINDS,
RELATIONS,
RELATION_LABEL,
entryHref,
tabHref,
type Relation
} from '#lib/plant.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import Pencil from 'lucide-svelte/icons/pencil';
import Trash2 from 'lucide-svelte/icons/trash-2';
import CircleCheck from 'lucide-svelte/icons/circle-check';
import CircleDashed from 'lucide-svelte/icons/circle-dashed';
import Link2 from 'lucide-svelte/icons/link-2';
import Unlink from 'lucide-svelte/icons/unlink';
import Plus from 'lucide-svelte/icons/plus';
let { data, form } = $props();
type Entry = (typeof data.entries)[number];
const byId = $derived(new Map(data.entries.map((e) => [e.id, e])));
const entry = $derived(byId.get(data.entryId)!);
const parentChain = $derived.by(() => {
const chain: Entry[] = [];
let p = entry?.parentId ? byId.get(entry.parentId) : undefined;
while (p && chain.length < 5 && !chain.includes(p)) {
chain.unshift(p);
p = p.parentId ? byId.get(p.parentId) : undefined;
}
return chain;
});
const children = $derived(
data.entries
.filter((e) => e.parentId === data.entryId)
.sort((a, b) => a.sortOrder - b.sortOrder || a.title.localeCompare(b.title))
);
/** Links grouped by how they read from this entry ("feeds into", "fed by", ...). */
const linkGroups = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, rebuilt on every derive
const groups = new Map<string, { link: (typeof data.links)[number]; other: Entry }[]>();
for (const relation of RELATIONS)
for (const dir of ['out', 'in'] as const) {
const label = RELATION_LABEL[relation][dir];
for (const l of data.links) {
if (l.relation !== relation) continue;
const mine = dir === 'out' ? l.fromId : l.toId;
if (mine !== data.entryId) continue;
const other = byId.get(dir === 'out' ? l.toId : l.fromId);
if (other) groups.set(label, [...(groups.get(label) ?? []), { link: l, other }]);
}
}
return [...groups];
});
const linkTotal = $derived(linkGroups.reduce((s, [, g]) => s + g.length, 0));
let editing = $state(false);
let linking = $state(false);
$effect(() => {
void data.entryId;
editing = false;
linking = false;
});
let busy = $state(false);
let linkError = $state<string | null>(null);
const submit =
(confirmText?: string, after?: () => void): SubmitFunction =>
({ cancel }) => {
if (confirmText && !confirm(confirmText)) return cancel();
busy = true;
linkError = null;
return async ({ result, update }) => {
await update({ reset: false, navigate: false });
busy = false;
if (result.type === 'failure') linkError = String(result.data?.message ?? 'Failed');
if (result.type === 'success') after?.();
};
};
let reverse = $state(false);
let relation = $state<Relation>('related');
const stamp = (iso: string) =>
new Date(iso).toLocaleString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Amsterdam'
});
</script>
{#if entry}
<nav class="crumbs" aria-label="Breadcrumb">
<a href={resolve(tabHref(entry.kind) as '/')}
><ArrowLeft size={14} />{KIND_LABEL[entry.kind].many}</a
>
{#each parentChain as p (p.id)}
<span>/</span><a href={resolve(entryHref(p) as '/')}>{p.title}</a>
{/each}
</nav>
<div class="layout">
<article class="card main">
<header class="head">
<KindIcon kind={entry.kind} size={40} />
<div class="head-text">
<p class="kind">{KIND_LABEL[entry.kind].one}</p>
<h2 class={{ answered: entry.answeredAt }}>{entry.title}</h2>
</div>
<div class="tools">
<button
type="button"
class="icon"
aria-label="edit"
title="edit"
aria-expanded={editing}
onclick={() => (editing = !editing)}><Pencil size={16} /></button
>
<form
method="POST"
action="?/delete"
use:enhance={submit(`Delete “${entry.title}” and its links?`)}
>
<input type="hidden" name="id" value={entry.id} />
<input type="hidden" name="redirect" value="1" />
<button type="submit" class="icon danger" aria-label="delete" title="delete"
><Trash2 size={16} /></button
>
</form>
</div>
</header>
<div class="badges">
<ConfidencePill value={entry.confidence} />
{#if entry.kind === 'question'}
<form method="POST" action="?/answer" use:enhance={submit()}>
<input type="hidden" name="id" value={entry.id} />
<input type="hidden" name="answered" value={entry.answeredAt ? '0' : '1'} />
<button
type="submit"
class={['answer', { done: entry.answeredAt }]}
disabled={busy}
aria-pressed={!!entry.answeredAt}
>{#if entry.answeredAt}<CircleCheck size={14} />answered {relativeTime(
entry.answeredAt,
data.now
)}{:else}<CircleDashed size={14} />mark answered{/if}</button
>
</form>
{/if}
{#each entry.tags as t (t)}<span class={['tag', { 'tag-inactive': t === 'inactive' }]}
>{t}</span
>{/each}
</div>
{#if editing}
<EntryForm {entry} entries={data.entries} onclose={() => (editing = false)} />
{:else}
{#if entry.summary}<p class="summary">{entry.summary}</p>{/if}
{#if data.bodyHtml}
<!-- Safe: rendered server-side by #lib/server/plant (raw HTML escaped). -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="md">{@html data.bodyHtml}</div>
{:else if !entry.summary}
<p class="meta">No details yet.</p>
{/if}
{/if}
<footer class="foot">
{#if entry.source}<span>source: {entry.source}</span>{/if}
<time datetime={entry.updatedAt} title="{stamp(entry.updatedAt)} (Amsterdam)"
>updated {relativeTime(entry.updatedAt, data.now)}</time
>
</footer>
</article>
<aside class="side">
<section class="card">
<h3 class="card-title">
<Link2 size={14} />linked {linkTotal ? `(${linkTotal})` : ''}
<button
type="button"
class="icon add-link"
aria-label="add a link"
title="add a link"
aria-expanded={linking}
onclick={() => (linking = !linking)}><Plus size={15} /></button
>
</h3>
{#if linking}
<form
class="link-form"
method="POST"
action="?/link"
use:enhance={submit(undefined, () => (linking = false))}
>
<input type="hidden" name={reverse ? 'to_id' : 'from_id'} value={entry.id} />
<div class="lf-row">
<select bind:value={reverse} aria-label="direction">
<option value={false}>this → other</option>
<option value={true}>other → this</option>
</select>
<select name="relation" bind:value={relation} aria-label="relation">
{#each RELATIONS as r (r)}<option value={r}>{RELATION_LABEL[r].out}</option>{/each}
</select>
</div>
<select name={reverse ? 'from_id' : 'to_id'} required aria-label="other entry">
<option value="">pick an entry…</option>
{#each PLANT_KINDS as k (k)}
{@const options = data.entries.filter((e) => e.kind === k && e.id !== entry.id)}
{#if options.length}
<optgroup label={KIND_LABEL[k].many}>
{#each options as o (o.id)}<option value={o.id}>{o.title}</option>{/each}
</optgroup>
{/if}
{/each}
</select>
<div class="lf-row">
<input name="label" maxlength="80" placeholder="label (optional)" />
<label class="check"><input type="checkbox" name="dashed" value="1" />dashed</label>
</div>
<div class="lf-row end">
<button type="button" class="ghost" onclick={() => (linking = false)}>cancel</button>
<button type="submit" class="primary" disabled={busy}>link</button>
</div>
{#if linkError}<p class="error" role="alert">{linkError}</p>{/if}
</form>
{/if}
{#if form?.message && !linking && !editing}<p class="error" role="alert">
{form.message}
</p>{/if}
{#each linkGroups as [label, items] (label)}
<div class="group">
<p class="group-label">{label}</p>
<ul>
{#each items as { link, other } (link.id)}
<li>
<KindIcon kind={other.kind} size={20} />
<a href={resolve(entryHref(other) as '/')}>{other.title}</a>
{#if link.label}<span class="tag">{link.label}</span>{/if}
{#if link.confidence !== 'confirmed'}<span
class="tag maybe"
title="{link.confidence} link, not confirmed yet">?</span
>{/if}
<form method="POST" action="?/unlink" use:enhance={submit()}>
<input type="hidden" name="id" value={link.id} />
<button
type="submit"
class="icon danger unlink"
aria-label="unlink {other.title}"
title="unlink"><Unlink size={13} /></button
>
</form>
</li>
{/each}
</ul>
</div>
{:else}
{#if !linking && !data.pending.length}<p class="meta">No links yet.</p>{/if}
{/each}
{#if data.pending.length}
<div class="group pending">
<p class="group-label">pending</p>
<ul>
{#each data.pending as p (p.id)}
<li title="Not in the tracker yet; resolves when an entry with this slug is added">
<span class="pending-dot" aria-hidden="true"></span>
<span class="pending-ref">{p.to}</span>
<span class="tag"
>{p.field === 'parent'
? 'part of'
: (RELATION_LABEL[p.field as Relation]?.out ?? p.field)}</span
>
</li>
{/each}
</ul>
</div>
{/if}
</section>
{#if children.length}
<section class="card">
<h3 class="card-title">contains ({children.length})</h3>
<ul class="group">
{#each children as c (c.id)}
<li>
<KindIcon kind={c.kind} size={20} />
<a href={resolve(entryHref(c) as '/')}>{c.title}</a>
</li>
{/each}
</ul>
</section>
{/if}
</aside>
</div>
{/if}
<style>
.crumbs {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
font-size: 0.83rem;
color: var(--site-muted);
}
.crumbs a {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.crumbs a:hover {
color: var(--site-text);
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 20rem);
gap: 0.75rem;
align-items: start;
}
.main {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.1rem;
}
.head {
display: flex;
align-items: center;
gap: 0.75rem;
}
.head-text {
flex: 1;
min-width: 0;
}
.kind {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
}
h2 {
font-size: 1.45rem;
font-weight: 600;
letter-spacing: -0.015em;
line-height: 1.15;
}
h2.answered {
text-decoration: line-through;
text-decoration-color: color-mix(in srgb, var(--site-muted) 60%, transparent);
}
.tools {
display: flex;
gap: 0.1rem;
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.tag-inactive {
border-style: dashed;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.answer {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
padding: 0.1rem 0.55rem;
border-radius: 999px;
border: 1px solid rgb(236 72 153 / 0.4);
color: #ec4899;
}
.answer.done {
border-color: rgb(16 185 129 / 0.4);
color: #10b981;
}
.summary {
font-size: 1rem;
line-height: 1.45;
}
.foot {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 1rem;
font-size: 0.72rem;
color: var(--site-muted);
border-top: 1px solid var(--site-border);
padding-top: 0.6rem;
}
.side {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-width: 0;
}
.add-link {
margin-left: auto;
}
.group + .group {
margin-top: 0.6rem;
}
.maybe {
border-style: dashed;
color: #f59e0b;
font-weight: 700;
}
.pending li {
opacity: 0.7;
}
.pending-dot {
width: 20px;
height: 20px;
border-radius: 6px;
border: 1px dashed var(--site-muted);
flex-shrink: 0;
}
.pending-ref {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.8rem;
}
.group-label {
font-size: 0.72rem;
color: var(--site-muted);
margin-bottom: 0.2rem;
}
.group li,
ul.group li {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.2rem 0;
font-size: 0.86rem;
min-width: 0;
}
.group li a {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group li a:hover {
text-decoration: underline;
}
.group li form {
margin-left: auto;
}
.unlink {
opacity: 0;
}
.group li:hover .unlink,
.unlink:focus-visible {
opacity: 1;
}
@media (hover: none) {
.unlink {
opacity: 1;
}
}
.link-form {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--site-border);
}
.lf-row {
display: flex;
gap: 0.4rem;
align-items: center;
}
.lf-row.end {
justify-content: flex-end;
}
.link-form select,
.link-form input:not([type='checkbox']) {
font: inherit;
font-size: 0.83rem;
height: 2rem;
padding: 0 0.45rem;
border: 1px solid var(--site-border);
border-radius: 0.45rem;
background: var(--site-bg);
color: var(--site-text);
min-width: 0;
flex: 1;
}
.check {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--site-muted);
}
.error {
color: #f87171;
font-size: 0.8rem;
}
@media (max-width: 860px) {
.layout {
grid-template-columns: minmax(0, 1fr);
}
}
</style>