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">
import { goto } from '$app/navigation';
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 { Checkbox } from '$lib/components/ui/checkbox';
import Label from '$lib/components/ui/label/label.svelte';
@ -140,6 +141,7 @@
searchQuery = '';
}
handleMobileSidebarItemClick();
await goto(`#/chat/${id}`);
}
@ -149,13 +151,25 @@
</script>
<div class="flex h-full flex-col">
<ScrollArea class="flex-1">
<ScrollArea class="h-full flex-1">
<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}>
<h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">llama.cpp</h1>
</a>
<div class="flex items-center justify-between">
<a href="#/" onclick={handleMobileSidebarItemClick}>
<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
bind:this={chatSidebarActions}
@ -209,11 +223,11 @@
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
</ScrollArea>
<Sidebar.Footer>
<ChatSidebarFooter />
</Sidebar.Footer>
<Sidebar.Footer class="sticky bottom-0 z-10 mt-2 bg-sidebar/50 p-3 backdrop-blur-lg">
<ChatSidebarFooter />
</Sidebar.Footer>
</ScrollArea>
</div>
<DialogConfirmation

View File

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

View File

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

View File

@ -1,4 +1,5 @@
<script lang="ts">
import type { Component } from 'svelte';
import { Download, Upload, Trash2, Database } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { DialogConversationSelection, DialogConfirmation } from '$lib/components/app';
@ -8,6 +9,14 @@
import { toast } from 'svelte-sonner';
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 importedConversations = $state<DatabaseConversation[]>([]);
let showExportSummary = $state(false);
@ -175,6 +184,54 @@
}
</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="flex items-center gap-2 pb-4">
<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>
</div>
<div class="space-y-4">
<div class="grid">
<h4 class="mt-0 mb-2 text-sm font-medium">Export Conversations</h4>
<div class="space-y-6">
{@render section(
'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">
Download all your conversations as a JSON file. This includes all messages, attachments, and
conversation history.
</p>
{@render section(
'Import Conversations',
'Import one or more conversations from a previously exported JSON file. This will merge with your existing conversations.',
Upload,
'Import conversations',
handleImportClick,
{
wrapperClass: 'border-t border-border/30 pt-6',
summary: { show: showImportSummary, verb: 'Imported', items: importedConversations }
}
)}
<Button
class="w-full justify-start justify-self-start md:w-auto"
onclick={handleExportClick}
variant="outline"
>
<Download class="mr-2 h-4 w-4" />
Export conversations
</Button>
{#if showExportSummary && exportedConversations.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">
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>
{@render section(
'Delete All Conversations',
'Permanently delete all conversations and their messages. This action cannot be undone. Consider exporting your conversations first if you want to keep a backup.',
Trash2,
'Delete all conversations',
handleDeleteAllClick,
{
wrapperClass: 'border-t border-border/30 pt-4',
titleClass: 'text-destructive',
buttonVariant: 'destructive',
buttonClass:
'text-destructive-foreground justify-start justify-self-start bg-destructive hover:bg-destructive/80 md:w-auto'
}
)}
</div>
</div>

View File

@ -85,7 +85,7 @@
}
</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">
<McpLogo class="h-5 w-5 md:h-6 md:w-6" />
@ -141,7 +141,10 @@
{/if}
{#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)}
{#if !initialLoadComplete}
<McpServerCardSkeleton />

View File

@ -53,7 +53,7 @@ class SidebarState {
};
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">
import * as Sheet from '$lib/components/ui/sheet/index.js';
import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
import { SIDEBAR_WIDTH_MOBILE } from './constants.js';
import { useSidebar } from './context.svelte.js';
let {
@ -33,29 +31,10 @@
>
{@render children?.()}
</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}
<div
bind:this={ref}
class="group peer hidden text-sidebar-foreground md:block"
class="group peer block text-sidebar-foreground"
data-state={sidebar.state}
data-collapsible={sidebar.state === 'collapsed' ? collapsible : ''}
data-variant={variant}
@ -67,8 +46,11 @@
data-slot="sidebar-gap"
class={cn(
'relative bg-transparent transition-[width] duration-200 ease-linear',
variant === 'floating' ? 'w-[calc(var(--sidebar-width)+0.75rem)]' : 'w-(--sidebar-width)',
'group-data-[collapsible=offcanvas]:w-0',
'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',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
@ -79,7 +61,7 @@
<div
data-slot="sidebar-container"
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'
? [
'transition-[left,right,width,opacity]',

View File

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

View File

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