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:
@@ -17,17 +17,20 @@
|
||||
let { children } = $props();
|
||||
|
||||
const isLogin = $derived(page.url.pathname === `${base}/login`);
|
||||
const isPanel = $derived(page.url.pathname.startsWith(`${base}/panel`));
|
||||
const inConversations = $derived(
|
||||
page.url.pathname.startsWith(`${base}/conversations`)
|
||||
);
|
||||
let navBefore: boolean | null = null;
|
||||
|
||||
onMount(() => {
|
||||
session.check();
|
||||
if (!isPanel) {
|
||||
session.check();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!session.ready) {
|
||||
if (!session.ready || isPanel) {
|
||||
return;
|
||||
}
|
||||
if (!(session.user || isLogin)) {
|
||||
@@ -74,7 +77,7 @@
|
||||
<Toaster position="bottom-right" richColors />
|
||||
|
||||
<Tooltip.Provider delayDuration={300}>
|
||||
{#if isLogin}
|
||||
{#if isLogin || isPanel}
|
||||
{@render children()}
|
||||
{:else if session.ready && session.user}
|
||||
<div class="flex h-dvh flex-col overflow-hidden sm:flex-row">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn-svelte/tailwind.css";
|
||||
@import "@fontsource-variable/inter";
|
||||
@import "../lib/panel/panel.css";
|
||||
|
||||
@plugin "@tailwindcss/forms";
|
||||
@plugin "@tailwindcss/typography";
|
||||
@@ -178,16 +179,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@utility touch-shown {
|
||||
@media (hover: none) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@utility tabular {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@utility hue-chip {
|
||||
color: oklch(0.45 0.13 var(--hue));
|
||||
background: oklch(0.6 0.09 var(--hue) / 0.12);
|
||||
@@ -197,49 +188,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-none {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
--color-kind-master: var(--signal);
|
||||
--color-kind-branch: var(--status-reply);
|
||||
--color-kind-deep: var(--status-meeting);
|
||||
--color-kind-job: var(--status-work);
|
||||
--color-kind-fork: var(--status-skip);
|
||||
--color-ok: var(--status-done);
|
||||
--color-warn: var(--warn);
|
||||
--color-link: var(--link);
|
||||
--animate-pulse-dot: pulse-dot 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
--animate-rise: rise 220ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in oklab, var(--signal) 55%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 5px color-mix(in oklab, var(--signal) 0%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(3px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
scrollbar-color: color-mix(in oklab, var(--foreground) 22%, transparent)
|
||||
@@ -281,19 +229,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@utility row-hover {
|
||||
transition: background-color 150ms ease-out;
|
||||
&:hover {
|
||||
background: color-mix(in oklab, var(--foreground) 4%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@utility ledger-grid {
|
||||
display: grid;
|
||||
column-gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@utility hairline {
|
||||
border-color: color-mix(in oklab, var(--border) 100%, transparent);
|
||||
}
|
||||
|
||||
@@ -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