feat(ui,api): strip board redesign - island, rail by day, context view, server search, vault graph
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
<script lang="ts">
|
||||
import SearchIcon from "@lucide/svelte/icons/search";
|
||||
import { goto } from "$app/navigation";
|
||||
import { base } from "$app/paths";
|
||||
import type { ConversationSummary } from "$lib/api/types";
|
||||
import { clip, fmtRelative, shortId } from "$lib/format";
|
||||
import { gateway } from "$lib/gateway.svelte";
|
||||
import { SECTIONS, SYSTEM_PAGES } from "$lib/nav";
|
||||
import { ui } from "$lib/ui.svelte";
|
||||
import { cn } from "$lib/utils";
|
||||
import KindMark from "./kind-mark.svelte";
|
||||
import { byActivity } from "./state";
|
||||
|
||||
interface Item {
|
||||
hint: string;
|
||||
href: string;
|
||||
id: string;
|
||||
kind: "section" | "conversation";
|
||||
label: string;
|
||||
row?: ConversationSummary;
|
||||
}
|
||||
|
||||
const LIMIT = 10;
|
||||
let query = $state("");
|
||||
let cursor = $state(0);
|
||||
let input = $state<HTMLInputElement | null>(null);
|
||||
|
||||
const items = $derived.by((): Item[] => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
const sections: Item[] = [...SECTIONS, ...SYSTEM_PAGES]
|
||||
.filter((s) => !needle || s.label.toLowerCase().includes(needle))
|
||||
.map((s) => ({
|
||||
hint: "section",
|
||||
href: `${base}${s.href}`,
|
||||
id: `s:${s.href}`,
|
||||
kind: "section",
|
||||
label: s.label,
|
||||
}));
|
||||
const conversations: Item[] = gateway.conversations
|
||||
.filter(
|
||||
(row) =>
|
||||
!needle ||
|
||||
(row.title ?? "").toLowerCase().includes(needle) ||
|
||||
row.id.startsWith(needle) ||
|
||||
row.agent.toLowerCase().includes(needle) ||
|
||||
(row.last_item?.text ?? "").toLowerCase().includes(needle)
|
||||
)
|
||||
.sort(byActivity)
|
||||
.slice(0, needle ? LIMIT : 5)
|
||||
.map((row) => ({
|
||||
hint: `${row.status === "open" ? "" : `${row.status} · `}${fmtRelative(row.last_activity_at ?? row.created_at)}`,
|
||||
href: `${base}/conversations/${row.id}`,
|
||||
id: row.id,
|
||||
kind: "conversation",
|
||||
label: row.title ?? `${row.kind} · ${shortId(row.id)}`,
|
||||
row,
|
||||
}));
|
||||
return needle
|
||||
? [...conversations, ...sections]
|
||||
: [...sections.slice(0, 4), ...conversations];
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (ui.palette) {
|
||||
query = "";
|
||||
cursor = 0;
|
||||
queueMicrotask(() => input?.focus());
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (cursor >= items.length) {
|
||||
cursor = Math.max(0, items.length - 1);
|
||||
}
|
||||
});
|
||||
|
||||
function go(item: Item) {
|
||||
ui.closeAll();
|
||||
goto(item.href);
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
cursor = Math.min(items.length - 1, cursor + 1);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
cursor = Math.max(0, cursor - 1);
|
||||
} else if (event.key === "Enter" && items[cursor]) {
|
||||
event.preventDefault();
|
||||
go(items[cursor]);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if ui.palette}
|
||||
<div
|
||||
aria-label="Go to"
|
||||
aria-modal="true"
|
||||
class="fixed inset-0 z-40 flex items-start justify-center px-4 pt-[12vh]"
|
||||
role="dialog"
|
||||
>
|
||||
<button
|
||||
aria-label="Close"
|
||||
class="absolute inset-0 animate-fade-in bg-foreground/15 backdrop-blur-[2px]"
|
||||
onclick={() => ui.closePalette()}
|
||||
type="button"
|
||||
></button>
|
||||
<div
|
||||
class="relative flex w-full max-w-lg animate-island-in flex-col overflow-hidden rounded-xl border bg-popover shadow-float"
|
||||
>
|
||||
<label class="flex items-center gap-2 border-b px-3">
|
||||
<SearchIcon class="size-4 shrink-0 text-icon" />
|
||||
<input
|
||||
autocomplete="off"
|
||||
class="h-11 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
onkeydown={onKeydown}
|
||||
placeholder="Conversation, section…"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
bind:this={input}
|
||||
bind:value={query}
|
||||
>
|
||||
<kbd
|
||||
class="rounded border px-1 font-sans text-[10px] text-muted-foreground"
|
||||
>
|
||||
esc
|
||||
</kbd>
|
||||
</label>
|
||||
<div class="max-h-[50vh] overflow-y-auto py-1" role="listbox">
|
||||
{#each items as item, index (item.id)}
|
||||
<div
|
||||
aria-selected={index === cursor}
|
||||
class={cn(
|
||||
"flex cursor-pointer items-center gap-2.5 px-3 py-2 text-sm",
|
||||
index === cursor && "bg-accent"
|
||||
)}
|
||||
onclick={() => go(item)}
|
||||
onkeydown={(event) => event.key === "Enter" && go(item)}
|
||||
onmousemove={() => {
|
||||
cursor = index;
|
||||
}}
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
>
|
||||
{#if item.row}
|
||||
<KindMark kind={item.row.kind} />
|
||||
{:else}
|
||||
<span class="size-5 shrink-0"></span>
|
||||
{/if}
|
||||
<span class="min-w-0 flex-1 truncate">{clip(item.label, 80)}</span>
|
||||
<span class="tabular shrink-0 text-muted-foreground text-xs">
|
||||
{item.hint}
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="px-3 py-3 text-muted-foreground text-sm">
|
||||
Nothing matches.
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user