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
208 lines
6.7 KiB
Svelte
208 lines
6.7 KiB
Svelte
<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>
|