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,203 @@
<script lang="ts">
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Building2 from '@lucide/svelte/icons/building-2';
import Plus from '@lucide/svelte/icons/plus';
import { api } from '$lib/api/client';
import { apiErrorCode, errorMessage } from '$lib/api/errors';
import type { components } from '$lib/api/schema';
import IconAction from '$lib/components/IconAction.svelte';
import Button from '$lib/components/Button.svelte';
import Card from '$lib/components/Card.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import Dialog from '$lib/components/Dialog.svelte';
import FormField from '$lib/components/FormField.svelte';
import Input from '$lib/components/Input.svelte';
import { m } from '$lib/paraglide/messages';
type Department = components['schemas']['DepartmentOut'];
// The page owns the list because the user manager reads it too; every
// change here reports back so both stay on the same set.
let {
departments,
onChanged
}: { departments: Department[]; onChanged: () => Promise<void> | void } = $props();
let error = $state<string | null>(null);
let creating = $state(false);
let editing = $state<Department | null>(null);
let draft = $state('');
let confirmRequest = $state<{ title: string; message: string; run: () => void } | null>(null);
function startCreate() {
error = null;
draft = '';
creating = true;
}
function startEdit(department: Department) {
error = null;
draft = department.name;
editing = department;
}
async function create(event: SubmitEvent) {
event.preventDefault();
error = null;
const { data, error: apiError } = await api.POST('/api/admin/departments', {
body: { name: draft }
});
if (!data) {
error = errorMessage(apiErrorCode(apiError), m.admin_departments_create_failed());
return;
}
creating = false;
await onChanged();
}
async function save(department: Department) {
error = null;
const { error: apiError } = await api.PATCH('/api/admin/departments/{department_id}', {
params: { path: { department_id: department.id } },
body: { name: draft }
});
if (apiError) {
error = errorMessage(apiErrorCode(apiError), m.admin_departments_rename_failed());
return;
}
editing = null;
await onChanged();
}
function remove(department: Department) {
confirmRequest = {
title: m.common_delete(),
message: m.admin_departments_delete_confirm({ name: department.name }),
run: async () => {
// The modal is the admin's acknowledgement, so pass confirm — the
// backend otherwise refuses to silently drop a department still in
// use (its shared-access grants CASCADE away).
await api.DELETE('/api/admin/departments/{department_id}', {
params: { path: { department_id: department.id }, query: { confirm: true } }
});
await onChanged();
}
};
}
</script>
<Card>
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 class="flex items-center gap-2 text-lg font-semibold">
<Building2 size={18} class="text-ink-muted" />
{m.admin_departments_title()}
</h2>
<Button size="sm" onclick={startCreate} data-testid="new-department">
<Plus size={15} />
{m.admin_departments_new_button()}
</Button>
</div>
{#if error && !creating && !editing}
<p role="alert" class="mb-2 text-sm text-danger">{error}</p>
{/if}
<ul class="flex flex-col gap-1" data-testid="department-list">
{#each departments as department (department.id)}
<li
class="flex items-center justify-between gap-2 rounded-md px-2 py-1 hover:bg-surface-sunken"
>
<span class="text-sm">{department.name}</span>
<span class="inline-flex items-center gap-1 whitespace-nowrap">
<IconAction
label={m.admin_departments_rename()}
icon={Pencil}
size="sm"
onclick={() => startEdit(department)}
testid="rename-department"
/>
<IconAction
label={m.admin_users_delete()}
icon={Trash2}
size="sm"
variant="danger"
onclick={() => remove(department)}
/>
</span>
</li>
{/each}
</ul>
</Card>
<Dialog
open={creating}
onOpenChange={(open) => {
if (!open) creating = false;
}}
title={m.admin_departments_create_title()}
data-testid="create-department-dialog"
>
<form class="flex flex-col gap-3" onsubmit={create}>
<FormField label={m.admin_departments_new()} for="new-department-name">
<Input
id="new-department-name"
required
placeholder={m.admin_departments_placeholder()}
bind:value={draft}
/>
</FormField>
{#if error}
<p role="alert" class="text-sm text-danger">{error}</p>
{/if}
<div class="flex gap-2">
<Button type="submit" size="sm" data-testid="create-department">
{m.admin_departments_create()}
</Button>
<Button variant="ghost" size="sm" onclick={() => (creating = false)}>
{m.common_cancel()}
</Button>
</div>
</form>
</Dialog>
<Dialog
open={editing !== null}
onOpenChange={(open) => {
if (!open) editing = null;
}}
title={m.admin_department_edit_title()}
data-testid="department-dialog"
>
{#if editing}
{@const department = editing}
<div class="flex flex-col gap-3">
<FormField label={m.admin_departments_title()} for="edit-department-name">
<Input
id="edit-department-name"
placeholder={m.admin_departments_placeholder()}
bind:value={draft}
/>
</FormField>
{#if error}
<p role="alert" class="text-sm text-danger">{error}</p>
{/if}
<div class="flex gap-2">
<Button size="sm" onclick={() => save(department)} data-testid="save-department">
{m.admin_users_save()}
</Button>
<Button variant="ghost" size="sm" onclick={() => (editing = null)}>
{m.common_cancel()}
</Button>
</div>
</div>
{/if}
</Dialog>
<ConfirmDialog
open={confirmRequest !== null}
title={confirmRequest?.title ?? ''}
message={confirmRequest?.message ?? ''}
onConfirm={() => confirmRequest?.run()}
onClose={() => (confirmRequest = null)}
/>