feat(ui,api,telegram): shared conversation panel for obsidian and /admin/panel, bind materializes a window, inbox survives link previews

This commit is contained in:
hh committed 2026-08-29 18:30:33 +02:00
1 parent be114e6eaf
commit 60dc7db925
58 files changed
+2858 -499

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
<script lang="ts">
import { onMount } from "svelte";
import { ApiClient, ApiError } from "$lib/api/client";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { Toaster } from "$lib/components/ui/sonner";
import * as Tooltip from "$lib/components/ui/tooltip";
import { ConversationIndex } from "$lib/panel/index.svelte";
import PanelShell from "$lib/panel/panel-shell.svelte";
// The browser twin of the Obsidian panel: ``?token=`` once, then the
// token lives in localStorage and the conversation id in the hash.
const TOKEN_KEY = "beaver.panel.token";
let client = $state<ApiClient | null>(null);
let index = $state<ConversationIndex | null>(null);
let selected = $state<string | null>(null);
let draft = $state("");
let failure = $state<string | null>(null);
function connect(token: string) {
localStorage.setItem(TOKEN_KEY, token);
const api = new ApiClient(window.location.origin, token, {
onUnauthorized: () => {
localStorage.removeItem(TOKEN_KEY);
failure = "The token was rejected. Paste a fresh one.";
client = null;
index = null;
return Promise.resolve(null);
},
});
client = api;
index = new ConversationIndex(() => api);
failure = null;
}
async function submit() {
const token = draft.trim();
if (!token) {
return;
}
try {
await new ApiClient(window.location.origin, token).agents();
connect(token);
draft = "";
} catch (error) {
failure =
error instanceof ApiError
? `${error.status}: ${error.message}`
: String(error);
}
}
onMount(() => {
const url = new URL(window.location.href);
const fromQuery = url.searchParams.get("token");
if (fromQuery) {
url.searchParams.delete("token");
window.history.replaceState(window.history.state, "", url);
}
const token = fromQuery ?? localStorage.getItem(TOKEN_KEY);
if (token) {
connect(token);
}
selected = window.location.hash.slice(1) || null;
});
$effect(() => {
const hash = selected ? `#${selected}` : "";
if (window.location.hash !== hash) {
window.history.replaceState(
window.history.state,
"",
`${window.location.pathname}${window.location.search}${hash}`
);
}
});
</script>
<svelte:head><title>Panel · Beaver</title></svelte:head>
<Toaster position="bottom-right" richColors />
<Tooltip.Provider delayDuration={300}>
{#if client && index}
<div class="h-dvh">
<PanelShell {client} {index} bind:selected />
</div>
{:else}
<div class="flex h-dvh items-center justify-center bg-sidebar p-6">
<form
class="flex w-full max-w-sm flex-col gap-4 rounded-xl border bg-background p-6"
onsubmit={(event) => {
event.preventDefault();
submit();
}}
style="box-shadow: 0 8px 24px -12px rgb(0 0 0 / 0.25)"
>
<div class="flex items-center gap-2">
<span class="size-2.5 rounded-sm bg-primary"></span>
<span class="font-semibold tracking-tight">Beaver panel</span>
</div>
<p class="text-muted-foreground text-sm">
Paste a token with the <code>api</code> scope. It stays in this
browser; open the page with <code>?token=…</code> to hand it over.
</p>
<div class="flex flex-col gap-1.5">
<Label for="panel-token">Token</Label>
<Input
autocomplete="off"
id="panel-token"
placeholder="bvr_…"
type="password"
bind:value={draft}
/>
</div>
{#if failure}
<p class="text-destructive text-sm" role="alert">{failure}</p>
{/if}
<Button disabled={!draft.trim()} type="submit">Open the panel</Button>
</form>
</div>
{/if}
</Tooltip.Provider>