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:
1 parent
be114e6eaf
commit
60dc7db925
58 files changed
+2858
-499
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user