Pablan, as it stands

Self-hosted knowledge management for SMEs: a split-screen Markdown editor
whose sections an LLM refines while you write, and RAG question answering
over the documents that result. FastAPI + Postgres/pgvector on the back,
SvelteKit on the front, everything OpenAI-compatible and self-hostable.

Squashed into a single commit; the development history stays local.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
This commit is contained in:
ProfessorNova
2026-09-04 09:21:37 +02:00
co-authored by Claude Opus 5
parent 68d3a43191
commit 784b76baf7
346 changed files with 43430 additions and 0 deletions
@@ -0,0 +1,207 @@
<script lang="ts">
import Building2 from '@lucide/svelte/icons/building-2';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import Globe from '@lucide/svelte/icons/globe';
import KeyRound from '@lucide/svelte/icons/key-round';
import { SvelteSet } from 'svelte/reactivity';
import { api } from '$lib/api/client';
import type { components } from '$lib/api/schema';
import Button from '$lib/components/Button.svelte';
import Popover from '$lib/components/Popover.svelte';
import Select from '$lib/components/Select.svelte';
import { visibilityLabel } from '$lib/documents/presentation';
import { m } from '$lib/paraglide/messages';
// "Who can see this?" — the answer is on the chip, the controls are one
// click behind it. Visibility and the extra departments are the same
// question asked twice, so they are answered in one place; both are the
// owner's call, which is why a reviewer sees the answer and no controls.
type Department = components['schemas']['DepartmentOut'];
type DocumentDetail = components['schemas']['DocumentDetail'];
let {
document: doc,
canManage,
onChanged
}: {
document: DocumentDetail;
canManage: boolean;
onChanged?: () => Promise<void> | void;
} = $props();
let open = $state(false);
let departments = $state<Department[]>([]);
const selected = new SvelteSet<string>();
let busy = $state(false);
let error = $state<string | null>(null);
// A change that would remove the editing admin's own access is held until
// they confirm it; `pending` is what they are being asked about.
let pending = $state<{ visibility?: DocumentDetail['visibility'] } | null>(null);
const shared = $derived(doc.shared_departments);
const shareable = $derived(departments.filter((entry) => entry.id !== doc.department_id));
const VisibilityIcon = $derived(doc.visibility === 'restricted' ? KeyRound : Globe);
$effect(() => {
if (!open) return;
selected.clear();
for (const department of shared) selected.add(department.id);
error = null;
pending = null;
void api.GET('/api/departments').then(({ data }) => (departments = data ?? []));
});
function toggle(id: string) {
if (selected.has(id)) selected.delete(id);
else selected.add(id);
}
async function setVisibility(visibility: DocumentDetail['visibility'], confirmLockout = false) {
busy = true;
error = null;
const { response, error: err } = await api.PATCH('/api/documents/{document_id}', {
params: { path: { document_id: doc.id } },
body: { visibility, confirm_lockout: confirmLockout }
});
busy = false;
if (response.ok) {
pending = null;
await onChanged?.();
return;
}
if ((err as { code?: string })?.code === 'self_lockout_warning' && !confirmLockout) {
pending = { visibility };
return;
}
error = m.visibility_save_failed();
}
async function saveDepartments(confirmLockout = false) {
busy = true;
error = null;
const { response, error: err } = await api.PUT('/api/documents/{document_id}/departments', {
params: { path: { document_id: doc.id } },
body: { department_ids: [...selected], confirm_lockout: confirmLockout }
});
busy = false;
if (response.ok) {
pending = null;
await onChanged?.();
return;
}
if ((err as { code?: string })?.code === 'self_lockout_warning' && !confirmLockout) {
pending = {};
return;
}
error = m.sharing_save_failed();
}
</script>
<Popover bind:open contentClass="w-80" triggerLabel={m.access_popover_title()}>
{#snippet trigger()}
<span
class="flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-ink-muted transition-colors hover:border-border-strong hover:text-ink"
data-testid="access-chip"
>
<VisibilityIcon size={12} />
{visibilityLabel(doc.visibility)}
{#if shared.length > 0}
<span class="text-ink-muted">{m.access_plus_departments({ count: shared.length })}</span>
{/if}
<ChevronDown size={12} class="opacity-60" />
</span>
{/snippet}
<div class="flex flex-col gap-3" data-testid="access-controls">
<p class="text-sm font-medium">{m.access_popover_title()}</p>
{#if canManage}
<label class="flex flex-col gap-1 text-xs text-ink-muted">
{m.visibility_label()}
<Select
value={doc.visibility}
disabled={busy}
onchange={(event) =>
setVisibility(event.currentTarget.value as DocumentDetail['visibility'])}
data-testid="visibility-select"
>
<option value="public">{m.document_visibility_public()}</option>
<option value="department">{m.document_visibility_department()}</option>
<option value="restricted">{m.document_visibility_restricted()}</option>
</Select>
</label>
{:else}
<p class="text-sm">{m.document_visibility_line({ visibility: doc.visibility })}</p>
{/if}
<div class="flex flex-col gap-1">
<p class="text-xs text-ink-muted">{m.access_extra_departments()}</p>
{#if canManage}
{#if shareable.length === 0}
<p class="text-sm text-ink-muted">{m.sharing_none_shareable()}</p>
{:else}
<ul class="flex max-h-44 flex-col overflow-y-auto">
{#each shareable as department (department.id)}
<li>
<label
class="flex cursor-pointer items-center gap-2 rounded-md px-1.5 py-1 text-sm hover:bg-surface-sunken"
>
<input
type="checkbox"
class="accent-accent"
checked={selected.has(department.id)}
onchange={() => toggle(department.id)}
/>
{department.name}
</label>
</li>
{/each}
</ul>
<Button
size="sm"
class="self-start"
disabled={busy}
onclick={() => saveDepartments()}
data-testid="share-save"
>
{m.sharing_save()}
</Button>
{/if}
{:else if shared.length === 0}
<p class="text-sm text-ink-muted">{m.access_no_extra_departments()}</p>
{:else}
<ul class="flex flex-col gap-1 text-sm">
{#each shared as department (department.id)}
<li class="flex items-center gap-1.5">
<Building2 size={13} class="text-ink-muted" />
{department.name}
</li>
{/each}
</ul>
{/if}
</div>
{#if pending}
<div class="flex flex-col gap-2 rounded-lg bg-warning-muted px-3 py-2">
<span class="text-sm text-warning" role="alert">{m.sharing_lockout_warning()}</span>
<div class="flex gap-2">
<Button
variant="danger"
size="sm"
disabled={busy}
onclick={() =>
pending?.visibility ? setVisibility(pending.visibility, true) : saveDepartments(true)}
>
{m.sharing_lockout_confirm()}
</Button>
<Button variant="ghost" size="sm" onclick={() => (pending = null)}>
{m.common_cancel()}
</Button>
</div>
</div>
{/if}
{#if error}
<p role="alert" class="text-sm text-danger">{error}</p>
{/if}
</div>
</Popover>
@@ -0,0 +1,112 @@
<script lang="ts">
import Eye from '@lucide/svelte/icons/eye';
import MessageCircleQuestion from '@lucide/svelte/icons/message-circle-question';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import Button from '$lib/components/Button.svelte';
import Dialog from '$lib/components/Dialog.svelte';
import ReviewRequestForm from '$lib/documents/ReviewRequestForm.svelte';
import { m } from '$lib/paraglide/messages';
// The moment the document becomes readable for everyone it is visible to.
// Offered right here, because this is when the doubt is freshest: if you
// are not sure about a detail, ask someone now — the document stays
// published and carries the question until they answer.
let { open = $bindable(false), documentId }: { open?: boolean; documentId: string } = $props();
let mode = $state<'done' | 'ask'>('done');
let askedName = $state<string | null>(null);
// Reset each time the reward opens (the component stays mounted).
$effect(() => {
if (open) {
mode = 'done';
askedName = null;
}
});
async function view() {
open = false;
await goto(resolve(`/documents/${documentId}`));
}
</script>
<Dialog bind:open title={m.capture_success_title()} data-testid="capture-success">
<div class="flex flex-col items-center gap-4 pt-2 text-center">
<div class="check" aria-hidden="true">
<svg viewBox="0 0 52 52">
<circle cx="26" cy="26" r="24" />
<path d="M15 27l7 7 15-15" />
</svg>
</div>
{#if mode === 'done'}
<p class="max-w-sm text-sm text-ink-muted">{m.capture_success_body()}</p>
<div class="mt-1 flex flex-wrap justify-center gap-2">
<Button onclick={view} data-testid="success-view">
<Eye size={16} />
{m.capture_success_view()}
</Button>
<Button variant="secondary" onclick={() => (mode = 'ask')} data-testid="success-ask">
<MessageCircleQuestion size={16} />
{m.capture_success_ask()}
</Button>
</div>
{:else if askedName}
<p class="text-sm text-ink" data-testid="review-asked">
{m.review_ask_sent({ name: askedName })}
</p>
<Button onclick={view}>{m.capture_success_view()}</Button>
{:else}
<div class="w-full text-left">
<p class="mb-3 text-sm text-ink-muted">{m.review_ask_hint()}</p>
<ReviewRequestForm {documentId} onSent={(name) => void (askedName = name)} />
</div>
<Button variant="ghost" size="sm" onclick={() => (mode = 'done')}>
{m.common_back()}
</Button>
{/if}
</div>
</Dialog>
<style>
.check svg {
width: 4rem;
height: 4rem;
}
.check circle {
fill: none;
stroke: var(--pb-success);
stroke-width: 3;
stroke-dasharray: 151;
stroke-dashoffset: 151;
animation: pb-check-circle 0.5s ease-out forwards;
}
.check path {
fill: none;
stroke: var(--pb-success);
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 40;
stroke-dashoffset: 40;
animation: pb-check-mark 0.35s 0.4s ease-out forwards;
}
@keyframes pb-check-circle {
to {
stroke-dashoffset: 0;
}
}
@keyframes pb-check-mark {
to {
stroke-dashoffset: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.check circle,
.check path {
animation: none;
stroke-dashoffset: 0;
}
}
</style>
@@ -0,0 +1,65 @@
<script lang="ts">
import BookOpen from '@lucide/svelte/icons/book-open';
import MessageCircleQuestion from '@lucide/svelte/icons/message-circle-question';
import PencilLine from '@lucide/svelte/icons/pencil-line';
import { resolve } from '$app/paths';
import type { DocumentRow } from '$lib/documents/list.svelte';
import { formatDate } from '$lib/documents/presentation';
import { m } from '$lib/paraglide/messages';
// A row in a list of a hundred: the title carries it, everything else is
// context in one grey line. Badges are for EXCEPTIONS only — "published",
// "public" and "yours" are the normal case, and repeating them on every
// card turns the two rows that actually need attention into more of the
// same. What is marked here: an unanswered question, a draft nobody else
// can see, a help page shipped with the product.
let { document, departmentName }: { document: DocumentRow; departmentName: string | undefined } =
$props();
const flagged = $derived(document.open_reviews > 0);
</script>
<a
href={resolve(`/documents/${document.id}`)}
class="flex h-full flex-col gap-1.5 rounded-xl border bg-surface-raised px-4 py-3 transition-colors hover:border-border-strong {flagged
? 'border-warning/40'
: 'border-border'}"
>
<p class="truncate font-medium">{document.title}</p>
<p class="truncate text-xs text-ink-muted">
<!-- A search hit shows the section that matched; a browsed row its
department, which is what the filters are about. -->
{document.heading_path || departmentName || m.documents_no_department()}
<span class="opacity-70">
· {m.documents_updated_at({ date: formatDate(document.updated_at) })}
</span>
</p>
{#if flagged || document.status !== 'published' || document.is_builtin}
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{#if document.open_reviews > 0}
<!-- Somebody asked whether this is still right, and nobody has
answered — true of drafts and published documents alike. -->
<span class="flex items-center gap-1 text-warning" data-testid="open-review-badge">
<MessageCircleQuestion size={12} />
{m.documents_badge_open_reviews({ count: document.open_reviews })}
</span>
{/if}
{#if document.status === 'draft'}
<span class="flex items-center gap-1 text-ink-muted">
<PencilLine size={12} />
{m.document_draft_chip()}
</span>
{:else if document.status === 'archived'}
<span class="text-ink-muted">{m.documents_status_archived()}</span>
{/if}
{#if document.is_builtin}
<span class="flex items-center gap-1 text-secondary">
<BookOpen size={12} />
{m.documents_badge_builtin()}
</span>
{/if}
</div>
{/if}
</a>
@@ -0,0 +1,213 @@
<script lang="ts">
import Download from '@lucide/svelte/icons/download';
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
import X from '@lucide/svelte/icons/x';
import type { components } from '$lib/api/schema';
import Input from '$lib/components/Input.svelte';
import Select from '$lib/components/Select.svelte';
import type { AccessFilter, DocumentList } from '$lib/documents/list.svelte';
import { documentView } from '$lib/documents/view.svelte';
import { m } from '$lib/paraglide/messages';
// The search field is the tool people reach for; the six ways to narrow a
// list are the tool they reach for once a month. So the field owns the row
// and the rest lives behind one "Filter" toggle — except the filters that
// are currently ON, which stay visible as removable chips, because a list
// that is quietly filtered is a list that lies.
type Department = components['schemas']['DepartmentOut'];
let { list, departments }: { list: DocumentList; departments: Department[] } = $props();
let showFilters = $state(false);
// $derived: a const list keeps the language it was built in.
const SORTS = $derived([
{ value: 'updated' as const, label: m.documents_sort_updated() },
{ value: 'created' as const, label: m.documents_sort_created() }
]);
const ACCESS_FILTERS = $derived([
{ value: 'all' as const, label: m.documents_access_all() },
{ value: 'author' as const, label: m.documents_access_mine() },
{ value: 'department' as const, label: m.documents_access_department() },
{ value: 'public' as const, label: m.documents_access_public() },
{ value: 'granted' as const, label: m.documents_access_granted() }
]);
const statusLabels = $derived<Record<string, string>>({
published: m.documents_status_published(),
draft: m.documents_status_draft(),
archived: m.documents_status_archived()
});
// What is narrowing the list right now, in the words of the control that
// set it — each one removable where it is shown.
const active = $derived.by(() => {
const chips: { label: string; clear: () => void }[] = [];
if (list.status) {
chips.push({
label: statusLabels[list.status] ?? list.status,
clear: () => {
list.status = '';
list.reload();
}
});
}
if (list.department) {
const name = departments.find((entry) => entry.id === list.department)?.name;
if (name) {
chips.push({
label: name,
clear: () => {
list.department = '';
list.reload();
}
});
}
}
if (list.access !== 'all') {
const label = ACCESS_FILTERS.find((entry) => entry.value === list.access)?.label;
if (label) chips.push({ label, clear: () => (list.access = 'all') });
}
if (list.assignedToMe) {
chips.push({
label: m.documents_filter_my_reviews(),
clear: () => {
list.assignedToMe = false;
list.reload();
}
});
}
return chips;
});
const filterHint = $derived(list.searching ? m.documents_filter_disabled_hint() : undefined);
</script>
<div class="flex flex-wrap items-center gap-2">
<div class="min-w-56 flex-1">
<Input
placeholder={m.documents_search_placeholder()}
bind:value={list.search}
oninput={() => list.onSearchInput()}
data-testid="document-search"
/>
</div>
<button
type="button"
class="flex cursor-pointer items-center gap-1.5 rounded-full border px-3.5 py-2 text-sm transition-colors {showFilters ||
active.length > 0
? 'border-border-strong text-ink'
: 'border-border text-ink-muted hover:text-ink'}"
onclick={() => (showFilters = !showFilters)}
data-testid="filters-toggle"
>
<SlidersHorizontal size={15} />
{m.documents_filters()}
</button>
<!-- A ZIP of everything the user can read (Markdown + frontmatter), built
and streamed by the backend; a plain download, not a route. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
href="/api/documents/export"
download
title={m.documents_export_hint()}
class="flex items-center gap-1.5 rounded-full border border-border px-3.5 py-2 text-sm text-ink-muted transition-colors hover:border-border-strong hover:text-ink"
data-testid="export-button"
>
<Download size={15} />
{m.documents_export()}
</a>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
</div>
{#if active.length > 0 && !showFilters}
<div class="flex flex-wrap items-center gap-1.5" data-testid="active-filters">
{#each active as chip (chip.label)}
<button
type="button"
class="flex cursor-pointer items-center gap-1 rounded-full border border-border-strong bg-surface-sunken px-3 py-1 text-xs text-ink"
onclick={chip.clear}
>
{chip.label}
<X size={12} class="opacity-60" />
</button>
{/each}
</div>
{/if}
{#if showFilters}
<div class="flex flex-col gap-2 rounded-xl border border-border bg-surface-raised p-3">
<div class="flex flex-wrap items-end gap-2">
<Select
class="w-44"
bind:value={list.status}
onchange={() => list.reload()}
disabled={list.searching}
title={filterHint}
data-testid="status-filter"
>
<option value="">{m.documents_filter_all_statuses()}</option>
<option value="published">{m.documents_status_published()}</option>
<option value="draft">{m.documents_status_draft()}</option>
<option value="archived">{m.documents_status_archived()}</option>
</Select>
<Select
class="w-44"
bind:value={list.department}
onchange={() => list.reload()}
disabled={list.searching}
title={filterHint}
>
<option value="">{m.documents_filter_all_departments()}</option>
{#each departments as entry (entry.id)}
<option value={entry.id}>{entry.name}</option>
{/each}
</Select>
<Select
class="w-44"
value={documentView.sort}
onchange={(event) => {
documentView.set({ sort: event.currentTarget.value as 'updated' | 'created' });
list.reload();
}}
disabled={list.searching}
title={list.searching ? m.documents_sort_disabled_hint() : undefined}
data-testid="sort-select"
>
{#each SORTS as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</Select>
<!-- Documents somebody asked this user to check. -->
<button
class="cursor-pointer rounded-full border px-3 py-2 text-sm transition-colors {list.assignedToMe
? 'border-accent bg-accent/10 text-ink'
: 'border-border text-ink-muted hover:text-ink'}"
onclick={() => {
list.assignedToMe = !list.assignedToMe;
list.reload();
}}
disabled={list.searching}
data-testid="review-filter"
>
{m.documents_filter_my_reviews()}
</button>
</div>
<div class="flex flex-wrap items-center gap-1.5" data-testid="access-filter">
<span class="mr-1 text-xs text-ink-muted">{m.documents_access_filter_label()}</span>
{#each ACCESS_FILTERS as option (option.value)}
<button
class="cursor-pointer rounded-full border px-3 py-1 text-xs transition-colors {list.access ===
option.value
? 'border-border-strong bg-surface-sunken text-ink'
: 'border-border text-ink-muted hover:text-ink'}"
onclick={() => (list.access = option.value as AccessFilter)}
>
{option.label}
</button>
{/each}
</div>
</div>
{/if}
@@ -0,0 +1,133 @@
<script lang="ts">
import History from '@lucide/svelte/icons/history';
import { api } from '$lib/api/client';
import type { components } from '$lib/api/schema';
import Card from '$lib/components/Card.svelte';
import VersionDiffDialog from '$lib/documents/VersionDiffDialog.svelte';
import { i18n } from '$lib/i18n/locale.svelte';
import { m } from '$lib/paraglide/messages';
type DocumentEventOut = components['schemas']['DocumentEventOut'];
type DocumentVersion = components['schemas']['DocumentVersion'];
let {
documentId,
canEdit,
onChanged
}: {
documentId: string;
canEdit: boolean;
onChanged?: () => void;
} = $props();
let events = $state<DocumentEventOut[]>([]);
// The last few entries answer "what happened lately"; the whole trail is a
// click away rather than a wall of rows under every document.
let expanded = $state(false);
const SHOWN = 4;
const visible = $derived(expanded ? events : events.slice(0, SHOWN));
// The version whose own change is shown in the modal: its snapshot against
// the one it replaced, so the entry you click is the change you see.
let selected = $state<DocumentVersion | null>(null);
const dateFormat = $derived(
new Intl.DateTimeFormat(i18n.locale, { dateStyle: 'medium', timeStyle: 'short' })
);
function formatDate(iso: string): string {
return dateFormat.format(new Date(iso));
}
const actionLabels = $derived<Record<string, string>>({
created: m.history_action_created(),
edited: m.history_action_edited(),
published: m.history_action_published(),
archived: m.history_action_archived(),
visibility_changed: m.history_action_visibility_changed(),
review_requested: m.history_action_review_requested(),
review_resolved: m.history_action_review_resolved()
});
async function load() {
const { data } = await api.GET('/api/documents/{document_id}/history', {
params: { path: { document_id: documentId } }
});
events = data ?? [];
}
$effect(() => {
void documentId;
void load();
});
async function view(event: DocumentEventOut) {
const { data } = await api.GET('/api/documents/{document_id}/versions/{event_id}', {
params: { path: { document_id: documentId, event_id: event.id } }
});
if (data) selected = data;
}
async function restore() {
if (!selected?.content_md) return;
await api.PATCH('/api/documents/{document_id}', {
params: { path: { document_id: documentId } },
body: { content_md: selected.content_md }
});
selected = null;
await load();
onChanged?.();
}
</script>
<Card>
<h2 class="flex items-center gap-1.5 text-sm font-semibold text-ink-muted">
<History size={14} />
{m.history_title()}
</h2>
{#if events.length === 0}
<p class="mt-2 text-sm text-ink-muted">{m.history_empty()}</p>
{:else}
<ol class="mt-3 space-y-2" data-testid="document-history">
{#each visible as event (event.id)}
<li class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-sm">
<span class="font-medium">{actionLabels[event.action] ?? event.action}</span>
<span class="text-ink-muted">
{m.history_by({ actor: event.actor_name ?? m.history_actor_unknown() })}
</span>
<span class="text-xs text-ink-muted">{formatDate(event.created_at)}</span>
{#if event.has_snapshot}
<button
type="button"
class="cursor-pointer text-xs text-secondary underline"
onclick={() => view(event)}
>
{m.history_view_changes()}
</button>
{/if}
</li>
{/each}
</ol>
{#if events.length > SHOWN}
<button
type="button"
class="mt-2 cursor-pointer text-xs text-secondary underline"
onclick={() => (expanded = !expanded)}
data-testid="history-toggle"
>
{expanded ? m.history_show_less() : m.history_show_all({ count: events.length })}
</button>
{/if}
{/if}
</Card>
{#if selected}
<VersionDiffDialog
open={true}
onClose={() => (selected = null)}
title={m.history_diff_title()}
description={formatDate(selected.created_at)}
original={selected.previous_content_md ?? ''}
modified={selected.content_md ?? ''}
restoreLabel={canEdit ? m.history_restore() : undefined}
onRestore={canEdit ? restore : undefined}
/>
{/if}
+113
View File
@@ -0,0 +1,113 @@
<script lang="ts">
import ClipboardCheck from '@lucide/svelte/icons/clipboard-check';
import PencilLine from '@lucide/svelte/icons/pencil-line';
import Send from '@lucide/svelte/icons/send';
import { resolve } from '$app/paths';
import { api } from '$lib/api/client';
import type { components } from '$lib/api/schema';
import { formatDate } from '$lib/documents/presentation';
import { m } from '$lib/paraglide/messages';
// What is waiting for this person, on the page they land on: drafts they
// started and never published, and documents a colleague asked them to
// check. Both are invisible everywhere else — a draft is private by
// definition, and a question addressed to you is easy to miss in a list —
// so they are the one thing the landing page volunteers.
type DocumentSummary = components['schemas']['DocumentSummary'];
const SHOWN = 3;
let drafts = $state<DocumentSummary[]>([]);
let reviewCount = $state(0);
let publishing = $state<string | null>(null);
async function load() {
// Drafts are author-only, bar one exception: a draft someone asked you
// to check is readable too. "Your drafts" means the ones you wrote.
const [mine, queue] = await Promise.all([
api.GET('/api/documents', {
params: { query: { status: 'draft', sort: 'updated', per_page: 20 } }
}),
api.GET('/api/documents', {
params: { query: { assigned_to_me: true, per_page: 1 } }
})
]);
drafts = (mine.data?.items ?? []).filter((item) => item.access_reason === 'author');
reviewCount = queue.data?.total ?? 0;
}
$effect(() => {
void load();
});
async function publish(id: string) {
publishing = id;
await api.POST('/api/documents/{document_id}/publish', {
params: { path: { document_id: id } }
});
publishing = null;
await load();
}
</script>
{#if reviewCount > 0}
<!-- The path IS resolved; the query string is data, not part of the route. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a
href="{resolve('/documents')}?review=1"
class="flex items-center justify-between gap-3 rounded-xl border border-accent bg-accent/10 px-4 py-3 text-sm transition-colors hover:border-accent-hover"
data-testid="review-queue-banner"
>
<span class="flex items-center gap-2 font-medium">
<ClipboardCheck size={16} class="text-accent" />
{m.landing_review_pending({ count: reviewCount })}
</span>
<span class="whitespace-nowrap text-accent">{m.landing_review_open()}</span>
</a>
{/if}
{#if drafts.length > 0}
<div class="rounded-xl border border-border bg-surface-raised p-4" data-testid="drafts-card">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<p class="flex items-center gap-2 text-sm font-medium">
<PencilLine size={15} class="text-ink-muted" />
{m.landing_drafts_title({ count: drafts.length })}
</p>
<p class="text-xs text-ink-muted">{m.landing_drafts_hint()}</p>
</div>
<ul class="mt-2 flex flex-col divide-y divide-border">
{#each drafts.slice(0, SHOWN) as draft (draft.id)}
<li class="flex flex-wrap items-center gap-2 py-2">
<a
href={resolve(`/documents/${draft.id}/edit`)}
class="min-w-0 flex-1 truncate text-sm hover:underline"
>
{draft.title}
<span class="ml-1 text-xs text-ink-muted">
{m.documents_updated_at({ date: formatDate(draft.updated_at) })}
</span>
</a>
<button
class="flex shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border px-3 py-1 text-xs text-ink-muted transition-colors hover:border-accent hover:text-ink disabled:opacity-50"
onclick={() => publish(draft.id)}
disabled={publishing === draft.id}
data-testid="draft-publish"
>
<Send size={13} />
{m.landing_drafts_publish()}
</button>
</li>
{/each}
</ul>
{#if drafts.length > SHOWN}
<!-- The path IS resolved; the query string is data, not part of the route. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a
href="{resolve('/documents')}?status=draft"
class="mt-1 inline-block text-xs text-secondary underline"
>
{m.landing_drafts_all({ count: drafts.length })}
</a>
{/if}
</div>
{/if}
@@ -0,0 +1,129 @@
<script lang="ts">
import Check from '@lucide/svelte/icons/check';
import MessageCircleQuestion from '@lucide/svelte/icons/message-circle-question';
import Pencil from '@lucide/svelte/icons/pencil';
import UserCheck from '@lucide/svelte/icons/user-check';
import { api } from '$lib/api/client';
import type { components } from '$lib/api/schema';
import Button from '$lib/components/Button.svelte';
import { formatDate } from '$lib/documents/presentation';
import { m } from '$lib/paraglide/messages';
// What is still open on this document, and what the reader can do about it.
//
// An open question is the one thing a reader must see before trusting the
// text, so it sits above the content — not in the history, not behind a
// tab. The person who was asked gets the answer buttons; the author can
// close a question that has become moot.
type DocumentDetail = components['schemas']['DocumentDetail'];
let {
document: doc,
onEdit,
onChanged
}: {
document: DocumentDetail;
onEdit: () => void;
onChanged: () => Promise<void> | void;
} = $props();
let busy = $state(false);
let error = $state<string | null>(null);
const open = $derived(doc.reviews.filter((review) => review.resolved_at === null));
const answered = $derived(doc.reviews.filter((review) => review.resolved_at !== null));
async function resolve(reviewId: string) {
busy = true;
error = null;
const { data } = await api.POST('/api/documents/{document_id}/reviews/{review_id}/resolve', {
params: { path: { document_id: doc.id, review_id: reviewId } }
});
busy = false;
if (!data) {
error = m.document_review_failed();
return;
}
await onChanged();
}
</script>
{#if open.length > 0}
<div
class="flex flex-col gap-3 rounded-xl border border-warning/40 bg-warning-muted p-4"
data-testid="open-reviews"
>
{#each open as review (review.id)}
<div class="flex flex-col gap-2">
<p class="flex items-start gap-2 text-sm">
<MessageCircleQuestion size={16} class="mt-0.5 shrink-0 text-warning" />
<span>
{#if review.question}
<span class="text-ink-muted">
{m.document_review_asked_by({ name: review.requester_name ?? '' })}
</span>
<!-- The question is user-written text: plain, never rendered. -->
<span class="font-medium">{review.question}</span>
{:else}
<span class="font-medium">
{m.document_review_asked_plain({ name: review.requester_name ?? '' })}
</span>
{/if}
<span class="mt-0.5 block text-xs text-ink-muted">
{m.document_review_waiting_on({
name: review.reviewer_name ?? '',
date: formatDate(review.created_at)
})}
</span>
</span>
</p>
<div class="flex flex-wrap gap-2 pl-6">
{#if review.is_mine}
<Button
size="sm"
disabled={busy}
onclick={() => resolve(review.id)}
data-testid="review-confirm"
>
<Check size={15} />
{m.document_review_confirm()}
</Button>
{#if doc.can_edit}
<Button size="sm" variant="secondary" onclick={onEdit}>
<Pencil size={15} />
{m.document_review_fix()}
</Button>
{/if}
{:else if doc.can_edit}
<Button
size="sm"
variant="ghost"
disabled={busy}
onclick={() => resolve(review.id)}
data-testid="review-close"
>
{m.document_review_close()}
</Button>
{/if}
</div>
</div>
{/each}
{#if error}
<p role="alert" class="text-sm text-danger">{error}</p>
{/if}
</div>
{/if}
{#if answered.length > 0}
<ul class="flex flex-col gap-1 text-xs text-ink-muted" data-testid="answered-reviews">
{#each answered as review (review.id)}
<li class="flex items-center gap-1.5">
<UserCheck size={13} class="shrink-0 text-success" />
{m.document_review_answered({
name: review.resolved_by_name ?? '',
date: formatDate(review.resolved_at ?? review.created_at)
})}
</li>
{/each}
</ul>
{/if}
@@ -0,0 +1,85 @@
<script lang="ts">
import { api } from '$lib/api/client';
import type { components } from '$lib/api/schema';
import Button from '$lib/components/Button.svelte';
import Select from '$lib/components/Select.svelte';
import { m } from '$lib/paraglide/messages';
// Asking a colleague to check something: who, and what exactly to look at.
// The question is the point — "please review" says nothing, "do the 14
// holiday days still hold?" is answerable — so it gets the larger field,
// but it stays optional.
type ReviewerCandidate = components['schemas']['ReviewerCandidate'];
let {
documentId,
onSent
}: { documentId: string; onSent: (name: string) => Promise<void> | void } = $props();
let candidates = $state<ReviewerCandidate[]>([]);
let loaded = $state(false);
let selected = $state('');
let question = $state('');
let busy = $state(false);
let error = $state<string | null>(null);
$effect(() => {
void (async () => {
const { data } = await api.GET('/api/documents/{document_id}/reviewers', {
params: { path: { document_id: documentId } }
});
candidates = data ?? [];
selected = candidates[0]?.id ?? '';
loaded = true;
})();
});
async function send() {
if (!selected) return;
busy = true;
error = null;
const { data } = await api.POST('/api/documents/{document_id}/reviews', {
params: { path: { document_id: documentId } },
body: { reviewer_id: selected, question: question.trim() || null }
});
busy = false;
if (!data) {
error = m.review_ask_failed();
return;
}
await onSent(candidates.find((candidate) => candidate.id === selected)?.name ?? '');
}
</script>
{#if loaded && candidates.length === 0}
<p class="text-sm text-ink-muted" data-testid="review-ask-empty">{m.review_ask_none()}</p>
{:else}
<div class="flex flex-col gap-3">
<label class="flex flex-col gap-1 text-sm">
<span class="text-ink-muted">{m.review_ask_reviewer()}</span>
<Select bind:value={selected} data-testid="reviewer-select">
{#each candidates as candidate (candidate.id)}
<option value={candidate.id}>{candidate.name}</option>
{/each}
</Select>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-ink-muted">{m.review_ask_question()}</span>
<textarea
bind:value={question}
rows="3"
maxlength="2000"
placeholder={m.review_ask_question_placeholder()}
class="w-full resize-y rounded-lg border border-border bg-surface p-2.5 text-sm text-ink transition-colors placeholder:text-ink-muted focus:border-border-strong focus:outline-none"
data-testid="review-question"></textarea>
</label>
{#if error}
<p role="alert" class="text-sm text-danger">{error}</p>
{/if}
<div class="flex justify-end">
<Button onclick={send} disabled={busy || !selected} data-testid="review-ask-send">
{m.review_ask_send()}
</Button>
</div>
</div>
{/if}
@@ -0,0 +1,154 @@
<script lang="ts">
import Sparkles from '@lucide/svelte/icons/sparkles';
import { lineNumbers, EditorView } from '@codemirror/view';
import { markdown } from '@codemirror/lang-markdown';
import { unifiedMergeView } from '@codemirror/merge';
import { api } from '$lib/api/client';
import Button from '$lib/components/Button.svelte';
import Dialog from '$lib/components/Dialog.svelte';
import Input from '$lib/components/Input.svelte';
import Tooltip from '$lib/components/Tooltip.svelte';
import { editorTheme } from '$lib/documents/editorTheme';
import { m } from '$lib/paraglide/messages';
// Read before you save: saving shows what changed since the last save and
// asks again. There is no autosave, because this look at the diff is the
// point — you see what you are about to put your name on, and can still
// back out. The title sits right next to it: this is the moment you notice
// that the document is still called "Onboarding: Neue Kollegin". A draft
// can go straight from here to published.
type Props = {
open: boolean;
documentId: string;
/** The text being saved, and the last saved text it is diffed against. */
content: string;
baseline: string;
title: string;
/** Whether this save may also publish: a draft, and the caller's to
* publish. */
canPublish: boolean;
busy: boolean;
error: string | null;
onSave: () => void;
onPublish: () => void;
};
let {
open = $bindable(),
documentId,
content,
baseline,
title = $bindable(),
canPublish,
busy,
error,
onSave,
onPublish
}: Props = $props();
let mergeHost = $state<HTMLDivElement>();
let suggesting = $state(false);
let suggestError = $state<string | null>(null);
const unchanged = $derived(content === baseline);
// A read-only unified merge view (VSCode-style), mounted only while open.
$effect(() => {
if (!open || !mergeHost) return;
const view = new EditorView({
parent: mergeHost,
doc: content,
extensions: [
lineNumbers(),
markdown(),
EditorView.lineWrapping,
EditorView.editable.of(false),
editorTheme,
unifiedMergeView({ original: baseline, mergeControls: false })
]
});
return () => view.destroy();
});
// Asked for, not volunteered: a title suggestion costs a model call, and
// most saves are on a document that is already named. The result lands in
// the field, where it can be edited or typed over.
async function suggestTitle() {
suggesting = true;
suggestError = null;
const { data } = await api.POST('/api/documents/{document_id}/suggest-title', {
params: { path: { document_id: documentId } }
});
suggesting = false;
if (!data) {
suggestError = m.editor_title_suggest_failed();
return;
}
title = data.title;
}
</script>
<Dialog
bind:open
title={m.editor_save_title()}
description={m.editor_save_hint()}
contentClass="w-[min(52rem,calc(100vw-2rem))]"
data-testid="editor-save-dialog"
>
<div class="mb-3 flex flex-col gap-1">
<span class="text-sm text-ink-muted">{m.editor_title_label()}</span>
<div class="flex items-center gap-2">
<Input bind:value={title} class="flex-1 font-medium" data-testid="save-title" />
<Tooltip text={m.editor_title_suggest()}>
<button
type="button"
class="cursor-pointer rounded-full border border-border p-2 text-accent transition-colors hover:border-accent disabled:opacity-50"
onclick={suggestTitle}
disabled={suggesting}
aria-label={m.editor_title_suggest()}
data-testid="title-suggest"
>
<Sparkles size={16} class={suggesting ? 'animate-pulse' : ''} />
</button>
</Tooltip>
</div>
{#if suggestError}
<p role="alert" class="text-sm text-danger">{suggestError}</p>
{/if}
</div>
{#if unchanged}
<p class="text-sm text-ink-muted" data-testid="editor-no-changes">{m.editor_no_changes()}</p>
{:else}
<div
bind:this={mergeHost}
class="max-h-[55vh] overflow-auto rounded-lg border border-border bg-surface"
data-testid="editor-diff"
></div>
{/if}
{#if error}
<p role="alert" class="mt-2 text-sm text-danger">{error}</p>
{/if}
<div class="mt-4 flex flex-wrap justify-end gap-2">
<Button variant="ghost" disabled={busy} onclick={() => (open = false)}>
{m.common_cancel()}
</Button>
<Button
variant={canPublish ? 'secondary' : 'primary'}
disabled={busy}
onclick={onSave}
data-testid="editor-save"
>
{m.editor_save()}
</Button>
{#if canPublish}
<!-- The draft's way out: saved and readable in one step, the author's
own decision — nobody has to approve it. -->
<Button disabled={busy} onclick={onPublish} data-testid="editor-publish">
{m.editor_save_and_publish()}
</Button>
{/if}
</div>
</Dialog>
@@ -0,0 +1,72 @@
<script lang="ts">
import { EditorView, lineNumbers } from '@codemirror/view';
import { markdown } from '@codemirror/lang-markdown';
import { unifiedMergeView } from '@codemirror/merge';
import Button from '$lib/components/Button.svelte';
import Dialog from '$lib/components/Dialog.svelte';
import { editorTheme } from '$lib/documents/editorTheme';
let {
open = $bindable(false),
onClose,
title,
description,
original,
modified,
restoreLabel,
onRestore
}: {
open?: boolean;
onClose?: () => void;
title: string;
description?: string;
// Read-only unified diff: `original` (the older text) on the left,
// `modified` (usually the current document) as the working copy.
original: string;
modified: string;
restoreLabel?: string;
onRestore?: () => void;
} = $props();
let host = $state<HTMLDivElement>();
// Mounted only while the modal is open (the host binds when the portal
// renders), like the editor's own pre-save diff.
$effect(() => {
if (!open || !host) return;
const view = new EditorView({
parent: host,
doc: modified,
extensions: [
lineNumbers(),
markdown(),
EditorView.lineWrapping,
EditorView.editable.of(false),
editorTheme,
unifiedMergeView({ original, mergeControls: false })
]
});
return () => view.destroy();
});
</script>
<Dialog
bind:open
onOpenChange={(next) => {
if (!next) onClose?.();
}}
{title}
{description}
contentClass="w-[min(52rem,calc(100vw-2rem))]"
data-testid="version-diff"
>
<div
bind:this={host}
class="max-h-[60vh] overflow-auto rounded-md border border-border p-2"
></div>
{#if restoreLabel && onRestore}
<div class="mt-4 flex justify-end">
<Button variant="ghost" size="sm" onclick={onRestore}>{restoreLabel}</Button>
</div>
{/if}
</Dialog>
@@ -0,0 +1,464 @@
<script lang="ts">
import { EditorView, keymap, lineNumbers, drawSelection } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { markdown } from '@codemirror/lang-markdown';
import AlertTriangle from '@lucide/svelte/icons/triangle-alert';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import { beforeNavigate } from '$app/navigation';
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { api } from '$lib/api/client';
import { errorMessage } from '$lib/api/errors';
import type { components } from '$lib/api/schema';
import { streamRefine } from '$lib/api/refine';
import Button from '$lib/components/Button.svelte';
import Input from '$lib/components/Input.svelte';
import CaptureSuccess from '$lib/documents/CaptureSuccess.svelte';
import SaveDialog from '$lib/documents/SaveDialog.svelte';
import { InlineSuggestion } from '$lib/documents/editor/inlineSuggestion';
import { editorTheme } from '$lib/documents/editorTheme';
import { activeSection } from '$lib/documents/sections';
import { i18n } from '$lib/i18n/locale.svelte';
import { m } from '$lib/paraglide/messages';
import { untrack } from 'svelte';
type DocumentDetail = components['schemas']['DocumentDetail'];
let { document: doc }: { document: DocumentDetail } = $props();
// The route mounts this keyed on `doc.id`, so the document is fixed for the
// component's life: read it once (untrack) and keep editable copies.
const documentId = untrack(() => doc.id);
const isDraft = untrack(() => doc.status === 'draft');
const initialDoc = untrack(() => doc.content_md);
// Publishing is the owner's call. A colleague asked to check a draft edits
// and saves here like anyone else, but does not decide who gets to read it.
const canPublish = untrack(
() => doc.access_reason === 'author' || page.data.user?.role === 'admin'
);
let content = $state(initialDoc);
let baseline = $state(initialDoc); // last saved — the diff is against this
let title = $state(untrack(() => doc.title));
let busy = $state(false);
// Two errors, because they belong to two surfaces: a refinement that could
// not run is about the text you are writing (and is shown once, with the
// pause), a failed save is about the dialog you are standing in. Sharing
// one made the save modal report that the model was unreachable.
let error = $state<string | null>(null);
let saveError = $state<string | null>(null);
let saving = $state(false); // the save/diff modal is open
let published = $state(false); // the success/reward modal is open
// After an accept or dismiss the writer must add some new text before the
// next suggestion fires, so it does not immediately re-propose what it just
// wrote. Starts large so the first suggestion is allowed.
let charsSinceGate = Infinity;
const COOLDOWN_CHARS = 40;
const IDLE_MS = 2000;
// A dead endpoint must not be knocked on every two seconds. After a
// failure the suggestions go quiet and say so, and the next attempt waits:
// a while for an endpoint that is not there, briefly for one that is just
// busy. Writing continues untouched either way — the assistant is the
// optional half of this editor.
const RETRY_MS: Record<string, number> = {
llm_unreachable: 120_000,
llm_misconfigured: 300_000,
llm_busy: 30_000,
llm_failed: 60_000
};
let pausedCode = $state<string | null>(null);
let retryAt = 0;
// What is happening to this document right now, in the writer's terms.
// Picking a documentation type creates the draft immediately (rule 6: the
// document IS the state), and leaving an untouched skeleton deletes it
// again — both are right, and both were invisible, which is what made a
// document that "did not exist yet" confusing. Now the line under the
// editor says which of the three it is.
let savedAt = $state<string | null>(null);
const untouched = $derived(content === initialDoc && isEmptySkeleton(content));
let host = $state<HTMLDivElement>();
let view: EditorView | undefined;
let refineTimer: ReturnType<typeof setTimeout> | undefined;
let refineAbort: AbortController | undefined;
// The suggestion is a block inside the editor, not a pane beside it; it owns
// its DOM and its CodeMirror extension (lib/documents/editor).
const inline = new InlineSuggestion({ onAccept: accept, onDismiss: dismiss });
// Plain locals, not $state: the suggestion is rendered by the widget, so
// nothing here needs to drive Svelte's template.
let suggesting = false;
let suggestion = '';
let suggestionRange: { start: number; end: number } | null = null;
function cursorLine(state: EditorState): number {
return state.doc.lineAt(state.selection.main.head).number;
}
function firstHeading(text: string, range: { start: number; end: number }): string {
const first = text.split('\n')[range.start - 1] ?? '';
const match = /^#{1,6}\s+(.*)$/.exec(first.trim());
return match ? match[1] : '';
}
function cancelSuggestion() {
refineAbort?.abort();
refineAbort = undefined;
suggesting = false;
suggestion = '';
suggestionRange = null;
inline.clear();
}
function scheduleRefine() {
clearTimeout(refineTimer);
// Any edit makes a shown suggestion stale, so drop it and re-arm.
cancelSuggestion();
refineTimer = setTimeout(runRefine, IDLE_MS);
}
async function runRefine() {
if (!view || !content.trim() || charsSinceGate < COOLDOWN_CHARS || suggesting) return;
// Still in the quiet period after a failure: no request, no second
// error message about the same dead endpoint.
if (Date.now() < retryAt) return;
const line = cursorLine(view.state);
const heading = firstHeading(content, activeSection(content, line));
suggesting = true;
suggestion = '';
suggestionRange = null;
refineAbort = new AbortController();
try {
for await (const event of streamRefine(documentId, content, line, refineAbort.signal)) {
if (event.type === 'section') {
suggestionRange = { start: event.start_line, end: event.end_line };
// Anchor the widget just below the section it will replace.
const lines = view.state.doc;
inline.show(lines.line(Math.min(event.end_line, lines.lines)).to, heading);
} else if (event.type === 'grounding') {
inline.setGrounding(event.references);
} else if (event.type === 'token') {
suggestion += event.text;
inline.stream(suggestion);
} else if (event.type === 'error') {
pause(event.code);
inline.clear();
break;
} else if (event.type === 'done') {
break;
}
}
if (suggestion.trim()) {
inline.finish(suggestion);
// It answered, so whatever was wrong is over.
resume();
} else {
inline.clear();
}
} catch {
// Aborted because the user resumed typing — expected, stay quiet.
inline.clear();
} finally {
suggesting = false;
refineAbort = undefined;
}
}
function accept() {
if (!view || !suggestionRange || !suggestion.trim()) return;
const lines = view.state.doc;
const from = lines.line(Math.min(suggestionRange.start, lines.lines)).from;
const to = lines.line(Math.min(suggestionRange.end, lines.lines)).to;
const text = suggestion.trimEnd();
inline.clear();
view.dispatch({ changes: { from, to, insert: text } });
charsSinceGate = 0; // start the cooldown (the dispatch above re-armed it)
suggestion = '';
suggestionRange = null;
view.focus();
}
function dismiss() {
cancelSuggestion();
charsSinceGate = 0;
view?.focus();
}
function pause(code: string) {
pausedCode = code;
error = errorMessage(code);
retryAt = Date.now() + (RETRY_MS[code] ?? RETRY_MS.llm_failed);
}
function resume() {
pausedCode = null;
error = null;
retryAt = 0;
}
/** "Try now" — the writer knows better than a timer when the endpoint is
* back, so asking again is one click and does not wait it out. */
function retryNow() {
resume();
charsSinceGate = Infinity;
void runRefine();
}
$effect(() => {
if (!host) return;
const listener = EditorView.updateListener.of((update) => {
if (!update.docChanged) return;
content = update.state.doc.toString();
let added = 0;
update.changes.iterChanges((_a, _b, _c, _d, inserted) => (added += inserted.length));
charsSinceGate += added;
scheduleRefine();
});
view = new EditorView({
doc: initialDoc,
parent: host,
extensions: [
lineNumbers(),
history(),
drawSelection(),
keymap.of([...defaultKeymap, ...historyKeymap]),
markdown(),
EditorView.lineWrapping,
editorTheme,
inline.extension,
listener
]
});
inline.bind(view);
return () => {
clearTimeout(refineTimer);
refineAbort?.abort();
inline.bind(undefined);
view?.destroy();
view = undefined;
};
});
async function save(): Promise<boolean> {
busy = true;
saveError = null;
const { data } = await api.PATCH('/api/documents/{document_id}', {
params: { path: { document_id: documentId } },
body: { title, content_md: content }
});
busy = false;
if (!data) {
saveError = m.document_save_failed();
return false;
}
baseline = content;
savedAt = new Intl.DateTimeFormat(i18n.locale, { timeStyle: 'short' }).format(new Date());
saving = false;
return true;
}
// Save, then make the draft readable — one action, the author's own.
async function publish() {
if (!(await save())) return;
busy = true;
const { data } = await api.POST('/api/documents/{document_id}/publish', {
params: { path: { document_id: documentId } }
});
busy = false;
if (!data) {
saveError = m.document_save_failed();
return;
}
published = true; // open the reward modal
}
// A draft that is only the (unedited) template skeleton — headings and blank
// lines, no captured knowledge.
function isEmptySkeleton(text: string): boolean {
return text.split('\n').every((line) => line.trim() === '' || /^#{1,6}\s/.test(line.trim()));
}
// On the way out, take care of the draft so nothing is lost and nothing is
// left as clutter. Skipped once published (the document is no longer a draft).
beforeNavigate(() => {
if (published || !isDraft) return;
if (content === initialDoc && isEmptySkeleton(content)) {
// An abandoned, never-filled template: discard it so empty drafts do
// not pile up in the document list.
void api.DELETE('/api/documents/{document_id}', {
params: { path: { document_id: documentId } }
});
} else if (content !== baseline) {
// Unsaved draft edits: persist them (a draft is private and not
// indexed, so this is cheap) so leaving never loses work.
void api.PATCH('/api/documents/{document_id}', {
params: { path: { document_id: documentId } },
body: { title, content_md: content }
});
}
});
</script>
<div class="flex min-h-0 flex-1 flex-col gap-3">
<!-- Title and text, and nothing else: who may READ this is a property of the
document as it stands, and is set where it is shown (AccessPopover). -->
<div class="flex items-center gap-2">
<a
href={resolve(`/documents/${documentId}`)}
class="flex shrink-0 items-center gap-1 rounded-lg border border-border px-2.5 py-2 text-sm text-ink-muted transition-colors hover:border-border-strong hover:text-ink"
data-testid="editor-back"
>
<ArrowLeft size={15} />
{m.editor_back()}
</a>
<Input bind:value={title} class="text-lg font-semibold" data-testid="editor-title" />
</div>
{#if pausedCode}
<!-- One line, once: the endpoint is not answering, suggestions are off
until it does (or until this button says otherwise). -->
<p class="flex flex-wrap items-center gap-x-2 text-sm text-warning" data-testid="refine-paused">
<AlertTriangle size={14} class="shrink-0" />
{error}
<span class="text-ink-muted">{m.editor_suggestions_paused()}</span>
<button
type="button"
class="cursor-pointer underline decoration-dotted underline-offset-2"
onclick={retryNow}
data-testid="refine-retry"
>
{m.editor_suggestions_retry()}
</button>
</p>
{/if}
<!-- You write here. A refined version of the section at your cursor streams
INLINE as a block right below that section, so the suggestion appears
exactly where you are editing; accepting overwrites that section. -->
<div
bind:this={host}
class="editor-host min-h-0 flex-1 overflow-auto rounded-xl border border-border bg-surface px-3"
data-testid="editor-source"
></div>
<!-- Saving sits where you finish: bottom right, after the text. It opens the
diff rather than writing straight through — see SaveDialog. -->
<div class="flex items-center justify-end gap-2">
<span class="text-xs text-ink-muted" data-testid="editor-state">
{#if content !== baseline}
{m.editor_unsaved()}
{:else if savedAt}
{m.editor_saved_at({ time: savedAt })}
{:else if untouched}
<!-- Nothing written yet: leaving now takes the empty draft with it,
which is better said than discovered. -->
{m.editor_untouched_draft()}
{:else}
{m.editor_draft_exists()}
{/if}
</span>
<Button onclick={() => (saving = true)} data-testid="editor-open-save">
{m.editor_save()}
</Button>
</div>
</div>
<SaveDialog
bind:open={saving}
bind:title
{documentId}
{content}
{baseline}
canPublish={isDraft && canPublish}
{busy}
error={saveError}
onSave={save}
onPublish={publish}
/>
<CaptureSuccess bind:open={published} {documentId} />
<style>
/* The inline suggestion block, injected by CodeMirror into the editor flow.
Styled globally because it is not part of Svelte's scoped markup, and keyed
to the design tokens so it follows light/dark. */
.editor-host :global(.pb-suggestion) {
margin: 0.4rem 0 0.7rem;
padding: 0.55rem 0.75rem 0.65rem;
border: 1px solid var(--pb-accent);
border-radius: 0.6rem;
background: var(--pb-surface-raised);
color: var(--pb-ink);
font-family:
ui-sans-serif,
system-ui,
-apple-system,
sans-serif;
font-size: 0.9rem;
line-height: 1.55;
white-space: normal;
}
.editor-host :global(.pb-suggestion-header) {
display: flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 0.3rem;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--pb-accent);
}
.editor-host :global(.pb-suggestion-header)::before {
content: '✨';
}
.editor-host :global(.pb-suggestion--loading .pb-suggestion-body)::after {
content: '▍';
margin-left: 1px;
color: var(--pb-secondary);
animation: pb-suggestion-blink 1s step-start infinite;
}
@keyframes pb-suggestion-blink {
50% {
opacity: 0;
}
}
.editor-host :global(.pb-suggestion-grounding) {
margin-top: 0.4rem;
font-size: 0.72rem;
color: var(--pb-ink-muted);
}
.editor-host :global(.pb-suggestion-actions) {
display: flex;
gap: 0.4rem;
margin-top: 0.55rem;
}
.editor-host :global(.pb-suggestion-btn) {
cursor: pointer;
border-radius: 999px;
padding: 0.15rem 0.7rem;
font-size: 0.75rem;
font-weight: 500;
border: 1px solid var(--pb-border);
background: transparent;
color: var(--pb-ink-muted);
transition:
color 0.15s,
background 0.15s,
border-color 0.15s;
}
.editor-host :global(.pb-suggestion-btn:hover) {
color: var(--pb-ink);
border-color: var(--pb-border-strong);
}
.editor-host :global(.pb-suggestion-accept) {
background: var(--pb-success-muted);
color: var(--pb-success);
border-color: transparent;
}
</style>
@@ -0,0 +1,172 @@
// The refinement suggestion, shown as a block INSIDE the editor.
//
// It is a CodeMirror block widget anchored just below the section it would
// replace, so the suggestion appears exactly where the writer is working
// rather than in a disconnected pane. The DOM is built and mutated
// imperatively on purpose: the widget outlives Svelte's render cycle (
// CodeMirror keeps the element while the decoration lives), and streaming
// tokens into a node is cheaper than re-rendering a component per token.
//
// Its styles live with the component that hosts the editor
// (`WritingEditor.svelte`), scoped through `.editor-host :global(...)`, since
// that is the element they are injected into.
import { StateEffect, StateField, type Extension } from '@codemirror/state';
import { Decoration, EditorView, WidgetType, type DecorationSet } from '@codemirror/view';
import type { GroundingReference } from '$lib/api/refine';
import { renderMarkdown } from '$lib/markdown';
import { m } from '$lib/paraglide/messages';
type Handlers = { onAccept: () => void; onDismiss: () => void };
/** Wraps an element we own; CodeMirror positions it in the document flow. */
class SuggestionWidget extends WidgetType {
constructor(private readonly el: HTMLElement) {
super();
}
toDOM() {
return this.el;
}
eq(other: SuggestionWidget) {
return other.el === this.el;
}
ignoreEvent() {
// Let the accept/dismiss buttons handle their own clicks.
return true;
}
}
export class InlineSuggestion {
readonly extension: Extension;
#root: HTMLElement;
#label: HTMLElement;
#body: HTMLElement;
#grounding: HTMLElement;
#actions: HTMLElement;
#setPos = StateEffect.define<number | null>();
#view: EditorView | undefined;
#measureScheduled = false;
constructor(handlers: Handlers) {
const root = document.createElement('div');
root.className = 'pb-suggestion';
root.setAttribute('data-testid', 'editor-suggestion');
const header = document.createElement('div');
header.className = 'pb-suggestion-header';
this.#label = document.createElement('span');
this.#label.className = 'pb-suggestion-label';
header.append(this.#label);
this.#body = document.createElement('div');
this.#body.className = 'pb-suggestion-body markdown';
this.#grounding = document.createElement('div');
this.#grounding.className = 'pb-suggestion-grounding';
this.#grounding.hidden = true;
this.#actions = document.createElement('div');
this.#actions.className = 'pb-suggestion-actions';
this.#actions.hidden = true;
this.#actions.append(
this.#button(m.editor_accept(), 'editor-accept', handlers.onAccept, true),
this.#button(m.editor_dismiss(), 'editor-dismiss', handlers.onDismiss, false)
);
root.append(header, this.#body, this.#grounding, this.#actions);
this.#root = root;
const setPos = this.#setPos;
const element = () => this.#root;
this.extension = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(deco, tr) {
deco = deco.map(tr.changes);
for (const effect of tr.effects) {
if (effect.is(setPos)) {
deco =
effect.value === null
? Decoration.none
: Decoration.set([
Decoration.widget({
widget: new SuggestionWidget(element()),
block: true,
side: 1
}).range(effect.value)
]);
}
}
return deco;
},
provide: (field) => EditorView.decorations.from(field)
});
}
#button(text: string, testid: string, handler: () => void, primary: boolean) {
const button = document.createElement('button');
button.type = 'button';
button.className = `pb-suggestion-btn${primary ? ' pb-suggestion-accept' : ''}`;
button.textContent = text;
button.setAttribute('data-testid', testid);
// preventDefault on mousedown keeps the editor from blurring first.
button.addEventListener('mousedown', (event) => event.preventDefault());
button.addEventListener('click', handler);
return button;
}
/** The view this suggestion lives in; set once the editor exists. */
bind(view: EditorView | undefined) {
this.#view = view;
}
/** Open the block at `pos`, in its loading state, titled by the section. */
show(pos: number, heading: string) {
if (!this.#view) return;
this.#root.classList.add('pb-suggestion--loading');
this.#label.textContent = heading || m.editor_suggestion_title();
this.#body.textContent = '';
this.#actions.hidden = true;
this.#grounding.hidden = true;
this.#view.dispatch({ effects: this.#setPos.of(pos) });
}
/** Plain text while it streams: Markdown is only rendered once complete. */
stream(text: string) {
this.#body.textContent = text;
this.#measure();
}
finish(text: string) {
this.#root.classList.remove('pb-suggestion--loading');
this.#body.innerHTML = renderMarkdown(text);
this.#actions.hidden = false;
this.#measure();
}
/** What the suggestion drew from — titles and heading paths, no content. */
setGrounding(references: GroundingReference[]) {
if (!references.length) {
this.#grounding.hidden = true;
return;
}
const names = references.map((r) => r.heading_path || r.title).join(' · ');
this.#grounding.textContent = `${m.editor_grounding_label()}: ${names}`;
this.#grounding.hidden = false;
}
clear() {
this.#view?.dispatch({ effects: this.#setPos.of(null) });
}
/** Mutating a widget's DOM does not tell CodeMirror its height changed; a
* throttled requestMeasure keeps the lines below it laid out correctly. */
#measure() {
if (this.#measureScheduled || !this.#view) return;
this.#measureScheduled = true;
requestAnimationFrame(() => {
this.#measureScheduled = false;
this.#view?.requestMeasure();
});
}
}
+28
View File
@@ -0,0 +1,28 @@
import { EditorView } from '@codemirror/view';
/**
* The shared CodeMirror theme, keyed to the design tokens rather than a
* CodeMirror theme, so the writing editor and the read-only diff views match
* the app in both light and dark mode.
*/
export const editorTheme = EditorView.theme({
'&': { color: 'var(--color-ink)', backgroundColor: 'transparent', height: '100%' },
'.cm-content': {
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: '0.9rem',
padding: '0.5rem 0',
caretColor: 'var(--color-accent)'
},
'.cm-scroller': { lineHeight: '1.7' },
'&.cm-focused': { outline: 'none' },
'.cm-cursor': { borderLeftColor: 'var(--color-accent)', borderLeftWidth: '2px' },
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground': {
backgroundColor: 'var(--color-surface-sunken)'
},
'.cm-gutters': {
backgroundColor: 'transparent',
color: 'var(--color-ink-muted)',
border: 'none'
},
'.cm-activeLineGutter, .cm-activeLine': { backgroundColor: 'transparent' }
});
+121
View File
@@ -0,0 +1,121 @@
// What the document list is currently showing, and how it gets there.
//
// Two different queries wear one screen: an empty search browses (filtered,
// sorted, paged) and a non-empty one goes through hybrid retrieval (ranked, no
// paging, no filters). Keeping both here means the page renders results and
// this decides what "results" are.
import { api } from '$lib/api/client';
import type { components } from '$lib/api/schema';
import { documentView } from '$lib/documents/view.svelte';
type DocumentSummary = components['schemas']['DocumentSummary'];
type DocumentSearchHit = components['schemas']['DocumentSearchHit'];
/** A row is a summary, plus the matched heading when it came from a search. */
export type DocumentRow = DocumentSummary & Partial<DocumentSearchHit>;
export type AccessFilter = 'all' | 'author' | 'department' | 'public' | 'granted';
// Typing fires a request per keystroke. Showing "Loading…" immediately makes
// the list flicker on every fast response, so the spinner only appears once a
// request is actually slow — the previous results stay until the new ones come.
const LOADING_DELAY_MS = 400;
// Debounced: every keystroke would otherwise cost an embedding call.
const SEARCH_DEBOUNCE_MS = 300;
export class DocumentList {
documents = $state<DocumentRow[]>([]);
total = $state(0);
perPage = $state(30);
page = $state(1);
loading = $state(true);
search = $state('');
status = $state('');
department = $state('');
assignedToMe = $state(false);
// access_reason is computed per request, so filtering by it is a client
// concern — no extra round trip.
access = $state<AccessFilter>('all');
#inFlight = 0;
#debounce: ReturnType<typeof setTimeout> | undefined;
constructor(options: { reviewQueue?: boolean; status?: string } = {}) {
// The landing page deep-links into the two lists it advertises: the
// documents someone asked this user to check (?review=1), and their own
// drafts (?status=draft). Both are otherwise filter controls that are
// easy to miss.
if (options.reviewQueue) this.assignedToMe = true;
if (options.status) this.status = options.status;
}
get searching(): boolean {
return this.search.trim().length > 0;
}
/** Search is ranked rather than paged, so the pager only applies to browsing. */
get pages(): number {
return this.searching ? 1 : Math.max(1, Math.ceil(this.total / this.perPage));
}
get visible(): DocumentRow[] {
return this.access === 'all'
? this.documents
: this.documents.filter((row) => row.access_reason === this.access);
}
async load(): Promise<void> {
const request = ++this.#inFlight;
const slow = setTimeout(() => {
if (request === this.#inFlight) this.loading = true;
}, LOADING_DELAY_MS);
const query = this.search.trim();
const { data } = query
? await api.GET('/api/documents/search', { params: { query: { q: query } } })
: await api.GET('/api/documents', {
params: {
query: {
status: (this.status || undefined) as DocumentSummary['status'] | undefined,
department: this.department || undefined,
assigned_to_me: this.assignedToMe || undefined,
sort: documentView.sort,
page: this.page
}
}
});
clearTimeout(slow);
// A slower earlier request must not overwrite newer results.
if (request !== this.#inFlight) return;
if (Array.isArray(data)) {
this.documents = data;
this.total = data.length;
} else {
this.documents = data?.items ?? [];
this.total = data?.total ?? 0;
this.perPage = data?.per_page ?? this.perPage;
}
this.loading = false;
}
/** Any change to what is listed starts over at page one — staying on page 4
* of a different result set shows an empty screen. */
reload(): void {
this.page = 1;
void this.load();
}
goTo(next: number): void {
this.page = Math.min(Math.max(1, next), this.pages);
void this.load();
}
onSearchInput(): void {
clearTimeout(this.#debounce);
this.#debounce = setTimeout(() => this.reload(), SEARCH_DEBOUNCE_MS);
}
}
+126
View File
@@ -0,0 +1,126 @@
// How a document describes itself: why you may see it, where it stands, when
// it was touched.
//
// The list and the detail page both answer those three questions, so the
// vocabulary lives here rather than twice. A plain module, not `.svelte.ts`:
// nothing here holds state, and the reactivity comes from the caller reading
// `i18n.locale` inside its own template. Written as functions with explicit
// cases: Paraglide is a compiler and can only check and tree-shake message
// keys it can see literally (docs/i18n.md), so a lookup by computed key would
// ship every message and turn a typo into a blank.
import Archive from '@lucide/svelte/icons/archive';
import Building2 from '@lucide/svelte/icons/building-2';
import CheckCircle2 from '@lucide/svelte/icons/circle-check-big';
import Globe from '@lucide/svelte/icons/globe';
import KeyRound from '@lucide/svelte/icons/key-round';
import MessageCircleQuestion from '@lucide/svelte/icons/message-circle-question';
import PencilLine from '@lucide/svelte/icons/pencil-line';
import UserIcon from '@lucide/svelte/icons/user';
import { i18n } from '$lib/i18n/locale.svelte';
import { m } from '$lib/paraglide/messages';
export const ACCESS_ICONS: Record<string, typeof Globe> = {
author: UserIcon,
department: Building2,
public: Globe,
granted: KeyRound,
review: MessageCircleQuestion
};
export function accessLabel(reason: string): string {
switch (reason) {
case 'author':
return m.documents_access_label_author();
case 'department':
return m.documents_access_label_department();
case 'public':
return m.documents_access_label_public();
case 'review':
return m.documents_access_label_review();
default:
return m.documents_access_label_granted();
}
}
export function accessHint(reason: string): string {
switch (reason) {
case 'author':
return m.documents_access_hint_author();
case 'department':
return m.documents_access_hint_department();
case 'public':
return m.documents_access_hint_public();
case 'review':
return m.documents_access_hint_review();
default:
return m.documents_access_hint_granted();
}
}
// Three states, and only three: a document is being written, readable, or
// retired. Doubt about the CONTENT is a review request instead — it can sit on
// a draft or on a document published for months, so it was never a status.
export const STATUS_ICONS: Record<string, typeof CheckCircle2> = {
draft: PencilLine,
published: CheckCircle2,
archived: Archive
};
export const STATUS_VARIANTS: Record<string, 'neutral' | 'warning' | 'success'> = {
draft: 'neutral',
published: 'success',
archived: 'neutral'
};
export function statusLabel(status: string): string {
switch (status) {
case 'draft':
return m.documents_status_draft();
case 'published':
return m.documents_status_published();
default:
return m.documents_status_archived();
}
}
/** The body without its own leading `# Title`.
*
* Every document starts with a heading that repeats its title, and every
* surface that shows the document already shows that title above the text.
* Rendering both makes the reader read the same words twice, so the heading
* is dropped where it is a duplicate — and kept when the author wrote
* something else there.
*/
export function bodyWithoutTitle(contentMd: string, title: string): string {
const match = /^\s*#\s+(.+?)\s*(\n|$)/.exec(contentMd);
if (!match || match[1].trim().toLowerCase() !== title.trim().toLowerCase()) return contentMd;
return contentMd.slice(match[0].length).replace(/^\n+/, '');
}
export function visibilityLabel(visibility: string): string {
switch (visibility) {
case 'public':
return m.documents_visibility_public();
case 'department':
return m.documents_visibility_department();
default:
return m.documents_visibility_restricted();
}
}
// The INTERFACE language, not the browser's: someone on an English browser who
// picked German would otherwise get German labels around English dates.
// Reading i18n.locale inside the function keeps it reactive; the formatter is
// kept until the language changes, because building one per row is wasteful.
let formatter: { locale: string; format: Intl.DateTimeFormat } | null = null;
export function formatDate(iso: string): string {
if (formatter?.locale !== i18n.locale) {
formatter = {
locale: i18n.locale,
format: new Intl.DateTimeFormat(i18n.locale, { dateStyle: 'medium' })
};
}
return formatter.format.format(new Date(iso));
}
+56
View File
@@ -0,0 +1,56 @@
// Client mirror of backend app/authoring/sections.py::active_section, used
// ONLY for the visual highlight of the section the cursor is in. The server
// owns the authoritative range an accepted suggestion overwrites (the
// `section` SSE frame), so this stays deliberately simple: heading-delimited,
// no oversized-section paragraph fallback.
const HEADING = /^(#{1,6})\s+/;
function headingLines(lines: string[]): Array<[number, number]> {
const out: Array<[number, number]> = [];
let inFence = false;
for (let i = 0; i < lines.length; i++) {
if (lines[i].trimStart().startsWith('```')) {
inFence = !inFence;
continue;
}
if (inFence) continue;
const match = HEADING.exec(lines[i]);
if (match) out.push([i, match[1].length]);
}
return out;
}
/** 1-based inclusive [start, end] line range of the section at `cursorLine`. */
export function activeSection(text: string, cursorLine: number): { start: number; end: number } {
const lines = text.split('\n');
const n = lines.length;
if (n === 0) return { start: 1, end: 1 };
const cursor0 = Math.max(1, Math.min(cursorLine, n)) - 1;
const headings = headingLines(lines);
let owner: [number, number] | null = null;
for (const heading of headings) {
if (heading[0] <= cursor0) owner = heading;
else break;
}
let start0: number;
let end0: number;
if (!owner) {
start0 = 0;
end0 = headings.length ? headings[0][0] - 1 : n - 1;
} else {
start0 = owner[0];
const level = owner[1];
end0 = n - 1;
for (const [idx, lvl] of headings) {
if (idx > start0 && lvl <= level) {
end0 = idx - 1;
break;
}
}
}
while (end0 > start0 && lines[end0].trim() === '') end0--;
return { start: start0 + 1, end: end0 + 1 };
}
+48
View File
@@ -0,0 +1,48 @@
// How this person likes to look at the document list.
//
// Per-device, like the theme and the sidebar collapse: which layout reads
// better depends on the screen in front of you, not on who you are. Stored
// under one key so a future third preference does not need a third entry.
const STORAGE_KEY = 'pablan.documents.view';
export type Sort = 'updated' | 'created';
type Stored = { sort: Sort };
// Grid is the only view for now: a card shows the department and the
// updated date next to the title, which is what people scan for. The list
// layout and its toggle were removed rather than kept as dead options;
// the markup is one branch away in git if it comes back.
const DEFAULTS: Stored = { sort: 'updated' };
function parse(raw: string | null): Stored {
if (!raw) return DEFAULTS;
try {
const value = JSON.parse(raw) as Partial<Stored>;
return { sort: value.sort === 'created' ? 'created' : 'updated' };
} catch {
// Corrupted or from an older shape: the defaults are always valid.
return DEFAULTS;
}
}
class DocumentView {
#state = $state<Stored>({ ...DEFAULTS });
/** Call once the component is mounted — localStorage has no server side. */
init(): void {
this.#state = parse(localStorage.getItem(STORAGE_KEY));
}
get sort(): Sort {
return this.#state.sort;
}
set(patch: Partial<Stored>): void {
this.#state = { ...this.#state, ...patch };
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.#state));
}
}
export const documentView = new DocumentView();