feat: UI WIP

This commit is contained in:
Aleksander Grygier 2026-04-02 11:03:01 +02:00
parent ad9e97b32d
commit 5468fd03e3
12 changed files with 268 additions and 267 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { Trash2, Pencil } from '@lucide/svelte'; import { Trash2, Pencil, X } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { ChatSidebarConversationItem, DialogConfirmation } from '$lib/components/app'; import { ChatSidebarConversationItem, DialogConfirmation } from '$lib/components/app';
import { Checkbox } from '$lib/components/ui/checkbox'; import { Checkbox } from '$lib/components/ui/checkbox';
import Label from '$lib/components/ui/label/label.svelte'; import Label from '$lib/components/ui/label/label.svelte';
@ -140,6 +141,7 @@
searchQuery = ''; searchQuery = '';
} }
handleMobileSidebarItemClick();
await goto(`#/chat/${id}`); await goto(`#/chat/${id}`);
} }
@ -149,13 +151,25 @@
</script> </script>
<div class="flex h-full flex-col"> <div class="flex h-full flex-col">
<ScrollArea class="flex-1"> <ScrollArea class="h-full flex-1">
<Sidebar.Header <Sidebar.Header
class=" top-0 z-10 gap-4 bg-sidebar/50 p-3 pt-4 pb-2 backdrop-blur-lg md:sticky" class=" sticky top-0 z-10 gap-4 bg-sidebar/50 p-3 backdrop-blur-lg md:pt-4 md:pb-2"
> >
<a href="#/" onclick={handleMobileSidebarItemClick}> <div class="flex items-center justify-between">
<h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">llama.cpp</h1> <a href="#/" onclick={handleMobileSidebarItemClick}>
</a> <h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">llama.cpp</h1>
</a>
<Button
class="rounded-full md:hidden"
variant="ghost"
size="icon"
onclick={() => sidebar.toggle()}
>
<X class="h-4 w-4" />
<span class="sr-only">Close sidebar</span>
</Button>
</div>
<ChatSidebarActions <ChatSidebarActions
bind:this={chatSidebarActions} bind:this={chatSidebarActions}
@ -209,11 +223,11 @@
</Sidebar.Menu> </Sidebar.Menu>
</Sidebar.GroupContent> </Sidebar.GroupContent>
</Sidebar.Group> </Sidebar.Group>
</ScrollArea>
<Sidebar.Footer> <Sidebar.Footer class="sticky bottom-0 z-10 mt-2 bg-sidebar/50 p-3 backdrop-blur-lg">
<ChatSidebarFooter /> <ChatSidebarFooter />
</Sidebar.Footer> </Sidebar.Footer>
</ScrollArea>
</div> </div>
<DialogConfirmation <DialogConfirmation

View File

@ -71,7 +71,7 @@
</div> </div>
{#if capabilities || transportType} {#if capabilities || transportType}
<div class="flex flex-wrap items-center gap-1"> <div class="flex flex-wrap items-center gap-1.5">
{#if transportType} {#if transportType}
{@const TransportIcon = MCP_TRANSPORT_ICONS[transportType]} {@const TransportIcon = MCP_TRANSPORT_ICONS[transportType]}
<Badge variant="outline" class="h-5 gap-1 px-1.5 text-[10px]"> <Badge variant="outline" class="h-5 gap-1 px-1.5 text-[10px]">

View File

@ -282,7 +282,7 @@
: 'text-muted-foreground', : 'text-muted-foreground',
isOpen ? 'text-foreground' : '' isOpen ? 'text-foreground' : ''
)} )}
style="max-width: min(calc(100cqw - 9rem), 24rem)" style="max-width: min(calc(100cqw - 9rem), 25rem)"
disabled={disabled || updating} disabled={disabled || updating}
> >
<Package class="h-3.5 w-3.5" /> <Package class="h-3.5 w-3.5" />

