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,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)}
|
||||
/>
|
||||
Reference in New Issue
Block a user