plant: private Cargill Gouda tracker (/trackers/cargill)
All checks were successful
Deploy production / deploy (push) Successful in 1m19s
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:
11
src/routes/trackers/cargill/+layout.server.ts
Normal file
11
src/routes/trackers/cargill/+layout.server.ts
Normal 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() };
|
||||
};
|
||||
394
src/routes/trackers/cargill/+layout.svelte
Normal file
394
src/routes/trackers/cargill/+layout.svelte
Normal 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>
|
||||
27
src/routes/trackers/cargill/+page.server.ts
Normal file
27
src/routes/trackers/cargill/+page.server.ts
Normal 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;
|
||||
978
src/routes/trackers/cargill/+page.svelte
Normal file
978
src/routes/trackers/cargill/+page.svelte
Normal 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>
|
||||
34
src/routes/trackers/cargill/[kind]/[slug]/+page.server.ts
Normal file
34
src/routes/trackers/cargill/[kind]/[slug]/+page.server.ts
Normal 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;
|
||||
512
src/routes/trackers/cargill/[kind]/[slug]/+page.svelte
Normal file
512
src/routes/trackers/cargill/[kind]/[slug]/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user