View File

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { Component } from 'svelte';
import { Download, Upload, Trash2, Database } from '@lucide/svelte'; import { Download, Upload, Trash2, Database } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { DialogConversationSelection, DialogConfirmation } from '$lib/components/app'; import { DialogConversationSelection, DialogConfirmation } from '$lib/components/app';
@ -8,6 +9,14 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
type SectionOpts = {
wrapperClass?: string;
titleClass?: string;
buttonVariant?: 'outline' | 'destructive';
buttonClass?: string;
summary?: { show: boolean; verb: string; items: DatabaseConversation[] };
};
let exportedConversations = $state<DatabaseConversation[]>([]); let exportedConversations = $state<DatabaseConversation[]>([]);
let importedConversations = $state<DatabaseConversation[]>([]); let importedConversations = $state<DatabaseConversation[]>([]);
let showExportSummary = $state(false); let showExportSummary = $state(false);
@ -175,6 +184,54 @@
} }
</script> </script>
{#snippet summaryList(show: boolean, verb: string, items: DatabaseConversation[])}
{#if show && items.length > 0}
<div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
<h5 class="mb-2 text-sm font-medium">
{verb}
{items.length} conversation{items.length === 1 ? '' : 's'}
</h5>
<ul class="space-y-1 text-sm text-muted-foreground">
{#each items.slice(0, 10) as conv (conv.id)}
<li class="truncate">{conv.name || 'Untitled conversation'}</li>
{/each}
{#if items.length > 10}
<li class="italic">... and {items.length - 10} more</li>
{/if}
</ul>
</div>
{/if}
{/snippet}
{#snippet section(
title: string,
description: string,
Icon: Component,
buttonText: string,
onclick: () => void,
opts: SectionOpts
)}
{@const buttonClass = opts?.buttonClass ?? 'justify-start justify-self-start md:w-auto'}
{@const buttonVariant = opts?.buttonVariant ?? 'outline'}
<div class="grid gap-1 {opts?.wrapperClass ?? ''}">
<h4 class="mt-0 mb-2 text-sm font-medium {opts?.titleClass ?? ''}">{title}</h4>
<p class="mb-4 text-sm text-muted-foreground">{description}</p>
<Button class={buttonClass} {onclick} variant={buttonVariant}>
<Icon class="mr-2 h-4 w-4" />
{buttonText}
</Button>
{#if opts?.summary}
{@render summaryList(opts.summary.show, opts.summary.verb, opts.summary.items)}
{/if}
</div>
{/snippet}
<div class="space-y-6" in:fade={{ duration: 150 }}> <div class="space-y-6" in:fade={{ duration: 150 }}>
<div class="flex items-center gap-2 pb-4"> <div class="flex items-center gap-2 pb-4">
<Database class="h-5 w-5 md:h-6 md:w-6" /> <Database class="h-5 w-5 md:h-6 md:w-6" />
@ -182,106 +239,42 @@
<h1 class="text-xl font-semibold md:text-2xl">Import / Export</h1> <h1 class="text-xl font-semibold md:text-2xl">Import / Export</h1>
</div> </div>
<div class="space-y-4"> <div class="space-y-6">
<div class="grid"> {@render section(
<h4 class="mt-0 mb-2 text-sm font-medium">Export Conversations</h4> 'Export Conversations',
'Download all your conversations as a JSON file. This includes all messages, attachments, and conversation history.',
Download,
'Export conversations',
handleExportClick,
{ summary: { show: showExportSummary, verb: 'Exported', items: exportedConversations } }
)}
<p class="mb-4 text-sm text-muted-foreground"> {@render section(
Download all your conversations as a JSON file. This includes all messages, attachments, and 'Import Conversations',
conversation history. 'Import one or more conversations from a previously exported JSON file. This will merge with your existing conversations.',
</p> Upload,
'Import conversations',
handleImportClick,
{
wrapperClass: 'border-t border-border/30 pt-6',
summary: { show: showImportSummary, verb: 'Imported', items: importedConversations }
}
)}
<Button {@render section(
class="w-full justify-start justify-self-start md:w-auto" 'Delete All Conversations',
onclick={handleExportClick} 'Permanently delete all conversations and their messages. This action cannot be undone. Consider exporting your conversations first if you want to keep a backup.',
variant="outline" Trash2,
> 'Delete all conversations',
<Download class="mr-2 h-4 w-4" /> handleDeleteAllClick,
{
Export conversations wrapperClass: 'border-t border-border/30 pt-4',
</Button> titleClass: 'text-destructive',
buttonVariant: 'destructive',
{#if showExportSummary && exportedConversations.length > 0} buttonClass:
<div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4"> 'text-destructive-foreground justify-start justify-self-start bg-destructive hover:bg-destructive/80 md:w-auto'
<h5 class="mb-2 text-sm font-medium"> }
Exported {exportedConversations.length} conversation{exportedConversations.length === 1 )}
? ''
: 's'}
</h5>
<ul class="space-y-1 text-sm text-muted-foreground">
{#each exportedConversations.slice(0, 10) as conv (conv.id)}
<li class="truncate">{conv.name || 'Untitled conversation'}</li>
{/each}
{#if exportedConversations.length > 10}
<li class="italic">
... and {exportedConversations.length - 10} more
</li>
{/if}
</ul>
</div>
{/if}
</div>
<div class="grid border-t border-border/30 pt-4">
<h4 class="mt-0 mb-2 text-sm font-medium">Import Conversations</h4>
<p class="mb-4 text-sm text-muted-foreground">
Import one or more conversations from a previously exported JSON file. This will merge with
your existing conversations.
</p>
<Button
class="w-full justify-start justify-self-start md:w-auto"
onclick={handleImportClick}
variant="outline"
>
<Upload class="mr-2 h-4 w-4" />
Import conversations
</Button>
{#if showImportSummary && importedConversations.length > 0}
<div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
<h5 class="mb-2 text-sm font-medium">
Imported {importedConversations.length} conversation{importedConversations.length === 1
? ''
: 's'}
</h5>
<ul class="space-y-1 text-sm text-muted-foreground">
{#each importedConversations.slice(0, 10) as conv (conv.id)}
<li class="truncate">{conv.name || 'Untitled conversation'}</li>
{/each}
{#if importedConversations.length > 10}
<li class="italic">
... and {importedConversations.length - 10} more
</li>
{/if}
</ul>
</div>
{/if}
</div>
<div class="grid border-t border-border/30 pt-4">
<h4 class="mt-0 mb-2 text-sm font-medium text-destructive">Delete All Conversations</h4>
<p class="mb-4 text-sm text-muted-foreground">
Permanently delete all conversations and their messages. This action cannot be undone.
Consider exporting your conversations first if you want to keep a backup.
</p>
<Button
class="text-destructive-foreground w-full justify-start justify-self-start bg-destructive hover:bg-destructive/80 md:w-auto"
onclick={handleDeleteAllClick}
variant="destructive"
>
<Trash2 class="mr-2 h-4 w-4" />
Delete all conversations
</Button>
</div>
</div> </div>
</div> </div>

View File

@ -85,7 +85,7 @@
} }
</script> </script>
<div in:fade={{ duration: 150 }}> <div in:fade={{ duration: 150 }} class="max-h-full overflow-auto">
<div class="flex items-center gap-2 p-4 md:absolute md:top-8 md:left-8 md:p-0"> <div class="flex items-center gap-2 p-4 md:absolute md:top-8 md:left-8 md:p-0">
<McpLogo class="h-5 w-5 md:h-6 md:w-6" /> <McpLogo class="h-5 w-5 md:h-6 md:w-6" />
@ -141,7 +141,10 @@
{/if} {/if}
{#if servers.length > 0} {#if servers.length > 0}
<div class="grid gap-3" style="grid-template-columns: repeat(auto-fill, minmax(28rem, 1fr));"> <div
class="grid gap-3"
style="grid-template-columns: repeat(auto-fill, minmax(min(32rem, calc(100dvw - 2rem)), 1fr));"
>
{#each servers as server (server.id)} {#each servers as server (server.id)}
{#if !initialLoadComplete} {#if !initialLoadComplete}
<McpServerCardSkeleton /> <McpServerCardSkeleton />

View File

@ -53,7 +53,7 @@ class SidebarState {
}; };
toggle = () => { toggle = () => {
return this.#isMobile.current ? (this.openMobile = !this.openMobile) : this.setOpen(!this.open); this.setOpen(!this.open);
}; };
} }

View File

@ -1,8 +1,6 @@
<script lang="ts"> <script lang="ts">
import * as Sheet from '$lib/components/ui/sheet/index.js';
import { cn, type WithElementRef } from '$lib/components/ui/utils.js'; import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
import { SIDEBAR_WIDTH_MOBILE } from './constants.js';
import { useSidebar } from './context.svelte.js'; import { useSidebar } from './context.svelte.js';
let { let {
@ -33,29 +31,10 @@
> >
{@render children?.()} {@render children?.()}
</div> </div>
{:else if sidebar.isMobile}
<Sheet.Root bind:open={() => sidebar.openMobile, (v) => sidebar.setOpenMobile(v)} {...restProps}>
<Sheet.Content
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
class="z-99999 w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground sm:z-99 [&>button]:hidden"
style="--sidebar-width: {SIDEBAR_WIDTH_MOBILE};"
{side}
>
<Sheet.Header class="sr-only">
<Sheet.Title>Sidebar</Sheet.Title>
<Sheet.Description>Displays the mobile sidebar.</Sheet.Description>
</Sheet.Header>
<div class="flex h-full w-full flex-col">
{@render children?.()}
</div>
</Sheet.Content>
</Sheet.Root>
{:else} {:else}
<div <div
bind:this={ref} bind:this={ref}
class="group peer hidden text-sidebar-foreground md:block" class="group peer block text-sidebar-foreground"
data-state={sidebar.state} data-state={sidebar.state}
data-collapsible={sidebar.state === 'collapsed' ? collapsible : ''} data-collapsible={sidebar.state === 'collapsed' ? collapsible : ''}
data-variant={variant} data-variant={variant}
@ -67,8 +46,11 @@
data-slot="sidebar-gap" data-slot="sidebar-gap"
class={cn( class={cn(
'relative bg-transparent transition-[width] duration-200 ease-linear', 'relative bg-transparent transition-[width] duration-200 ease-linear',
variant === 'floating' ? 'w-[calc(var(--sidebar-width)+0.75rem)]' : 'w-(--sidebar-width)', 'w-0',
'group-data-[collapsible=offcanvas]:w-0', variant === 'floating'
? 'md:w-[calc(var(--sidebar-width)+0.75rem)]'
: 'md:w-(--sidebar-width)',
'md:group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180', 'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset' variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]' ? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
@ -79,7 +61,7 @@
<div <div
data-slot="sidebar-container" data-slot="sidebar-container"
class={cn( class={cn(
'fixed inset-y-0 z-999 hidden w-(--sidebar-width) duration-200 ease-linear md:z-0 md:flex', 'fixed inset-y-0 z-[900] flex w-[calc(100dvw-1.5rem)] duration-200 ease-linear md:z-0 md:w-(--sidebar-width)',
variant === 'floating' variant === 'floating'
? [ ? [
'transition-[left,right,width,opacity]', 'transition-[left,right,width,opacity]',

View File

@ -248,8 +248,8 @@
{#if !(alwaysShowSidebarOnDesktop && isDesktop) && !(isSettingsRoute && !isDesktop)} {#if !(alwaysShowSidebarOnDesktop && isDesktop) && !(isSettingsRoute && !isDesktop)}
<Sidebar.Trigger <Sidebar.Trigger
class="transition-left absolute left-0 z-[900] duration-200 ease-linear {sidebarOpen class="transition-left absolute left-0 z-[900] duration-200 ease-linear {sidebarOpen
? 'md:left-[calc(var(--sidebar-width)+0.75rem)]' ? 'left-[calc(var(--sidebar-width)+0.75rem)] max-md:hidden'
: 'md:left-0!'}" : 'left-0!'}"
style="translate: 1rem 1rem;" style="translate: 1rem 1rem;"
/> />
{/if} {/if}

View File

@ -2,12 +2,23 @@
import { X } from '@lucide/svelte'; import { X } from '@lucide/svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { page } from '$app/state';
import { ActionIcon } from '$lib/components/app'; import { ActionIcon } from '$lib/components/app';
let { children } = $props(); let { children } = $props();
let previousRouteId = $state<string | null>(null);
$effect(() => {
const currentId = page.route.id;
return () => {
previousRouteId = currentId;
};
});
function handleClose() { function handleClose() {
if (browser && window.history.length > 1) { const prevIsSettings = previousRouteId?.startsWith('/settings');
if (browser && window.history.length > 1 && !prevIsSettings) {
history.back(); history.back();
} else { } else {
goto('#/'); goto('#/');