Przeglądaj źródła

feat: add layout

Thomas Zhang 3 miesięcy temu
rodzic
commit
4e2a5f675e

+ 0 - 1
src/lib/assets/favicon.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

+ 31 - 0
src/lib/assets/logo.svg

@@ -0,0 +1,31 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="-12 -12 24 24">
+    <path stroke="none" fill="#c8f" d="M 0 0 L -8.66 -5 L 0 -10 L 8.66 -5 Z" />
+    <path stroke="none" fill="#60c" d="M 0 0 L -8.66 -5 L -8.66 5 L 0 10 Z" />
+    <path stroke="none" fill="#a4f" d="M 0 0 L 0 10 L 8.66 5 L 8.66 -5 Z" />
+    <path stroke="none" fill="#205"
+        d="
+            M 4.33 -7.5
+            L -4.33 -2.5
+            L -4.33 2.5
+            L 0 5
+            L 6.5 1.25
+            L 6.5 3.75
+            L 0 7.5
+            L -6.5 3.75
+            L -6.5 -3.75
+            L 2.17 -8.75
+            Z
+        "
+    />
+    <path stroke="none" fill="#205"
+        d="
+            M 8.66 -5
+            L 8.66 -3.75
+            L 0 1.25
+            L -1.08 0.625
+            L -1.08 -0.625
+            L 7.58 -5.625
+            Z
+        "
+    />
+</svg>

+ 152 - 0
src/lib/components/layout/side/bar.svelte

