ui: consolidate playground nav into sidebar
Move Playground tabs into the sidebar as collapsible sub-items and make the sidebar the sole navigation for playground interfaces. - add collapsible UI primitive (bits-ui wrapper) - add playground store with selected tab and menu open state (persistent) - make Playground menu item collapsible; whole button toggles expand state - move playground sub-items (Chat/Images/Speech/etc) under Playground - remove in-page Tabs from Playground.svelte - update sectionTitle breadcrumb to reflect active sub-item - remove bg-sidebar panel background so items sit on page background - remove persistent data-active background tint on menu items fixes #123
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
import { enableAPIEvents, checkPerformanceEnabled } from "./stores/api";
|
||||
import { initScreenWidth, initSystemThemeListener, isDarkMode, appTitle, connectionState } from "./stores/theme";
|
||||
import { currentRoute } from "./stores/route";
|
||||
import { selectedPlaygroundTab, playgroundTabs } from "./stores/playground";
|
||||
|
||||
const routes = {
|
||||
"/": PlaygroundStub,
|
||||
@@ -31,7 +32,13 @@
|
||||
"/performance": "Performance",
|
||||
};
|
||||
|
||||
let sectionTitle = $derived(routeTitles[$currentRoute] ?? "Playground");
|
||||
let sectionTitle = $derived.by(() => {
|
||||
if ($currentRoute === "/") {
|
||||
const tab = playgroundTabs.find((t) => t.id === $selectedPlaygroundTab);
|
||||
return `Playground / ${tab?.label ?? ""}`;
|
||||
}
|
||||
return routeTitles[$currentRoute] ?? "Playground";
|
||||
});
|
||||
|
||||
function handleRouteLoaded(event: { detail: { route: string | RegExp } }) {
|
||||
const route = event.detail.route;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { link } from "svelte-spa-router";
|
||||
import { House, Boxes, Activity, ScrollText, Gauge, Sun, Moon, Monitor } from "@lucide/svelte";
|
||||
import { House, Boxes, Activity, ScrollText, Gauge, Sun, Moon, Monitor, ChevronRight } from "@lucide/svelte";
|
||||
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
||||
import * as Collapsible from "$lib/components/ui/collapsible/index.js";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import { toggleTheme, themeMode, appTitle } from "../stores/theme";
|
||||
import { currentRoute } from "../stores/route";
|
||||
import { playgroundActivity } from "../stores/playgroundActivity";
|
||||
import { performanceEnabled } from "../stores/api";
|
||||
import { selectedPlaygroundTab, playgroundTabs, playgroundMenuOpen } from "../stores/playground";
|
||||
import ConnectionStatus from "./ConnectionStatus.svelte";
|
||||
|
||||
function handleTitleChange(newTitle: string): void {
|
||||
@@ -55,16 +57,51 @@
|
||||
<Sidebar.Content>
|
||||
<Sidebar.Group>
|
||||
<Sidebar.GroupContent>
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.Menu class="gap-1">
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={isActive("/", $currentRoute)} tooltipContent="Playground">
|
||||
{#snippet child({ props })}
|
||||
<a href="/" use:link {...props}>
|
||||
<House />
|
||||
<span class={$playgroundActivity ? "activity-link" : ""}>Playground</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
<Collapsible.Root
|
||||
open={$playgroundMenuOpen}
|
||||
onOpenChange={(v) => playgroundMenuOpen.set(v)}
|
||||
class="gap-0"
|
||||
>
|
||||
<Collapsible.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuButton
|
||||
{...props}
|
||||
isActive={isActive("/", $currentRoute)}
|
||||
tooltipContent="Playground"
|
||||
>
|
||||
<House />
|
||||
<span class={$playgroundActivity ? "activity-link" : ""}>Playground</span>
|
||||
<ChevronRight
|
||||
class="ml-auto transition-transform duration-200 {$playgroundMenuOpen ? 'rotate-90' : ''}"
|
||||
/>
|
||||
</Sidebar.MenuButton>
|
||||
{/snippet}
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Sidebar.MenuSub>
|
||||
{#each playgroundTabs as tab (tab.id)}
|
||||
<Sidebar.MenuSubItem>
|
||||
<Sidebar.MenuSubButton
|
||||
isActive={isActive("/", $currentRoute) && $selectedPlaygroundTab === tab.id}
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<a
|
||||
href="/"
|
||||
use:link
|
||||
{...props}
|
||||
onclick={() => selectedPlaygroundTab.set(tab.id)}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuSubButton>
|
||||
</Sidebar.MenuSubItem>
|
||||
{/each}
|
||||
</Sidebar.MenuSub>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
</Sidebar.MenuItem>
|
||||
|
||||
<Sidebar.MenuItem>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: CollapsiblePrimitive.ContentProps = $props();
|
||||
</script>
|
||||
|
||||
<CollapsiblePrimitive.Content
|
||||
bind:ref
|
||||
data-slot="collapsible-content"
|
||||
class={className}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</CollapsiblePrimitive.Content>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: CollapsiblePrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<CollapsiblePrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="collapsible-trigger"
|
||||
class={className}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</CollapsiblePrimitive.Trigger>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: CollapsiblePrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<CollapsiblePrimitive.Root
|
||||
bind:ref
|
||||
data-slot="collapsible"
|
||||
class={className}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</CollapsiblePrimitive.Root>
|
||||
@@ -0,0 +1,13 @@
|
||||
import Root from "./collapsible.svelte";
|
||||
import Trigger from "./collapsible-trigger.svelte";
|
||||
import Content from "./collapsible-content.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Trigger,
|
||||
Content,
|
||||
//
|
||||
Root as Collapsible,
|
||||
Trigger as CollapsibleTrigger,
|
||||
Content as CollapsibleContent,
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
import { tv, type VariantProps } from "tailwind-variants";
|
||||
|
||||
export const sidebarMenuButtonVariants = tv({
|
||||
base: "ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground gap-2 rounded-md p-2 text-left text-sm transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 data-active:font-medium peer/menu-button group/menu-button flex w-full items-center overflow-hidden outline-hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
base: "ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:font-medium data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground gap-2 rounded-md p-2 text-left text-sm transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 peer/menu-button group/menu-button flex w-full items-center overflow-hidden outline-hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
const mergedProps = $derived({
|
||||
class: cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground h-7 gap-2 rounded-md px-2 focus-visible:ring-2 data-[size=md]:text-sm data-[size=sm]:text-xs [&>svg]:size-4 flex min-w-0 -translate-x-px items-center overflow-hidden outline-hidden group-data-[collapsible=icon]:hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:shrink-0",
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground data-active:font-medium data-active:text-sidebar-accent-foreground h-7 gap-2 rounded-md px-2 focus-visible:ring-2 data-[size=md]:text-sm data-[size=sm]:text-xs [&>svg]:size-4 flex min-w-0 -translate-x-px items-center overflow-hidden outline-hidden group-data-[collapsible=icon]:hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:shrink-0",
|
||||
className
|
||||
),
|
||||
"data-slot": "sidebar-menu-sub-button",
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
{#if collapsible === "none"}
|
||||
<div
|
||||
class={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
"bg-background text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
className
|
||||
)}
|
||||
bind:this={ref}
|
||||
@@ -44,7 +44,7 @@
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
class={cn(
|
||||
"bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden",
|
||||
"bg-background text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden",
|
||||
className
|
||||
)}
|
||||
style="--sidebar-width: {SIDEBAR_WIDTH_MOBILE};"
|
||||
@@ -99,7 +99,7 @@
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
class="bg-sidebar group-data-[variant=floating]:ring-sidebar-border group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 flex size-full flex-col"
|
||||
class="bg-background group-data-[variant=floating]:ring-sidebar-border group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 flex size-full flex-col"
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { persistentStore } from "../stores/persistent";
|
||||
import ChatInterface from "../components/playground/ChatInterface.svelte";
|
||||
import ImageInterface from "../components/playground/ImageInterface.svelte";
|
||||
import AudioInterface from "../components/playground/AudioInterface.svelte";
|
||||
@@ -7,52 +6,28 @@
|
||||
import RerankInterface from "../components/playground/RerankInterface.svelte";
|
||||
import ConcurrencyInterface from "../components/playground/ConcurrencyInterface.svelte";
|
||||
import * as Card from "$lib/components/ui/card/index.js";
|
||||
import * as Tabs from "$lib/components/ui/tabs/index.js";
|
||||
|
||||
type Tab = "chat" | "images" | "speech" | "audio" | "rerank" | "concurrency";
|
||||
|
||||
const selectedTabStore = persistentStore<Tab>("playground-selected-tab", "chat");
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: "chat", label: "Chat" },
|
||||
{ id: "images", label: "Images" },
|
||||
{ id: "speech", label: "Speech" },
|
||||
{ id: "audio", label: "Transcription" },
|
||||
{ id: "rerank", label: "Rerank" },
|
||||
{ id: "concurrency", label: "Load Test" },
|
||||
];
|
||||
import { selectedPlaygroundTab } from "../stores/playground";
|
||||
</script>
|
||||
|
||||
<Card.Root class="flex h-full flex-col gap-0 overflow-hidden p-4">
|
||||
<!-- Tab navigation: triggers update the store; content stays mounted to preserve state -->
|
||||
<div class="mb-4 shrink-0">
|
||||
<Tabs.Root bind:value={() => $selectedTabStore, (v) => selectedTabStore.set(v as Tab)}>
|
||||
<Tabs.List class="h-auto flex-wrap">
|
||||
{#each tabs as tab (tab.id)}
|
||||
<Tabs.Trigger value={tab.id}>{tab.label}</Tabs.Trigger>
|
||||
{/each}
|
||||
</Tabs.List>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
|
||||
<!-- Tab content (all interfaces stay mounted) -->
|
||||
<!-- Tab content (all interfaces stay mounted); navigation via the sidebar -->
|
||||
<div class="relative flex-1 overflow-hidden">
|
||||
<div class="h-full" class:hidden={$selectedTabStore !== "chat"}>
|
||||
<div class="h-full" class:hidden={$selectedPlaygroundTab !== "chat"}>
|
||||
<ChatInterface />
|
||||
</div>
|
||||
<div class="h-full" class:hidden={$selectedTabStore !== "images"}>
|
||||
<div class="h-full" class:hidden={$selectedPlaygroundTab !== "images"}>
|
||||
<ImageInterface />
|
||||
</div>
|
||||
<div class="h-full" class:hidden={$selectedTabStore !== "speech"}>
|
||||
<div class="h-full" class:hidden={$selectedPlaygroundTab !== "speech"}>
|
||||
<SpeechInterface />
|
||||
</div>
|
||||
<div class="h-full" class:hidden={$selectedTabStore !== "audio"}>
|
||||
<div class="h-full" class:hidden={$selectedPlaygroundTab !== "audio"}>
|
||||
<AudioInterface />
|
||||
</div>
|
||||
<div class="h-full" class:hidden={$selectedTabStore !== "rerank"}>
|
||||
<div class="h-full" class:hidden={$selectedPlaygroundTab !== "rerank"}>
|
||||
<RerankInterface />
|
||||
</div>
|
||||
<div class="h-full" class:hidden={$selectedTabStore !== "concurrency"}>
|
||||
<div class="h-full" class:hidden={$selectedPlaygroundTab !== "concurrency"}>
|
||||
<ConcurrencyInterface />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { persistentStore } from "./persistent";
|
||||
|
||||
export type PlaygroundTab = "chat" | "images" | "speech" | "audio" | "rerank" | "concurrency";
|
||||
|
||||
export const playgroundTabs: { id: PlaygroundTab; label: string }[] = [
|
||||
{ id: "chat", label: "Chat" },
|
||||
{ id: "images", label: "Images" },
|
||||
{ id: "speech", label: "Speech" },
|
||||
{ id: "audio", label: "Transcription" },
|
||||
{ id: "rerank", label: "Rerank" },
|
||||
{ id: "concurrency", label: "Load Test" },
|
||||
];
|
||||
|
||||
export const selectedPlaygroundTab = persistentStore<PlaygroundTab>("playground-selected-tab", "chat");
|
||||
|
||||
export const playgroundMenuOpen = persistentStore<boolean>("playground-menu-open", true);
|
||||
Reference in New Issue
Block a user