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>