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:
co-authored by
Claude Opus 5
parent
68d3a43191
commit
784b76baf7
@@ -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}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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' }
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user