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:
81
docs/plant-tracker.md
Normal file
81
docs/plant-tracker.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Plant tracker (`/trackers/cargill`)
|
||||
|
||||
A private (owner-only) knowledge base about the Cargill Bioindustrial plant in Gouda:
|
||||
departments, products, process steps, equipment, glossary terms, questions, notes and roles
|
||||
(never colleague names). Logged-out visitors get a 404 on the page, its data and its actions
|
||||
(`ownerOnlyPrefixes` in `src/lib/server/owner.ts`).
|
||||
|
||||
## Schema `plant` (migration `drizzle/20261026133106_plant`)
|
||||
|
||||
- `plant.entries`: `kind`, `slug` (unique per kind), `title`, `summary`, `body` (markdown),
|
||||
`parent_id` (any kind, e.g. a note under a note), `tags`, `sort_order`, `source`,
|
||||
`confidence` (`confirmed` | `assumed` | `open`), `answered_at` (questions), timestamps.
|
||||
- `plant.links`: `from_id`, `to_id`, `relation` (`feeds_into`, `made_in`, `uses`, `part_of`,
|
||||
`related`), optional `label`, `dashed`, `confidence` (default `confirmed`; `assumed` or
|
||||
`open` links show dashed in the flow and with a "?" badge on entry pages).
|
||||
- `plant.pending`: parents and links whose target doesn't exist yet. Every `apply_entries` run
|
||||
retries them, so a later batch that adds the target resolves them. Entry pages list them as
|
||||
"pending".
|
||||
|
||||
Kinds: `department`, `product`, `process_step`, `equipment`, `glossary`, `question`, `note`,
|
||||
`person_role`.
|
||||
|
||||
How tags drive the UI:
|
||||
|
||||
- `cluster` (departments): a section in the departments tab and a card in the overview, with
|
||||
every unit below it (units with `parent: "cluster-ea"`, and their own units, like MP1's).
|
||||
- `inactive`: dimmed everywhere; the "hide inactive" switch hides them (off by default).
|
||||
- `overview` (notes): shown at the top of the overview.
|
||||
|
||||
## Bulk import: `plant.apply_entries(jsonb)`
|
||||
|
||||
Upserts by `(kind, slug)`, then sets parents and links by slug. Run it as the app user or the
|
||||
owner; for big files pipe SQL through stdin, e.g.
|
||||
`select plant.apply_entries($doc$ { ... } $doc$::jsonb);`
|
||||
|
||||
The argument is either an object `{ "source": ..., "entries": [...], "links": [...],
|
||||
"delete": [...] }` or a bare array of entries.
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "Walter White, batch 5 (2026-10-26)",
|
||||
"entries": [
|
||||
{
|
||||
"kind": "department",
|
||||
"slug": "414-kolom-1",
|
||||
"title": "414 Kolom 1",
|
||||
"summary": "Fat splitting column, continuous",
|
||||
"body": "In and out:\n- In: feed, water and high-pressure steam.\n- Out: fatty acid and glycerine water.",
|
||||
"parent": "cluster-dim",
|
||||
"tags": ["splitting", "continuous"],
|
||||
"confidence": "confirmed",
|
||||
"links": [{ "to": "splitting", "relation": "uses", "label": "optional" }]
|
||||
},
|
||||
{ "kind": "question", "slug": "rndm", "title": "What is RNDM?", "tags": ["answered"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- Required: `kind`, plus `slug` or a `title` to slugify; new entries need a `title`.
|
||||
- Optional: `summary`, `body`, `parent` (alias `parent_slug`; `null` clears it), `tags`,
|
||||
`confidence` (default `assumed`, `open` for questions), `sort_order`, `source`, `answered`,
|
||||
`replace_links`, `replace_tags`, `body_mode` (`"append"`), `links` (`to`, `relation`,
|
||||
`label`, `dashed`, `confidence`, `remove`).
|
||||
- Updating an existing entry:
|
||||
- Keys that are left out keep their current value. An empty `summary` or `body` also keeps
|
||||
the current text. With `"body_mode": "append"` the new body is added below the current
|
||||
one (skipped when it is already there) instead of replacing it.
|
||||
- `tags` are merged into the existing tags, unless the entry sets
|
||||
`"confidence": "confirmed"` or `"replace_tags": true`, which replace them.
|
||||
- The top-level `source` is only the default for new entries; pass `source` on an entry to
|
||||
overwrite it.
|
||||
- A question tagged `answered` (or with `"answered": true`) is marked answered; the tag is
|
||||
dropped.
|
||||
- References (`parent`, `to`, `from`) are a slug or `kind:slug`. A bare slug used by several
|
||||
kinds resolves in this order: process_step, product, department, equipment, note,
|
||||
question, person_role, glossary (so `splitting` is the process step).
|
||||
- Unknown references never abort the import: they are listed in `unresolved` and kept in
|
||||
`plant.pending` until their target exists.
|
||||
|
||||
Returns `{ inserted, updated, linked, unlinked, deleted, unresolved: [{from, field, to}],
|
||||
resolved_pending, pending, entries: [{id, kind, slug, action}] }`.
|
||||
@@ -36,7 +36,8 @@ export default defineConfig({
|
||||
'movies',
|
||||
'worldtodo',
|
||||
'notes',
|
||||
'math'
|
||||
'math',
|
||||
'plant'
|
||||
],
|
||||
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
||||
verbose: true,
|
||||
|
||||
1141
drizzle/20261026133106_plant/migration.sql
Normal file
1141
drizzle/20261026133106_plant/migration.sql
Normal file
File diff suppressed because it is too large
Load Diff
44009
drizzle/20261026133106_plant/snapshot.json
Normal file
44009
drizzle/20261026133106_plant/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -314,6 +314,34 @@ try {
|
||||
const [{ n: mathChapters }] = await client`select count(*)::int as n from math.chapters`;
|
||||
if (mathChapters !== 100)
|
||||
throw new Error(`math.chapters has ${mathChapters} rows, expected the 100 Napkin chapters`);
|
||||
// Cargill plant tracker (owner-only): app role read/write, personal_ro none, and the app can run
|
||||
// plant.apply_entries (the bulk import). 2 = entries, links; the migration seeds the factory guide.
|
||||
const PLANT_TABLES = 3;
|
||||
const [plant] = await client`
|
||||
select
|
||||
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
|
||||
count(*) filter (
|
||||
where exists (
|
||||
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
|
||||
where r.rolname = 'personal_ro'
|
||||
)
|
||||
) as ro_read,
|
||||
count(*) as tables,
|
||||
has_schema_privilege('public', 'plant', 'usage') as public_usage,
|
||||
has_function_privilege('plant.apply_entries(jsonb)', 'execute') as can_apply
|
||||
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
||||
where n.nspname = 'plant' and c.relkind = 'r'
|
||||
`;
|
||||
if (
|
||||
Number(plant.tables) !== PLANT_TABLES ||
|
||||
Number(plant.app_rw) !== PLANT_TABLES ||
|
||||
Number(plant.ro_read) !== 0 ||
|
||||
plant.public_usage ||
|
||||
!plant.can_apply
|
||||
)
|
||||
throw new Error(`plant schema tables/grants are wrong: ${JSON.stringify(plant)}`);
|
||||
const [{ n: plantEntries }] = await client`select count(*)::int as n from plant.entries`;
|
||||
if (!plantEntries) throw new Error('plant.entries is empty, expected the seeded factory guide');
|
||||
// Energy tracker reads the ned-collector tables (SELECT only).
|
||||
const [energy] = await client`
|
||||
select count(*) as hours, max(valid_from) as latest from energy.ned_utilization
|
||||
@@ -323,7 +351,7 @@ try {
|
||||
if (!Number(energy.hours))
|
||||
console.warn('Warning: energy.ned_utilization has no hourly data from the last 7 days');
|
||||
console.log(
|
||||
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, stats: ${stats.tables} tables, ai: ${ai.tables} tables, movies: ${movies.tables} tables, worldtodo: ${worldtodo.tables} tables (${worldtodoTasks} tasks), notes: ${notes.tables} tables, math: ${math.tables} tables (${mathChapters} chapters), app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
|
||||
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, stats: ${stats.tables} tables, ai: ${ai.tables} tables, movies: ${movies.tables} tables, worldtodo: ${worldtodo.tables} tables (${worldtodoTasks} tasks), notes: ${notes.tables} tables, math: ${math.tables} tables (${mathChapters} chapters), plant: ${plant.tables} tables (${plantEntries} entries), app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
|
||||
);
|
||||
const [catalog] = await client`
|
||||
select
|
||||
|
||||
79
src/lib/plant.test.ts
Normal file
79
src/lib/plant.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
KIND_TAB,
|
||||
PLANT_KINDS,
|
||||
entryHref,
|
||||
kindFromSegment,
|
||||
kindFromTab,
|
||||
layoutFlow,
|
||||
matches,
|
||||
slugify,
|
||||
tabHref
|
||||
} from './plant';
|
||||
|
||||
describe('plant tracker', () => {
|
||||
it('lays the flow out top to bottom, ignoring dashed loops', () => {
|
||||
// oil → clean → split → col → dimer, dimer ⇢ col (dashed), clean ⇢ ester, col → ester
|
||||
const nodes = [1, 2, 3, 4, 5, 6].map((id) => ({ id, sort: id }));
|
||||
const edges = [
|
||||
{ from: 1, to: 2, dashed: false },
|
||||
{ from: 2, to: 3, dashed: false },
|
||||
{ from: 3, to: 4, dashed: false },
|
||||
{ from: 4, to: 5, dashed: false },
|
||||
{ from: 5, to: 4, dashed: true },
|
||||
{ from: 2, to: 6, dashed: true },
|
||||
{ from: 4, to: 6, dashed: false }
|
||||
];
|
||||
const { placed, width, height } = layoutFlow(nodes, edges);
|
||||
const layer = Object.fromEntries(placed.map((p) => [p.id, p.layer]));
|
||||
expect(layer).toEqual({ 1: 0, 2: 1, 3: 2, 4: 3, 5: 4, 6: 4 });
|
||||
expect(width).toBeGreaterThan(0);
|
||||
expect(height).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('survives a solid cycle', () => {
|
||||
const { placed } = layoutFlow(
|
||||
[
|
||||
{ id: 1, sort: 0 },
|
||||
{ id: 2, sort: 1 }
|
||||
],
|
||||
[
|
||||
{ from: 1, to: 2, dashed: false },
|
||||
{ from: 2, to: 1, dashed: false }
|
||||
]
|
||||
);
|
||||
expect(placed.map((p) => p.layer)).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('maps kinds to URLs and back', () => {
|
||||
expect(entryHref({ kind: 'process_step', slug: 'splitting' })).toBe(
|
||||
'/trackers/cargill/process-step/splitting'
|
||||
);
|
||||
expect(kindFromSegment('process-step')).toBe('process_step');
|
||||
expect(kindFromSegment('nope')).toBeNull();
|
||||
expect(slugify('Lab (QC) – Ëster')).toBe('lab-qc-ester');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tabs and search', () => {
|
||||
it('maps kinds to tabs and back', () => {
|
||||
for (const k of PLANT_KINDS) expect(kindFromTab(KIND_TAB[k])).toBe(k);
|
||||
expect(kindFromTab('nope')).toBeNull();
|
||||
expect(kindFromTab(null)).toBeNull();
|
||||
expect(tabHref('process_step')).toBe('/trackers/cargill?tab=process');
|
||||
expect(tabHref(null)).toBe('/trackers/cargill');
|
||||
});
|
||||
it('matches all words, ignoring case and accents', () => {
|
||||
const e = {
|
||||
title: 'Dimer acids',
|
||||
slug: 'dimer-acids',
|
||||
summary: 'Twee vetzuren',
|
||||
body: null,
|
||||
tags: ['product']
|
||||
};
|
||||
expect(matches(e, 'DIMER twee')).toBe(true);
|
||||
expect(matches(e, 'dimer ester')).toBe(false);
|
||||
expect(matches({ ...e, title: 'Crème' }, 'creme')).toBe(true);
|
||||
expect(matches(e, ' ')).toBe(true);
|
||||
});
|
||||
});
|
||||
184
src/lib/plant.ts
Normal file
184
src/lib/plant.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
// Cargill plant tracker (/trackers/cargill): what the Cargill Bioindustrial plant does.
|
||||
// Shared by the server (loads, validation) and the pages. Owner-only: the whole path is in
|
||||
// ownerOnlyPrefixes (#lib/server/owner), so everyone else gets a 404.
|
||||
|
||||
/** Entry kinds (kept in sync with the entries_kind_check constraint in the plant migration). */
|
||||
export const PLANT_KINDS = [
|
||||
'process_step',
|
||||
'product',
|
||||
'department',
|
||||
'equipment',
|
||||
'glossary',
|
||||
'question',
|
||||
'note',
|
||||
'person_role'
|
||||
] as const;
|
||||
export type PlantKind = (typeof PLANT_KINDS)[number];
|
||||
|
||||
export const KIND_LABEL: Record<PlantKind, { one: string; many: string }> = {
|
||||
process_step: { one: 'process step', many: 'process' },
|
||||
product: { one: 'product', many: 'products' },
|
||||
department: { one: 'department', many: 'departments' },
|
||||
equipment: { one: 'equipment', many: 'equipment' },
|
||||
glossary: { one: 'glossary word', many: 'glossary' },
|
||||
question: { one: 'question', many: 'questions' },
|
||||
note: { one: 'note', many: 'notes' },
|
||||
person_role: { one: 'role', many: 'roles' }
|
||||
};
|
||||
|
||||
/** Tile gradient per kind (colourful icons on a black-and-white page). */
|
||||
export const KIND_COLOR: Record<PlantKind, readonly [string, string]> = {
|
||||
process_step: ['#a78bfa', '#6d28d9'],
|
||||
product: ['#fcd34d', '#d97706'],
|
||||
department: ['#7dd3fc', '#0369a1'],
|
||||
equipment: ['#fdba74', '#c2410c'],
|
||||
glossary: ['#6ee7b7', '#047857'],
|
||||
question: ['#f9a8d4', '#be185d'],
|
||||
note: ['#d4d4d8', '#52525b'],
|
||||
person_role: ['#93c5fd', '#1d4ed8']
|
||||
};
|
||||
|
||||
/** confirmed: checked at work. assumed: from the guide / general knowledge. open: unknown. */
|
||||
export const CONFIDENCES = ['confirmed', 'assumed', 'open'] as const;
|
||||
export type Confidence = (typeof CONFIDENCES)[number];
|
||||
|
||||
export const RELATIONS = ['feeds_into', 'made_in', 'uses', 'part_of', 'related'] as const;
|
||||
export type Relation = (typeof RELATIONS)[number];
|
||||
|
||||
/** How a link reads from each end: "A feeds into B" / "B is fed by A". */
|
||||
export const RELATION_LABEL: Record<Relation, { out: string; in: string }> = {
|
||||
feeds_into: { out: 'feeds into', in: 'fed by' },
|
||||
made_in: { out: 'made in', in: 'makes' },
|
||||
uses: { out: 'uses', in: 'used by' },
|
||||
part_of: { out: 'part of', in: 'includes' },
|
||||
related: { out: 'related', in: 'related' }
|
||||
};
|
||||
|
||||
export const slugify = (s: string) =>
|
||||
s
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
|
||||
export const entryHref = (e: { kind: string; slug: string }) =>
|
||||
`/trackers/cargill/${e.kind.replace('_', '-')}/${e.slug}`;
|
||||
/** URL segment ("process-step") back to the kind ("process_step"). */
|
||||
export const kindFromSegment = (seg: string): PlantKind | null => {
|
||||
const k = seg.replace(/-/g, '_');
|
||||
return (PLANT_KINDS as readonly string[]).includes(k) ? (k as PlantKind) : null;
|
||||
};
|
||||
|
||||
// ---- Process flow layout (pure, unit-tested) -------------------------------------------
|
||||
|
||||
export type FlowNode = { id: number; sort: number };
|
||||
export type FlowEdge = { from: number; to: number; dashed: boolean };
|
||||
export type Placed = { id: number; layer: number; x: number; y: number };
|
||||
|
||||
export const FLOW = { w: 160, h: 50, gapX: 16, gapY: 46, pad: 8 } as const;
|
||||
|
||||
/**
|
||||
* Layered top-to-bottom layout. Layers are the longest path over solid edges (dashed ones,
|
||||
* like "sort again" loops, don't push nodes down; real cycles are broken by DFS order).
|
||||
* Within a layer nodes sit near their parents (barycentre), ties by sort order.
|
||||
*/
|
||||
export function layoutFlow(nodes: FlowNode[], edges: FlowEdge[]) {
|
||||
const ids = new Set(nodes.map((n) => n.id));
|
||||
const solid = edges.filter((e) => !e.dashed && ids.has(e.from) && ids.has(e.to));
|
||||
const sort = new Map(nodes.map((n) => [n.id, n.sort]));
|
||||
const out = new Map<number, number[]>();
|
||||
for (const e of solid) out.set(e.from, [...(out.get(e.from) ?? []), e.to]);
|
||||
// Drop back edges (cycles) with a DFS in sort order.
|
||||
const state = new Map<number, 1 | 2>();
|
||||
const keep: FlowEdge[] = [];
|
||||
const order = [...nodes].sort((a, b) => a.sort - b.sort || a.id - b.id);
|
||||
const visit = (id: number) => {
|
||||
state.set(id, 1);
|
||||
for (const to of out.get(id) ?? []) {
|
||||
if (state.get(to) === 1) continue; // back edge
|
||||
keep.push({ from: id, to, dashed: false });
|
||||
if (!state.has(to)) visit(to);
|
||||
}
|
||||
state.set(id, 2);
|
||||
};
|
||||
for (const n of order) if (!state.has(n.id)) visit(n.id);
|
||||
const layer = new Map(nodes.map((n) => [n.id, 0]));
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
let changed = false;
|
||||
for (const e of keep) {
|
||||
const l = layer.get(e.from)! + 1;
|
||||
if (l > layer.get(e.to)!) {
|
||||
layer.set(e.to, l);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) break;
|
||||
}
|
||||
const layers: number[][] = [];
|
||||
for (const n of order) (layers[layer.get(n.id)!] ??= []).push(n.id);
|
||||
const xIndex = new Map<number, number>();
|
||||
const parents = new Map<number, number[]>();
|
||||
for (const e of keep) parents.set(e.to, [...(parents.get(e.to) ?? []), e.from]);
|
||||
for (const row of layers) {
|
||||
if (!row) continue;
|
||||
const bary = (id: number) => {
|
||||
const ps = (parents.get(id) ?? []).filter((p) => xIndex.has(p));
|
||||
return ps.length ? ps.reduce((s, p) => s + xIndex.get(p)!, 0) / ps.length : Infinity;
|
||||
};
|
||||
row.sort((a, b) => {
|
||||
const d = bary(a) - bary(b);
|
||||
return Number.isFinite(d) && d !== 0 ? d : sort.get(a)! - sort.get(b)! || a - b;
|
||||
});
|
||||
row.forEach((id, i) => xIndex.set(id, i - (row.length - 1) / 2));
|
||||
}
|
||||
const widest = Math.max(1, ...layers.map((r) => r?.length ?? 0));
|
||||
const width = widest * FLOW.w + (widest - 1) * FLOW.gapX + 2 * FLOW.pad;
|
||||
const rows = layers.length;
|
||||
const height = rows * FLOW.h + Math.max(0, rows - 1) * FLOW.gapY + 2 * FLOW.pad;
|
||||
const placed: Placed[] = nodes.map((n) => {
|
||||
const l = layer.get(n.id)!;
|
||||
return {
|
||||
id: n.id,
|
||||
layer: l,
|
||||
x: width / 2 + xIndex.get(n.id)! * (FLOW.w + FLOW.gapX) - FLOW.w / 2,
|
||||
y: FLOW.pad + l * (FLOW.h + FLOW.gapY)
|
||||
};
|
||||
});
|
||||
return { placed, width, height };
|
||||
}
|
||||
|
||||
// ---- Tabs ------------------------------------------------------------------------------
|
||||
|
||||
/** ?tab= value per kind on /trackers/cargill (no tab = the overview). */
|
||||
export const KIND_TAB: Record<PlantKind, string> = {
|
||||
process_step: 'process',
|
||||
product: 'products',
|
||||
department: 'departments',
|
||||
equipment: 'equipment',
|
||||
glossary: 'glossary',
|
||||
question: 'questions',
|
||||
note: 'notes',
|
||||
person_role: 'roles'
|
||||
};
|
||||
export const kindFromTab = (tab: string | null): PlantKind | null =>
|
||||
(Object.entries(KIND_TAB).find(([, t]) => t === tab)?.[0] as PlantKind | undefined) ?? null;
|
||||
export const tabHref = (kind: PlantKind | null) =>
|
||||
kind ? `/trackers/cargill?tab=${KIND_TAB[kind]}` : '/trackers/cargill';
|
||||
|
||||
/** Case- and accent-insensitive match over title, slug, summary, tags and body. */
|
||||
export function matches(
|
||||
e: { title: string; slug: string; summary: string | null; body: string | null; tags: string[] },
|
||||
q: string
|
||||
) {
|
||||
const norm = (s: string) =>
|
||||
s
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase();
|
||||
const words = norm(q).split(/\s+/).filter(Boolean);
|
||||
if (!words.length) return true;
|
||||
const hay = norm([e.title, e.slug, e.summary ?? '', e.tags.join(' '), e.body ?? ''].join(' '));
|
||||
return words.every((w) => hay.includes(w));
|
||||
}
|
||||
46
src/lib/plant/ConfidencePill.svelte
Normal file
46
src/lib/plant/ConfidencePill.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import type { Confidence } from '#lib/plant.js';
|
||||
let { value }: { value: Confidence } = $props();
|
||||
const TITLE: Record<Confidence, string> = {
|
||||
confirmed: 'Confirmed: checked or certain',
|
||||
assumed: 'Assumed: from the guide, not yet checked at work',
|
||||
open: 'Open: still unknown'
|
||||
};
|
||||
</script>
|
||||
|
||||
<span class="pill {value}" title={TITLE[value]}>{value}</span>
|
||||
|
||||
<style>
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.64rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 600;
|
||||
padding: 0.08rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pill::before {
|
||||
content: '';
|
||||
width: 0.38rem;
|
||||
height: 0.38rem;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
}
|
||||
.confirmed {
|
||||
color: #10b981;
|
||||
border-color: rgb(16 185 129 / 0.4);
|
||||
}
|
||||
.assumed {
|
||||
color: #f59e0b;
|
||||
border-color: rgb(245 158 11 / 0.4);
|
||||
}
|
||||
.open {
|
||||
color: #ec4899;
|
||||
border-color: rgb(236 72 153 / 0.4);
|
||||
}
|
||||
</style>
|
||||
242
src/lib/plant/EntryForm.svelte
Normal file
242
src/lib/plant/EntryForm.svelte
Normal file
@@ -0,0 +1,242 @@
|
||||
<script lang="ts">
|
||||
// Owner form to add or edit one entry (markdown body). Posts to ?/save on the current page;
|
||||
// both /trackers/cargill pages share the actions (#lib/server/plant-actions).
|
||||
import { enhance, type SubmitFunction } from '$app/forms';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { CONFIDENCES, KIND_LABEL, PLANT_KINDS, type PlantKind } from '#lib/plant.js';
|
||||
|
||||
type Entry = {
|
||||
id: number;
|
||||
kind: PlantKind;
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
body: string | null;
|
||||
parentId: number | null;
|
||||
tags: string[];
|
||||
sortOrder: number;
|
||||
source: string | null;
|
||||
confidence: string;
|
||||
};
|
||||
let {
|
||||
entry,
|
||||
kind = 'note',
|
||||
entries,
|
||||
onclose,
|
||||
openAfterCreate = false
|
||||
}: {
|
||||
entry?: Entry;
|
||||
kind?: PlantKind;
|
||||
entries: Pick<Entry, 'id' | 'kind' | 'title'>[];
|
||||
onclose: () => void;
|
||||
/** After adding, go to the new entry's page (used by the generic "add entry" form). */
|
||||
openAfterCreate?: boolean;
|
||||
} = $props();
|
||||
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let chosenKind = $state<PlantKind>(entry?.kind ?? kind);
|
||||
const defaultConfidence = $derived(chosenKind === 'question' ? 'open' : 'assumed');
|
||||
|
||||
const submit: SubmitFunction = () => {
|
||||
busy = true;
|
||||
error = null;
|
||||
return async ({ result, update }) => {
|
||||
busy = false;
|
||||
if (result.type === 'failure') {
|
||||
error = String(result.data?.message ?? 'Could not save');
|
||||
return;
|
||||
}
|
||||
if (result.type === 'success') {
|
||||
const href = result.data?.href as string | undefined;
|
||||
const here = location.pathname;
|
||||
if (href && !entry && openAfterCreate)
|
||||
await goto(resolve(href as '/'), { refreshAll: true });
|
||||
// Editing on the entry's own page may change its slug or kind: follow it.
|
||||
else if (href && entry && here.startsWith('/trackers/cargill/') && here !== href)
|
||||
await goto(resolve(href as '/'), { replace: true, refreshAll: true });
|
||||
else await update({ reset: false, navigate: false });
|
||||
onclose();
|
||||
return;
|
||||
}
|
||||
await update({ navigate: false });
|
||||
};
|
||||
};
|
||||
const parents = $derived(entries.filter((e) => e.id !== entry?.id));
|
||||
</script>
|
||||
|
||||
<form class="edit" method="POST" action="?/save" use:enhance={submit}>
|
||||
{#if entry}<input type="hidden" name="id" value={entry.id} />{/if}
|
||||
<label
|
||||
>kind<select name="kind" bind:value={chosenKind}>
|
||||
{#each PLANT_KINDS as k (k)}<option value={k}>{KIND_LABEL[k].one}</option>{/each}
|
||||
</select></label
|
||||
>
|
||||
<label class="span2"
|
||||
>title<input
|
||||
name="title"
|
||||
value={entry?.title ?? ''}
|
||||
maxlength="200"
|
||||
required
|
||||
placeholder={chosenKind === 'question' ? 'What do I want to know?' : 'Name'}
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>confidence<select name="confidence" value={entry?.confidence ?? defaultConfidence}>
|
||||
{#each CONFIDENCES as c (c)}<option value={c}>{c}</option>{/each}
|
||||
</select></label
|
||||
>
|
||||
<label class="wide"
|
||||
>summary<input
|
||||
name="summary"
|
||||
value={entry?.summary ?? ''}
|
||||
maxlength="500"
|
||||
placeholder="one line"
|
||||
/></label
|
||||
>
|
||||
<label class="wide"
|
||||
>details (markdown)<textarea name="body" rows="7" value={entry?.body ?? ''} maxlength="50000"
|
||||
></textarea></label
|
||||
>
|
||||
<label class="span2"
|
||||
>tags<input
|
||||
name="tags"
|
||||
value={entry?.tags.join(', ') ?? ''}
|
||||
maxlength="400"
|
||||
placeholder="comma, separated"
|
||||
/></label
|
||||
>
|
||||
<label class="span2"
|
||||
>part of<select name="parent_id" value={entry?.parentId ?? ''}>
|
||||
<option value="">—</option>
|
||||
{#each parents as p (p.id)}<option value={p.id}>{p.title} · {KIND_LABEL[p.kind].one}</option
|
||||
>{/each}
|
||||
</select></label
|
||||
>
|
||||
<label
|
||||
>slug<input
|
||||
name="slug"
|
||||
value={entry?.slug ?? ''}
|
||||
maxlength="80"
|
||||
placeholder="from title"
|
||||
pattern="[a-z0-9\-]*"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>order<input
|
||||
name="sort_order"
|
||||
type="number"
|
||||
value={entry?.sortOrder ?? ''}
|
||||
placeholder="0"
|
||||
/></label
|
||||
>
|
||||
<label class="span2"
|
||||
>source<input
|
||||
name="source"
|
||||
value={entry?.source ?? ''}
|
||||
maxlength="200"
|
||||
placeholder="where this came from"
|
||||
/></label
|
||||
>
|
||||
<div class="actions">
|
||||
<button type="button" class="ghost" onclick={onclose}>cancel</button>
|
||||
<button type="submit" class="primary" disabled={busy}>{entry ? 'save' : 'add'}</button>
|
||||
</div>
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.edit {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
align-items: end;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--site-bg);
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--site-muted);
|
||||
min-width: 0;
|
||||
}
|
||||
.span2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
color: var(--site-text);
|
||||
background: var(--site-surface);
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
input,
|
||||
select {
|
||||
height: 2.1rem;
|
||||
}
|
||||
textarea {
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.actions {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.error {
|
||||
grid-column: 1 / -1;
|
||||
color: #f87171;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
button {
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.primary {
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
.primary:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.ghost {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid var(--site-border);
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.ghost:hover {
|
||||
color: var(--site-text);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.edit {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
256
src/lib/plant/FlowDiagram.svelte
Normal file
256
src/lib/plant/FlowDiagram.svelte
Normal file
@@ -0,0 +1,256 @@
|
||||
<script lang="ts">
|
||||
// The plant's process flow, drawn from feeds_into links: every entry on such a link is a
|
||||
// node (process steps as cards, raw materials / outputs as dashed pills), laid out top to
|
||||
// bottom by layoutFlow (#lib/plant). Dashed links are optional paths or loops back.
|
||||
import { FLOW, KIND_COLOR, entryHref, layoutFlow, type PlantKind } from '#lib/plant.js';
|
||||
|
||||
type Node = {
|
||||
id: number;
|
||||
kind: PlantKind;
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
sortOrder: number;
|
||||
};
|
||||
type Link = {
|
||||
id: number;
|
||||
fromId: number;
|
||||
toId: number;
|
||||
relation: string;
|
||||
label: string | null;
|
||||
dashed: boolean;
|
||||
confidence?: string;
|
||||
};
|
||||
let { entries, links }: { entries: Node[]; links: Link[] } = $props();
|
||||
|
||||
const flow = $derived.by(() => {
|
||||
// Only the process: feeds_into links that touch a process step (not product -> department).
|
||||
const kindOf = new Map(entries.map((e) => [e.id, e.kind]));
|
||||
const feeds = links.filter(
|
||||
(l) =>
|
||||
l.relation === 'feeds_into' &&
|
||||
(kindOf.get(l.fromId) === 'process_step' || kindOf.get(l.toId) === 'process_step')
|
||||
);
|
||||
const ids = new Set(feeds.flatMap((l) => [l.fromId, l.toId]));
|
||||
// Order: process steps by their sort order; inputs first, outputs last.
|
||||
const nodes = entries
|
||||
.filter((e) => ids.has(e.id))
|
||||
.map((e) => ({
|
||||
...e,
|
||||
sort:
|
||||
(e.kind === 'process_step' ? 1000 : feeds.some((l) => l.toId === e.id) ? 5000 : 0) +
|
||||
e.sortOrder
|
||||
}));
|
||||
const { placed, width, height } = layoutFlow(
|
||||
nodes.map((n) => ({ id: n.id, sort: n.sort })),
|
||||
feeds.map((l) => ({ from: l.fromId, to: l.toId, dashed: l.dashed }))
|
||||
);
|
||||
const pos = new Map(placed.map((p) => [p.id, p]));
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]));
|
||||
// Spread several edges over a node's bottom (out) and top (in) edge.
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, rebuilt on every derive
|
||||
const outs = new Map<number, Link[]>();
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, rebuilt on every derive
|
||||
const ins = new Map<number, Link[]>();
|
||||
for (const l of feeds) {
|
||||
outs.set(l.fromId, [...(outs.get(l.fromId) ?? []), l]);
|
||||
ins.set(l.toId, [...(ins.get(l.toId) ?? []), l]);
|
||||
}
|
||||
const port = (list: Link[], l: Link, x: (o: Link) => number) => {
|
||||
const sorted = [...list].sort((a, b) => x(a) - x(b));
|
||||
const i = sorted.indexOf(l);
|
||||
return (i - (sorted.length - 1) / 2) * Math.min(22, (FLOW.w - 30) / sorted.length);
|
||||
};
|
||||
const cx = (id: number) => pos.get(id)!.x + FLOW.w / 2;
|
||||
const placedNodes = placed.map((p) => ({ ...p, node: byId.get(p.id)! }));
|
||||
const mid = width / 2;
|
||||
const M = 12; // clearance between a lane and a node
|
||||
let minX = 0;
|
||||
let maxX = width;
|
||||
const edges = feeds.map((l) => {
|
||||
const a = pos.get(l.fromId)!;
|
||||
const b = pos.get(l.toId)!;
|
||||
let d: string;
|
||||
let lx: number;
|
||||
let ly: number;
|
||||
if (b.layer > a.layer) {
|
||||
const x1 = cx(l.fromId) + port(outs.get(l.fromId)!, l, (o) => cx(o.toId));
|
||||
const y1 = a.y + FLOW.h;
|
||||
const x2 = cx(l.toId) + port(ins.get(l.toId)!, l, (o) => cx(o.fromId));
|
||||
const y2 = b.y - 4;
|
||||
// Nodes on the layers in between that a straight-ish line would cross.
|
||||
const between = placed.filter((p) => p.layer > a.layer && p.layer < b.layer);
|
||||
const hits = (x: number) => between.some((p) => x > p.x - M && x < p.x + FLOW.w + M);
|
||||
const lo = Math.min(x1, x2);
|
||||
const hi = Math.max(x1, x2);
|
||||
const crossed = between.filter((p) => p.x + FLOW.w + M > lo && p.x - M < hi);
|
||||
if (!crossed.length) {
|
||||
const my = (y2 - y1) * 0.5;
|
||||
d = `M${x1} ${y1} C${x1} ${y1 + my} ${x2} ${y2 - my} ${x2} ${y2}`;
|
||||
lx = (x1 + x2) / 2;
|
||||
ly = (y1 + y2) / 2;
|
||||
} else {
|
||||
// Long edge: run down a free vertical lane beside the nodes it would cross.
|
||||
let right = Math.max(...crossed.map((p) => p.x + FLOW.w)) + M;
|
||||
while (hits(right)) right += FLOW.gapX;
|
||||
let left = Math.min(...crossed.map((p) => p.x)) - M;
|
||||
while (hits(left)) left -= FLOW.gapX;
|
||||
const cost = (x: number) => Math.abs(x - x1) + Math.abs(x - x2);
|
||||
const lane = cost(left) < cost(right) ? left : right;
|
||||
const g = FLOW.gapY / 2;
|
||||
const ya = y1 + g;
|
||||
const yb = y2 - g;
|
||||
d =
|
||||
`M${x1} ${y1} C${x1} ${y1 + g * 0.7} ${lane} ${ya - g * 0.7} ${lane} ${ya}` +
|
||||
` L${lane} ${yb} C${lane} ${yb + g * 0.7} ${x2} ${y2 - g * 0.7} ${x2} ${y2}`;
|
||||
lx = lane;
|
||||
ly = (ya + yb) / 2;
|
||||
minX = Math.min(minX, lane - 40);
|
||||
maxX = Math.max(maxX, lane + 40);
|
||||
}
|
||||
} else {
|
||||
// Loop back (or sideways): out of the outer side and back in on the same side.
|
||||
const leftSide = a.x + FLOW.w / 2 + (b.x + FLOW.w / 2) < 2 * mid;
|
||||
const x1 = leftSide ? a.x : a.x + FLOW.w;
|
||||
const x2 = leftSide ? b.x - 4 : b.x + FLOW.w + 4;
|
||||
const y1 = a.y + FLOW.h / 2;
|
||||
const y2 = b.y + FLOW.h / 2;
|
||||
const bulge = 30 + Math.abs(y1 - y2) * 0.1;
|
||||
const out = leftSide ? Math.min(x1, x2) - bulge : Math.max(x1, x2) + bulge;
|
||||
d = `M${x1} ${y1} C${out} ${y1} ${out} ${y2} ${x2} ${y2}`;
|
||||
lx = leftSide ? Math.min(x1, x2) - bulge * 0.75 : Math.max(x1, x2) + bulge * 0.75;
|
||||
ly = (y1 + y2) / 2;
|
||||
minX = Math.min(minX, out - 30);
|
||||
maxX = Math.max(maxX, out + 30);
|
||||
}
|
||||
return { ...l, d, lx, ly };
|
||||
});
|
||||
return { nodes: placedNodes, edges, minX, width: maxX - minX, height };
|
||||
});
|
||||
const clip = (s: string | null, n: number) =>
|
||||
!s ? '' : s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s;
|
||||
</script>
|
||||
|
||||
{#if flow.nodes.length}
|
||||
<svg
|
||||
class="flow"
|
||||
viewBox="{flow.minX} 0 {flow.width} {flow.height}"
|
||||
style:max-width="{flow.width}px"
|
||||
role="img"
|
||||
aria-label="Process flow of the plant"
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="plant-arrow"
|
||||
viewBox="0 0 10 10"
|
||||
refX="8"
|
||||
refY="5"
|
||||
markerWidth="7"
|
||||
markerHeight="7"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M1 1L9 5L1 9z" class="arrowhead" />
|
||||
</marker>
|
||||
</defs>
|
||||
{#each flow.edges as e (e.id)}
|
||||
<path
|
||||
class={['edge', { dashed: e.dashed || (e.confidence ?? 'confirmed') !== 'confirmed' }]}
|
||||
d={e.d}
|
||||
marker-end="url(#plant-arrow)"
|
||||
/>
|
||||
{/each}
|
||||
{#each flow.edges as e (e.id)}
|
||||
{#if e.label}
|
||||
{@const w = e.label.length * 5.4 + 10}
|
||||
<g class="elabel" transform="translate({e.lx} {e.ly})">
|
||||
<rect x={-w / 2} y="-8" width={w} height="16" rx="8" />
|
||||
<text text-anchor="middle" dy="3.5">{e.label}</text>
|
||||
</g>
|
||||
{/if}
|
||||
{/each}
|
||||
{#each flow.nodes as p (p.id)}
|
||||
{@const n = p.node}
|
||||
{@const step = n.kind === 'process_step'}
|
||||
<a href={entryHref(n)} class="node-link">
|
||||
<g class={['node', { step, terminal: !step }]} transform="translate({p.x} {p.y})">
|
||||
<title>{n.title}{n.summary ? ` — ${n.summary}` : ''}</title>
|
||||
<rect width={FLOW.w} height={FLOW.h} rx={step ? 10 : 25} />
|
||||
<circle cx="14" cy={FLOW.h / 2} r="4.5" fill={KIND_COLOR[n.kind][1]} />
|
||||
<text class="t" x="25" y={n.summary ? 21 : 29}>{clip(n.title, 20)}</text>
|
||||
{#if n.summary}<text class="s" x="25" y="36">{clip(n.summary, 26)}</text>{/if}
|
||||
</g>
|
||||
</a>
|
||||
{/each}
|
||||
</svg>
|
||||
{:else}
|
||||
<p class="empty">No flow yet: link process steps with “feeds into”.</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.flow {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
overflow: visible;
|
||||
}
|
||||
.edge {
|
||||
fill: none;
|
||||
stroke: var(--site-muted);
|
||||
stroke-opacity: 0.7;
|
||||
stroke-width: 1.4;
|
||||
}
|
||||
.edge.dashed {
|
||||
stroke-dasharray: 4 4;
|
||||
stroke-opacity: 0.55;
|
||||
}
|
||||
.arrowhead {
|
||||
fill: var(--site-muted);
|
||||
}
|
||||
.elabel rect {
|
||||
fill: var(--site-bg);
|
||||
stroke: var(--site-border);
|
||||
}
|
||||
.elabel text {
|
||||
font-size: 9.5px;
|
||||
fill: var(--site-muted);
|
||||
}
|
||||
.node rect {
|
||||
fill: var(--site-surface);
|
||||
stroke: var(--site-border);
|
||||
stroke-width: 1.2;
|
||||
transition:
|
||||
stroke 0.15s,
|
||||
fill 0.15s;
|
||||
}
|
||||
.node.terminal rect {
|
||||
fill: var(--site-bg);
|
||||
stroke-dasharray: 3 3;
|
||||
}
|
||||
.node-link:hover rect,
|
||||
.node-link:focus-visible rect {
|
||||
stroke: var(--site-text);
|
||||
}
|
||||
.node-link:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.t {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
fill: var(--site-text);
|
||||
}
|
||||
.s {
|
||||
font-size: 10px;
|
||||
fill: var(--site-muted);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
/* Keep the labels readable on phones: the card scrolls sideways instead. */
|
||||
.flow {
|
||||
min-width: 30rem;
|
||||
}
|
||||
}
|
||||
.empty {
|
||||
color: var(--site-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
51
src/lib/plant/KindIcon.svelte
Normal file
51
src/lib/plant/KindIcon.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
// A small gradient tile with the kind's icon (colourful icons on a black-and-white page).
|
||||
import { KIND_COLOR, KIND_LABEL, type PlantKind } from '#lib/plant.js';
|
||||
import Workflow from 'lucide-svelte/icons/workflow';
|
||||
import Package from 'lucide-svelte/icons/package';
|
||||
import Building2 from 'lucide-svelte/icons/building-2';
|
||||
import Wrench from 'lucide-svelte/icons/wrench';
|
||||
import BookA from 'lucide-svelte/icons/book-a';
|
||||
import CircleQuestionMark from 'lucide-svelte/icons/circle-question-mark';
|
||||
import StickyNote from 'lucide-svelte/icons/sticky-note';
|
||||
import UserRound from 'lucide-svelte/icons/user-round';
|
||||
|
||||
let { kind, size = 22 }: { kind: PlantKind; size?: number } = $props();
|
||||
|
||||
const ICONS = {
|
||||
process_step: Workflow,
|
||||
product: Package,
|
||||
department: Building2,
|
||||
equipment: Wrench,
|
||||
glossary: BookA,
|
||||
question: CircleQuestionMark,
|
||||
note: StickyNote,
|
||||
person_role: UserRound
|
||||
} as const;
|
||||
const Icon = $derived(ICONS[kind] ?? StickyNote);
|
||||
const colors = $derived(KIND_COLOR[kind] ?? KIND_COLOR.note);
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="kind-icon"
|
||||
style:--from={colors[0]}
|
||||
style:--to={colors[1]}
|
||||
style:--size="{size}px"
|
||||
title={KIND_LABEL[kind]?.one ?? kind}
|
||||
aria-hidden="true"><Icon size={Math.round(size * 0.58)} strokeWidth={2.25} /></span
|
||||
>
|
||||
|
||||
<style>
|
||||
.kind-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
border-radius: calc(var(--size) * 0.3);
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--from), var(--to));
|
||||
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.12);
|
||||
}
|
||||
</style>
|
||||
@@ -387,6 +387,13 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
|
||||
external: false,
|
||||
private: true
|
||||
},
|
||||
{
|
||||
name: 'cargill',
|
||||
subtitle: 'what the Cargill Bioindustrial plant in Gouda does: process, products and words',
|
||||
href: '/trackers/cargill',
|
||||
external: false,
|
||||
private: true
|
||||
},
|
||||
{
|
||||
name: 'math',
|
||||
subtitle: "working through Evan Chen's Napkin: chapters, exercises and notes",
|
||||
|
||||
@@ -34,3 +34,4 @@ export * from './worldtodo';
|
||||
export * from './osu-goals';
|
||||
export * from './notes';
|
||||
export * from './math';
|
||||
export * from './plant';
|
||||
|
||||
118
src/lib/server/db/schema/plant.ts
Normal file
118
src/lib/server/db/schema/plant.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
boolean,
|
||||
check,
|
||||
index,
|
||||
integer,
|
||||
text,
|
||||
uniqueIndex,
|
||||
type AnyPgColumn
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { plantSchema } from './schemas';
|
||||
import { id, timestamps, timestamptz } from './helpers';
|
||||
|
||||
// Cargill plant tracker (/trackers/cargill, owner-only): what the Cargill Bioindustrial plant
|
||||
// does. One generic `entries` table (departments, products, process steps, equipment, glossary,
|
||||
// questions, notes, roles) plus typed `links` between them. Bots apply JSON with
|
||||
// `select plant.apply_entries('{...}'::jsonb)` (shape documented in the migration and in
|
||||
// docs/plant-tracker.md). Kinds, confidences and relations are kept in sync with #lib/plant.
|
||||
|
||||
export const plantEntries = plantSchema.table(
|
||||
'entries',
|
||||
{
|
||||
id: id(),
|
||||
/** process_step | product | department | equipment | glossary | question | note | person_role */
|
||||
kind: text('kind').notNull(),
|
||||
/** Unique per kind; used in URLs and by apply_entries. */
|
||||
slug: text('slug').notNull(),
|
||||
title: text('title').notNull(),
|
||||
summary: text('summary'),
|
||||
/** Markdown. */
|
||||
body: text('body'),
|
||||
parentId: integer('parent_id').references((): AnyPgColumn => plantEntries.id, {
|
||||
onDelete: 'set null',
|
||||
onUpdate: 'cascade'
|
||||
}),
|
||||
tags: text('tags')
|
||||
.array()
|
||||
.notNull()
|
||||
.default(sql`'{}'::text[]`),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
/** Where it came from, e.g. "page 2026-10-26", "Walter White", "Stan". */
|
||||
source: text('source'),
|
||||
/** confirmed (checked at work) | assumed (from the guide) | open (unknown). */
|
||||
confidence: text('confidence').notNull().default('assumed'),
|
||||
/** Questions: when it was answered (null = still open). */
|
||||
answeredAt: timestamptz('answered_at'),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('entries_kind_slug_key').on(t.kind, t.slug),
|
||||
index('entries_parent_idx').on(t.parentId),
|
||||
check(
|
||||
'entries_kind_check',
|
||||
sql`${t.kind} IN ('process_step','product','department','equipment','glossary','question','note','person_role')`
|
||||
),
|
||||
check('entries_confidence_check', sql`${t.confidence} IN ('confirmed','assumed','open')`),
|
||||
check('entries_slug_check', sql`${t.slug} ~ '^[a-z0-9]+(-[a-z0-9]+)*$'`)
|
||||
]
|
||||
);
|
||||
|
||||
export const plantLinks = plantSchema.table(
|
||||
'links',
|
||||
{
|
||||
id: id(),
|
||||
fromId: integer('from_id')
|
||||
.notNull()
|
||||
.references(() => plantEntries.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
toId: integer('to_id')
|
||||
.notNull()
|
||||
.references(() => plantEntries.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
/** feeds_into | made_in | uses | part_of | related */
|
||||
relation: text('relation').notNull(),
|
||||
/** Short edge label, e.g. "fatty acids". */
|
||||
label: text('label'),
|
||||
/** Drawn dashed in the flow (optional path or a loop back); doesn't push layers down. */
|
||||
dashed: boolean('dashed').notNull().default(false),
|
||||
/** confirmed | assumed | open; not-confirmed links are drawn dashed with a "?". */
|
||||
confidence: text('confidence').notNull().default('confirmed'),
|
||||
createdAt: timestamptz('created_at').notNull().defaultNow()
|
||||
},
|
||||
(t) => [
|
||||
check('links_confidence_check', sql`${t.confidence} IN ('confirmed','assumed','open')`),
|
||||
uniqueIndex('links_from_to_relation_key').on(t.fromId, t.toId, t.relation),
|
||||
index('links_to_idx').on(t.toId),
|
||||
check(
|
||||
'links_relation_check',
|
||||
sql`${t.relation} IN ('feeds_into','made_in','uses','part_of','related')`
|
||||
),
|
||||
check('links_not_self_check', sql`${t.fromId} <> ${t.toId}`)
|
||||
]
|
||||
);
|
||||
|
||||
/** Parents and links whose target isn't in the tracker yet. apply_entries retries them on every
|
||||
* run, so a later batch that adds the target resolves them; the detail page lists them as pending. */
|
||||
export const plantPending = plantSchema.table(
|
||||
'pending',
|
||||
{
|
||||
id: id(),
|
||||
fromId: integer('from_id')
|
||||
.notNull()
|
||||
.references(() => plantEntries.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
/** "parent" or a link relation. */
|
||||
field: text('field').notNull(),
|
||||
/** The reference as given ("slug" or "kind:slug"). */
|
||||
toRef: text('to_ref').notNull(),
|
||||
label: text('label'),
|
||||
dashed: boolean('dashed').notNull().default(false),
|
||||
confidence: text('confidence').notNull().default('confirmed'),
|
||||
createdAt: timestamptz('created_at').notNull().defaultNow()
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('pending_from_field_ref_key').on(t.fromId, t.field, t.toRef),
|
||||
check(
|
||||
'pending_field_check',
|
||||
sql`${t.field} IN ('parent','feeds_into','made_in','uses','part_of','related')`
|
||||
)
|
||||
]
|
||||
);
|
||||
@@ -30,3 +30,4 @@ export const moviesSchema = pgSchema('movies');
|
||||
export const worldtodoSchema = pgSchema('worldtodo');
|
||||
export const notesSchema = pgSchema('notes');
|
||||
export const mathSchema = pgSchema('math');
|
||||
export const plantSchema = pgSchema('plant');
|
||||
|
||||
@@ -43,6 +43,14 @@ describe('owner gate', () => {
|
||||
expect(isOwnerOnlyPath('/trackers/math/__data.json')).toBe(MATH_PRIVATE);
|
||||
expect(isOwnerWritePath('/trackers/math')).toBe(true);
|
||||
});
|
||||
it('gates the whole cargill plant tracker, reads and writes', () => {
|
||||
expect(isOwnerOnlyPath('/trackers/cargill')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/cargill/__data.json')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/cargill/process-step/cleaning')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/cargillx')).toBe(false);
|
||||
expect(isOwnerWritePath('/trackers/cargill')).toBe(true);
|
||||
expect(isOwnerWritePath('/trackers/cargill/glossary/ester')).toBe(true);
|
||||
});
|
||||
it('only lets the owner write in owner areas', () => {
|
||||
expect(isOwnerWritePath('/trackers/books/12')).toBe(true);
|
||||
expect(isOwnerWritePath('/trackers/books')).toBe(true);
|
||||
|
||||
@@ -40,6 +40,8 @@ export const ownerOnlyPrefixes = [
|
||||
// Worldwide todo list: the list is public, the review queue and its API are mine only.
|
||||
'/projects/worldwide-todo/queue',
|
||||
'/projects/worldwide-todo/api/queue',
|
||||
// Cargill plant tracker: what the factory I work at does. Mine only.
|
||||
'/trackers/cargill',
|
||||
// Math tracker (the Napkin): owner-only while MATH_PRIVATE (#lib/math) is on.
|
||||
...(MATH_PRIVATE ? (['/trackers/math'] as const) : [])
|
||||
] as const;
|
||||
@@ -71,7 +73,8 @@ export const ownerWritePrefixes = [
|
||||
'/trackers/notes',
|
||||
'/trackers/ai',
|
||||
'/trackers/movies',
|
||||
'/trackers/math'
|
||||
'/trackers/math',
|
||||
'/trackers/cargill'
|
||||
] as const;
|
||||
|
||||
const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, '');
|
||||
|
||||
27
src/lib/server/plant-actions.ts
Normal file
27
src/lib/server/plant-actions.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Form actions shared by the /trackers/cargill overview and entry pages. Actions don't run the
|
||||
// load, so each one checks the owner again (hooks.server.ts also 404s the whole path).
|
||||
import { requireOwner } from '#lib/server/owner.js';
|
||||
import { addLink, answerQuestion, deleteEntry, removeLink, saveEntry } from '#lib/server/plant.js';
|
||||
import { fail, redirect, type RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
type Handler = (
|
||||
form: FormData
|
||||
) => Promise<{ ok: true; href?: string } | { ok: false; message: string }>;
|
||||
|
||||
const action = (fn: Handler) => async (event: RequestEvent) => {
|
||||
requireOwner(event);
|
||||
const form = await event.request.formData();
|
||||
const r = await fn(form);
|
||||
if (!r.ok) return fail(400, { message: r.message });
|
||||
// Deleting from an entry's own page goes back to the overview.
|
||||
if (form.get('redirect') === '1' && r.href) redirect(303, r.href);
|
||||
return { ok: true as const, href: r.href };
|
||||
};
|
||||
|
||||
export const plantActions = {
|
||||
save: action(saveEntry),
|
||||
delete: action(deleteEntry),
|
||||
answer: action(answerQuestion),
|
||||
link: action(addLink),
|
||||
unlink: action(removeLink)
|
||||
};
|
||||
232
src/lib/server/plant.ts
Normal file
232
src/lib/server/plant.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
// Cargill plant tracker (/trackers/cargill, owner-only): loads entries and links, renders
|
||||
// markdown bodies, and applies owner edits. Every write goes through plant.apply_entries (the
|
||||
// same JSON format bots use, see the plant migration) or a plain delete, so the site and bots
|
||||
// share one code path.
|
||||
import { client } from '#lib/server/db/index.js';
|
||||
import {
|
||||
CONFIDENCES,
|
||||
PLANT_KINDS,
|
||||
RELATIONS,
|
||||
slugify,
|
||||
type Confidence,
|
||||
type PlantKind,
|
||||
type Relation
|
||||
} from '#lib/plant.js';
|
||||
import { Marked } from 'marked';
|
||||
import * as z from 'zod';
|
||||
|
||||
// Markdown bodies. Raw HTML is shown as text, never rendered; only http(s)/mailto/relative links.
|
||||
const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
||||
const marked = new Marked({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
renderer: {
|
||||
html: ({ text }) => escapeHtml(text),
|
||||
link({ href, title, tokens }) {
|
||||
const label = this.parser.parseInline(tokens);
|
||||
if (!/^(https?:|mailto:|\/|#)/i.test(href)) return label;
|
||||
const t = title ? ` title="${escapeHtml(title)}"` : '';
|
||||
const ext = /^(https?:|mailto:)/i.test(href)
|
||||
? ' rel="noopener noreferrer" target="_blank"'
|
||||
: '';
|
||||
return `<a href="${escapeHtml(href)}"${t}${ext}>${label}</a>`;
|
||||
},
|
||||
image: ({ text }) => escapeHtml(text)
|
||||
}
|
||||
});
|
||||
export const renderBody = (md: string) => marked.parse(md, { async: false });
|
||||
|
||||
export type PlantEntry = {
|
||||
id: number;
|
||||
kind: PlantKind;
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
body: string | null;
|
||||
parentId: number | null;
|
||||
tags: string[];
|
||||
sortOrder: number;
|
||||
source: string | null;
|
||||
confidence: Confidence;
|
||||
answeredAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
export type PlantLink = {
|
||||
id: number;
|
||||
fromId: number;
|
||||
toId: number;
|
||||
relation: Relation;
|
||||
label: string | null;
|
||||
dashed: boolean;
|
||||
/** Not-confirmed links show dashed with a "?". */
|
||||
confidence: Confidence;
|
||||
};
|
||||
|
||||
const iso = (d: Date | string | null) => (d ? new Date(d).toISOString() : null);
|
||||
|
||||
export async function loadPlant() {
|
||||
const [entries, links] = await Promise.all([
|
||||
client`SELECT id, kind, slug, title, summary, body, parent_id, tags, sort_order, source,
|
||||
confidence, answered_at, created_at, updated_at
|
||||
FROM plant.entries ORDER BY kind, sort_order, lower(title)`,
|
||||
client`SELECT id, from_id, to_id, relation, label, dashed, confidence
|
||||
FROM plant.links ORDER BY id`
|
||||
]);
|
||||
return {
|
||||
entries: entries.map((e): PlantEntry => ({
|
||||
id: e.id,
|
||||
kind: e.kind,
|
||||
slug: e.slug,
|
||||
title: e.title,
|
||||
summary: e.summary,
|
||||
body: e.body,
|
||||
parentId: e.parent_id,
|
||||
tags: e.tags ?? [],
|
||||
sortOrder: e.sort_order,
|
||||
source: e.source,
|
||||
confidence: e.confidence,
|
||||
answeredAt: iso(e.answered_at),
|
||||
createdAt: iso(e.created_at)!,
|
||||
updatedAt: iso(e.updated_at)!
|
||||
})),
|
||||
links: links.map((l): PlantLink => ({
|
||||
id: l.id,
|
||||
fromId: l.from_id,
|
||||
toId: l.to_id,
|
||||
relation: l.relation,
|
||||
label: l.label,
|
||||
dashed: l.dashed,
|
||||
confidence: l.confidence
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
type Result = { ok: true; href?: string } | { ok: false; message: string };
|
||||
|
||||
const text = (max: number) => z.string().trim().max(max);
|
||||
const entryInput = z.object({
|
||||
id: z.coerce.number().int().positive().optional(),
|
||||
kind: z.enum(PLANT_KINDS),
|
||||
title: text(200).min(1, 'A title is required'),
|
||||
slug: text(80).optional(),
|
||||
summary: text(500).optional(),
|
||||
body: z.string().max(50_000).optional(),
|
||||
tags: text(400).optional(),
|
||||
parent_id: z.union([z.literal(''), z.coerce.number().int().positive()]).optional(),
|
||||
sort_order: z.union([z.literal(''), z.coerce.number().int().min(-100000).max(100000)]).optional(),
|
||||
source: text(200).optional(),
|
||||
confidence: z.enum(CONFIDENCES)
|
||||
});
|
||||
|
||||
const formObject = (form: FormData) => {
|
||||
const o: Record<string, string> = {};
|
||||
for (const [k, v] of form) if (typeof v === 'string') o[k] = v;
|
||||
return o;
|
||||
};
|
||||
const dbMessage = (e: unknown) => {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes('entries_kind_slug_key')) return 'That slug is already used for this kind';
|
||||
if (msg.startsWith('plant')) return msg.replace(/^plant(\.apply_entries)?: /, '');
|
||||
throw e;
|
||||
};
|
||||
|
||||
/** Create (no id) or update (id) one entry from the owner form. */
|
||||
export async function saveEntry(form: FormData): Promise<Result> {
|
||||
const parsed = entryInput.safeParse(formObject(form));
|
||||
if (!parsed.success) return { ok: false, message: parsed.error.issues[0]?.message ?? 'Invalid' };
|
||||
const p = parsed.data;
|
||||
const slug = slugify(p.slug || p.title);
|
||||
if (!slug) return { ok: false, message: 'Use some letters or digits in the title or slug' };
|
||||
const tags = (p.tags ?? '')
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 20);
|
||||
const parentId = typeof p.parent_id === 'number' ? p.parent_id : null;
|
||||
if (parentId && parentId === p.id)
|
||||
return { ok: false, message: 'An entry cannot be its own parent' };
|
||||
try {
|
||||
if (p.id) {
|
||||
// Updates go straight to the row (kind and slug may change, which apply_entries keys on).
|
||||
const rows = await client`UPDATE plant.entries SET
|
||||
kind = ${p.kind}, slug = ${slug}, title = ${p.title},
|
||||
summary = ${p.summary || null}, body = ${p.body?.trim() || null},
|
||||
tags = ${tags}, parent_id = ${parentId},
|
||||
sort_order = ${typeof p.sort_order === 'number' ? p.sort_order : 0},
|
||||
source = ${p.source || null}, confidence = ${p.confidence}
|
||||
WHERE id = ${p.id} RETURNING kind, slug`;
|
||||
if (!rows.length) return { ok: false, message: 'That entry no longer exists' };
|
||||
return {
|
||||
ok: true,
|
||||
href: `/trackers/cargill/${rows[0].kind.replace('_', '-')}/${rows[0].slug}`
|
||||
};
|
||||
}
|
||||
const [exists] =
|
||||
await client`SELECT 1 FROM plant.entries WHERE kind = ${p.kind} AND slug = ${slug}`;
|
||||
if (exists) return { ok: false, message: 'That slug is already used for this kind' };
|
||||
const json: Record<string, unknown> = {
|
||||
kind: p.kind,
|
||||
slug,
|
||||
title: p.title,
|
||||
confidence: p.confidence,
|
||||
source: p.source || 'Stan',
|
||||
tags
|
||||
};
|
||||
if (p.summary) json.summary = p.summary;
|
||||
if (p.body?.trim()) json.body = p.body;
|
||||
if (typeof p.sort_order === 'number') json.sort_order = p.sort_order;
|
||||
await client`SELECT plant.apply_entries(${JSON.stringify({ entries: [json] })}::jsonb)`;
|
||||
if (parentId)
|
||||
await client`UPDATE plant.entries SET parent_id = ${parentId} WHERE kind = ${p.kind} AND slug = ${slug}`;
|
||||
return { ok: true, href: `/trackers/cargill/${p.kind.replace('_', '-')}/${slug}` };
|
||||
} catch (e) {
|
||||
return { ok: false, message: dbMessage(e) };
|
||||
}
|
||||
}
|
||||
|
||||
const idOnly = z.object({ id: z.coerce.number().int().positive() });
|
||||
|
||||
export async function deleteEntry(form: FormData): Promise<Result> {
|
||||
const p = idOnly.safeParse({ id: form.get('id') });
|
||||
if (!p.success) return { ok: false, message: 'Invalid entry' };
|
||||
await client`DELETE FROM plant.entries WHERE id = ${p.data.id}`;
|
||||
return { ok: true, href: '/trackers/cargill' };
|
||||
}
|
||||
|
||||
/** Questions: toggle answered (answered=1 marks it, 0 reopens). */
|
||||
export async function answerQuestion(form: FormData): Promise<Result> {
|
||||
const p = idOnly.safeParse({ id: form.get('id') });
|
||||
if (!p.success) return { ok: false, message: 'Invalid question' };
|
||||
const on = form.get('answered') === '1';
|
||||
await client`UPDATE plant.entries
|
||||
SET answered_at = CASE WHEN ${on}::boolean THEN coalesce(answered_at, now()) END
|
||||
WHERE id = ${p.data.id} AND kind = 'question'`;
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const linkInput = z.object({
|
||||
from_id: z.coerce.number().int().positive(),
|
||||
to_id: z.coerce.number().int().positive(),
|
||||
relation: z.enum(RELATIONS),
|
||||
label: text(80).optional(),
|
||||
dashed: z.literal('1').optional()
|
||||
});
|
||||
|
||||
export async function addLink(form: FormData): Promise<Result> {
|
||||
const parsed = linkInput.safeParse(formObject(form));
|
||||
if (!parsed.success) return { ok: false, message: 'Pick an entry and a relation' };
|
||||
const l = parsed.data;
|
||||
if (l.from_id === l.to_id) return { ok: false, message: 'An entry cannot link to itself' };
|
||||
await client`INSERT INTO plant.links (from_id, to_id, relation, label, dashed)
|
||||
VALUES (${l.from_id}, ${l.to_id}, ${l.relation}, ${l.label || null}, ${l.dashed === '1'})
|
||||
ON CONFLICT (from_id, to_id, relation) DO UPDATE SET label = excluded.label, dashed = excluded.dashed`;
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function removeLink(form: FormData): Promise<Result> {
|
||||
const p = idOnly.safeParse({ id: form.get('id') });
|
||||
if (!p.success) return { ok: false, message: 'Invalid link' };
|
||||
await client`DELETE FROM plant.links WHERE id = ${p.data.id}`;
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -96,6 +96,13 @@ export const trackerIcons: Record<string, TrackerIcon> = {
|
||||
'M20 3v4M22 5h-4M4 17v2M5 18H3'
|
||||
]
|
||||
},
|
||||
cargill: {
|
||||
from: '#a3e635',
|
||||
to: '#4d7c0f',
|
||||
ink: WHITE,
|
||||
// factory: saw-tooth roof, chimney and windows
|
||||
paths: ['M3 20V10l5 3V10l5 3V10l5 3V4h3v16z', 'M7 17h1M11 17h1M15 17h1']
|
||||
},
|
||||
math: {
|
||||
from: '#60a5fa',
|
||||
to: '#1e40af',
|
||||
|
||||
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