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>
|
||||
Reference in New Issue
Block a user