@@ -0,0 +1,152 @@
+<script lang="ts">
+	import {
+		BookSearch,
+		Bot,
+		BotMessageSquare,
+		Brain,
+		BrainCircuit,
+		ChevronDown,
+		ChevronUp,
+		Component,
+		Key,
+		LibraryBig,
+		Toolbox,
+		Wrench,
+	} from '@lucide/svelte';
+
+	import { goto } from '$app/navigation';
+	import { resolve } from '$app/paths';
+	import { page } from '$app/state';
+	import type { ResolvedPathname } from '$app/types';
+
+	import * as DropdownMenu from '$lib/components/controls/dropdown-menu';
+	import * as Sidebar from '$lib/components/controls/sidebar';
+	import type { User } from '$lib/types/user';
+	import type { Workspace } from '$lib/types/workspace';
+
+	interface NavItem {
+		label: string;
+		icon: typeof Bot;
+		href: ResolvedPathname;
+	}
+
+	interface NavItemGroup {
+		label: string;
+		items: NavItem[];
+	}
+
+	let {
+		workspaceId,
+		workspaces,
+		user,
+	}: { workspaceId: string | null; workspaces: Workspace[]; user: User | undefined } = $props();
+	const workspaceName = $derived(
+		workspaces.find((ws) => ws.id === workspaceId)?.displayName ?? 'Unknown Workspace',
+	);
+
+	const navItemGroups: NavItemGroup[] = [
+		{
+			label: 'Utilities',
+			items: [
+				{ label: 'Agent Chat', icon: BotMessageSquare, href: resolve('/utilities/testing/agent') },
+				{ label: 'Retrieval', icon: BookSearch, href: resolve('/utilities/testing/kb') },
+				{ label: 'Tool Calling', icon: Wrench, href: resolve('/utilities/testing/tool') },
+				{ label: 'Model Inference', icon: BrainCircuit, href: resolve('/utilities/testing/model') },
+			],
+		},
+		{
+			label: 'Workloads',
+			items: [
+				{ label: 'Agents', icon: Bot, href: resolve('/workloads/agents') },
+				{ label: 'Knowledge Bases', icon: LibraryBig, href: resolve('/workloads/kbs') },
+				{ label: 'Tools', icon: Toolbox, href: resolve('/workloads/tools') },
+				{ label: 'Models', icon: Brain, href: resolve('/workloads/models') },
+			],
+		},
+		{
+			label: 'Resources',
+			items: [
+				{ label: 'Secrets', icon: Key, href: resolve('/resources/secrets') },
+				{ label: 'Stacks', icon: Component, href: resolve('/resources/stacks') },
+			],
+		},
+	];
+
+	const isActive = (href: string) =>
+		href === '/' ? page.url.pathname === '/' : page.url.pathname.startsWith(href);
+</script>
+
+<Sidebar.Root class="border-white/10 backdrop-blur-md" collapsible="icon">
+	<Sidebar.Header class="group-data-[collapsible=icon]:hidden">
+		<Sidebar.Menu>
+			<Sidebar.MenuItem>
+				<DropdownMenu.Root>
+					<DropdownMenu.Trigger>
+						{#snippet child({ props })}
+							<Sidebar.MenuButton class="whitespace-nowrap" {...props}>
+								{workspaceName}
+								<ChevronDown class="ms-auto" />
+							</Sidebar.MenuButton>
+						{/snippet}
+					</DropdownMenu.Trigger>
+					<DropdownMenu.Content class="w-(--bits-dropdown-menu-anchor-width)">
+						{#each workspaces as workspace (workspace.id)}
+							<DropdownMenu.Item>
+								<span>{workspace.displayName}</span>
+							</DropdownMenu.Item>
+						{/each}
+					</DropdownMenu.Content>
+				</DropdownMenu.Root>
+			</Sidebar.MenuItem>
+		</Sidebar.Menu>
+	</Sidebar.Header>
+	<Sidebar.Content>
+		{#each navItemGroups as group (group.label)}
+			<Sidebar.Group>
+				<Sidebar.GroupLabel>{group.label}</Sidebar.GroupLabel>
+				<Sidebar.GroupContent>
+					<Sidebar.Menu>
+						{#each group.items as item (item.href)}
+							<Sidebar.MenuItem>
+								<Sidebar.MenuButton isActive={isActive(item.href)} tooltipContent={item.label}>
+									{#snippet child({ props })}
+										<a href={item.href} {...props}>
+											<item.icon strokeWidth={3} />
+											<span>{item.label}</span>
+										</a>
+									{/snippet}
+								</Sidebar.MenuButton>
+							</Sidebar.MenuItem>
+						{/each}
+					</Sidebar.Menu>
+				</Sidebar.GroupContent>
+			</Sidebar.Group>
+		{/each}
+	</Sidebar.Content>
+	{#if user}
+		<Sidebar.Footer class="group-data-[collapsible=icon]:hidden">
+			<Sidebar.Menu>
+				<Sidebar.MenuItem>
+					<DropdownMenu.Root>
+						<DropdownMenu.Trigger>
+							{#snippet child({ props })}
+								<Sidebar.MenuButton
+									{...props}
+									class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
+								>
+									{user.name}
+									<ChevronUp class="ms-auto" />
+								</Sidebar.MenuButton>
+							{/snippet}
+						</DropdownMenu.Trigger>
+						<DropdownMenu.Content class="w-(--bits-dropdown-menu-anchor-width)" side="top">
+							<DropdownMenu.Item onclick={() => goto(resolve('/auth/logout'))}>
+								<span>Sign out</span>
+							</DropdownMenu.Item>
+						</DropdownMenu.Content>
+					</DropdownMenu.Root>
+				</Sidebar.MenuItem>
+			</Sidebar.Menu>
+		</Sidebar.Footer>
+	{/if}
+</Sidebar.Root>

+ 3 - 0
src/lib/components/layout/side/index.ts

@@ -0,0 +1,3 @@
+import Sidebar from './bar.svelte';
+
+export { Sidebar };

+ 64 - 0
src/lib/components/layout/top/bar.svelte

@@ -0,0 +1,64 @@
+<script lang="ts">
+	import type { Snippet } from 'svelte';
+
+	import { resolve } from '$app/paths';
+
+	import logo from '$lib/assets/logo.svg';
+	import * as Breadcrumb from '$lib/components/controls/breadcrumb';
+	import { getBreadcrumbContext } from '$lib/stores/breadcrumb.svelte';
+
+	const breadcrumb = getBreadcrumbContext();
+
+	let { side }: { side: Snippet } = $props();
+	let userMenuOpen = $state(false);
+
+	const moduleHref: Record<string, string> = {
+		Agents: resolve('/workloads/agents'),
+		'Knowledge Bases': resolve('/workloads/kbs'),
+		Tools: resolve('/workloads/tools'),
+		Models: resolve('/workloads/models'),
+	};
+</script>
+
+<svelte:window
+	onclick={(e) => {
+		if (userMenuOpen && !(e.target as Element).closest('[data-user-menu]')) {
+			userMenuOpen = false;
+		}
+	}}
+/>
+
+<header class="flex h-12 w-full items-center border-b border-white/10 bg-white/5 backdrop-blur-md">
+	<!-- Left: toggle + logo -->
+	<div class="ml-1 shrink-0 opacity-50">
+		{@render side()}
+	</div>
+
+	<div class="ml-4 flex min-w-0 shrink-0 items-center gap-2 select-none">
+		<img class="h-6 w-6" alt="LocoStack Logo" src={logo} />
+		<span class="text-lg font-medium text-nowrap text-white/90">LocoStack</span>
+	</div>
+
+	<!-- Center: breadcrumb -->
+	<div class="ml-8 flex-1">
+		<Breadcrumb.Root>
+			<Breadcrumb.List>
+				<Breadcrumb.Item>
+					<Breadcrumb.Link href={moduleHref[breadcrumb.current.module]}>
+						{breadcrumb.current.module}
+					</Breadcrumb.Link>
+				</Breadcrumb.Item>
+				{#each breadcrumb.current.pages as { name, href }, i (href)}
+					<Breadcrumb.Separator />
+					<Breadcrumb.Item>
+						{#if i === breadcrumb.current.pages.length - 1}
+							<Breadcrumb.Page>{name}</Breadcrumb.Page>
+						{:else}
+							<Breadcrumb.Link {href}>{name}</Breadcrumb.Link>
+						{/if}
+					</Breadcrumb.Item>
+				{/each}
+			</Breadcrumb.List>
+		</Breadcrumb.Root>
+	</div>
+</header>

+ 3 - 0
src/lib/components/layout/top/index.ts

@@ -0,0 +1,3 @@
+import TopBar from './bar.svelte';
+
+export { TopBar };

+ 36 - 0
src/lib/stores/breadcrumb.svelte.ts

@@ -0,0 +1,36 @@
+import { getContext, setContext } from 'svelte';
+
+type Breadcrumb = {
+	module: string;
+	pages: {
+		name: string;
+		href: string;
+	}[];
+};
+
+const KEY = Symbol('breadcrumb');
+
+const createBreadcrumbState = () => {
+	let current = $state<Breadcrumb>({ module: '', pages: [] });
+	return {
+		get current() {
+			return current;
+		},
+		set: (value: Breadcrumb) => {
+			current = value;
+		},
+	};
+};
+
+type BreadcrumbState = ReturnType<typeof createBreadcrumbState>;
+
+const setBreadcrumbContext = (): BreadcrumbState => {
+	const state = createBreadcrumbState();
+	setContext(KEY, state);
+	return state;
+};
+
+const getBreadcrumbContext = (): BreadcrumbState => getContext<BreadcrumbState>(KEY);
+
+export type { Breadcrumb, BreadcrumbState };
+export { getBreadcrumbContext, setBreadcrumbContext };

+ 68 - 4
src/routes/+layout.svelte

@@ -1,10 +1,74 @@
 <script lang="ts">
 <script lang="ts">
-	import favicon from '$lib/assets/favicon.svg';
+	import type { Snippet } from 'svelte';
+
+	import { page } from '$app/state';
+
+	import logo from '$lib/assets/logo.svg';
+	import * as Sidebar from '$lib/components/controls/sidebar';
+	import { Sidebar as AppSidebar } from '$lib/components/layout/side';
+	import { TopBar } from '$lib/components/layout/top';
+	import { setBreadcrumbContext } from '$lib/stores/breadcrumb.svelte';
+
+	import type { LayoutData } from './$types';
 
 
 	import './layout.css';
 	import './layout.css';
 
 
-	let { children } = $props();
+	let { children, data }: { children: Snippet; data: LayoutData } = $props();
+	const isAuthPage = $derived(page.url.pathname.startsWith('/auth'));
+
+	setBreadcrumbContext();
 </script>
 </script>
 
 
-<svelte:head><link href={favicon} rel="icon" /></svelte:head>
-{@render children()}
+<svelte:head>
+	<link href={logo} rel="icon" />
+</svelte:head>
+
+<div class="bg-black">
+	<!-- aurora background: angled bands with sharp color stops so backdrop-blur on cards is visible -->
+	<div
+		style="background: linear-gradient(
+				120deg,
+				transparent 0%,
+				transparent 10%,
+				rgba(99,102,241,0.1) 20%,
+				rgba(99,102,241,0.1) 49.5%,
+				rgba(77,13,153,0.25) 50%,
+				rgba(77,13,153,0.25) 65%,
+				transparent 100%
+			)"
+		class="pointer-events-none fixed inset-0"
+	></div>
+	{#if isAuthPage || !data.user}
+		{@render children()}
+	{:else}
+		<Sidebar.Provider>
+			<AppSidebar
+				user={data.authEnabled ? data.user : undefined}
+				workspaceId={data.workspaceId}
+				workspaces={data.workspaces}
+			/>
+			<div class="relative flex flex-1 overflow-hidden">
+				<main class="relative flex-1">
+					<div class="absolute top-0 z-10 w-full">
+						<TopBar>
+							{#snippet side()}
+								<Sidebar.Trigger />
+							{/snippet}
+						</TopBar>
+					</div>
+					<div class="viewport h-screen overflow-auto pt-12">
+						{@render children()}
+					</div>
+				</main>
+			</div>
+		</Sidebar.Provider>
+	{/if}
+</div>
+
+<style>
+	.viewport {
+		scrollbar-gutter: stable;
+		scrollbar-width: thin;
+		scrollbar-color: rgba(0, 0, 0, 1) transparent;
+	}
+</style>

+ 15 - 2
src/routes/+page.svelte

@@ -1,2 +1,15 @@
-<h1>Welcome to SvelteKit</h1>
-<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
+<script lang="ts">
+	import logo from '$lib/assets/logo.svg';
+</script>
+
+<svelte:head>
+	<title>LocoStack</title>
+</svelte:head>
+
+<div class="mx-auto max-w-400">
+	<div class="flex flex-col items-center justify-center gap-4 py-12">
+		<img class="h-48 w-48" alt="LocoStack Logo" src={logo} />
+		<h1 class="text-4xl text-white">LocoStack</h1>
+		<p class="text-lg text-white/50">Your local platform for your AI workloads</p>
+	</div>
+</div>

+ 4 - 4
src/routes/layout.css

@@ -50,7 +50,7 @@
 	--card-foreground: oklch(0.985 0 0);
 	--card-foreground: oklch(0.985 0 0);
 	--popover: oklch(0.212 0.019 322.12);
 	--popover: oklch(0.212 0.019 322.12);
 	--popover-foreground: oklch(0.985 0 0);
 	--popover-foreground: oklch(0.985 0 0);
-	--primary: oklch(0.438 0.218 303.724);
+	--primary: oklch(0.586 0.256 295.614);
 	--primary-foreground: oklch(0.977 0.014 308.299);
 	--primary-foreground: oklch(0.977 0.014 308.299);
 	--secondary: oklch(0.274 0.006 286.033);
 	--secondary: oklch(0.274 0.006 286.033);
 	--secondary-foreground: oklch(0.985 0 0);
 	--secondary-foreground: oklch(0.985 0 0);
@@ -67,12 +67,12 @@
 	--chart-3: oklch(0.435 0.029 321.78);
 	--chart-3: oklch(0.435 0.029 321.78);
 	--chart-4: oklch(0.364 0.029 323.89);
 	--chart-4: oklch(0.364 0.029 323.89);
 	--chart-5: oklch(0.263 0.024 320.12);
 	--chart-5: oklch(0.263 0.024 320.12);
-	--sidebar: oklch(0.212 0.019 322.12);
+	--sidebar: oklch(1 0 0 / 5%);
 	--sidebar-foreground: oklch(0.985 0 0);
 	--sidebar-foreground: oklch(0.985 0 0);
 	--sidebar-primary: oklch(0.627 0.265 303.9);
 	--sidebar-primary: oklch(0.627 0.265 303.9);
 	--sidebar-primary-foreground: oklch(0.977 0.014 308.299);
 	--sidebar-primary-foreground: oklch(0.977 0.014 308.299);
-	--sidebar-accent: oklch(0.263 0.024 320.12);
-	--sidebar-accent-foreground: oklch(0.985 0 0);
+	--sidebar-accent: oklch(1 0 0 / 8%);
+	--sidebar-accent-foreground: oklch(1 0 0);
 	--sidebar-border: oklch(1 0 0 / 10%);
 	--sidebar-border: oklch(1 0 0 / 10%);
 	--sidebar-ring: oklch(0.542 0.034 322.5);
 	--sidebar-ring: oklch(0.542 0.034 322.5);
 }
 }