Files
synapbus/web/src/lib/components/Header.svelte
T
Algis DumbrisandClaude Opus 4.6 78735bb8ee feat: implement Web UI with Svelte 5 SPA and REST API
Add complete Web UI infrastructure:

Go backend:
- REST API handlers for messages, agents, channels (internal/api/)
- SSE hub for real-time event streaming
- Session-to-owner middleware bridging auth sessions to API context
- SPA file server with go:embed for static assets
- GetMessageByID on MessagingService, RevokeKey on AgentService
- Updated router with RouterConfig for full service wiring

Svelte 5 SPA (web/):
- SvelteKit with static adapter for SPA mode
- Tailwind CSS with dark mode (class-based, localStorage persisted)
- API client with auto-redirect on 401
- SSE client with exponential backoff reconnect
- Pages: Login, Dashboard, Conversations, Channels, Agents, Settings
- Components: Sidebar, Header, MessageList, ComposeForm, AgentCard, TraceViewer
- Responsive layout with mobile sidebar toggle

Build:
- Placeholder index.html in internal/web/dist/ for go:embed compilation
- Updated Makefile web target to copy build output
- All existing Go tests pass, CGO_ENABLED=0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:34:36 +02:00

94 lines
3.6 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation';
import { user, logout } from '$lib/stores/auth';
import { theme, toggleTheme } from '$lib/stores/theme';
let { onMenuClick }: { onMenuClick: () => void } = $props();
let searchQuery = $state('');
let showUserMenu = $state(false);
function handleSearch(e: SubmitEvent) {
e.preventDefault();
if (searchQuery.trim()) {
goto(`/conversations?q=${encodeURIComponent(searchQuery.trim())}`);
searchQuery = '';
}
}
async function handleLogout() {
await logout();
goto('/login');
}
</script>
<header class="sticky top-0 z-20 flex items-center gap-4 h-16 px-4 lg:px-6 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<!-- Mobile menu button -->
<button class="lg:hidden p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" onclick={onMenuClick}>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<!-- Search -->
<form class="flex-1 max-w-lg" onsubmit={handleSearch}>
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="search"
placeholder="Search messages..."
class="w-full pl-10 pr-4 py-2 text-sm bg-gray-100 dark:bg-gray-700 border-none rounded-lg focus:ring-2 focus:ring-primary-500 outline-none"
bind:value={searchQuery}
/>
</div>
</form>
<div class="flex items-center gap-2">
<!-- Theme toggle -->
<button
class="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-lg"
onclick={toggleTheme}
title="Toggle dark mode"
>
{#if $theme === 'dark'}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
{:else}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
{/if}
</button>
<!-- User menu -->
<div class="relative">
<button
class="flex items-center gap-2 p-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
onclick={() => (showUserMenu = !showUserMenu)}
>
<div class="w-8 h-8 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center text-primary-700 dark:text-primary-300 font-medium">
{$user?.username?.charAt(0).toUpperCase() ?? '?'}
</div>
<span class="hidden sm:inline">{$user?.display_name ?? $user?.username ?? ''}</span>
</button>
{#if showUserMenu}
<div class="absolute right-0 mt-2 w-48 card py-1 shadow-lg">
<a href="/settings" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700" onclick={() => (showUserMenu = false)}>
Settings
</a>
<button
class="block w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-700"
onclick={handleLogout}
>
Logout
</button>
</div>
{/if}
</div>
</div>
</header>