chat ui sidebar (#4)

This commit is contained in:
Aidan Bleser 2025-06-13 15:51:48 -05:00 committed by GitHub
parent 054307caad
commit 4304e435c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 380 additions and 33 deletions

View file

@ -47,6 +47,7 @@
"runed": "^0.28.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-toolbelt": "^0.9.1",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^1.0.0",
"tailwindcss": "^4.0.0",

3
pnpm-lock.yaml generated
View file

@ -102,6 +102,9 @@ importers:
svelte-check:
specifier: ^4.0.0
version: 4.2.1(picomatch@4.0.2)(svelte@5.34.1)(typescript@5.8.3)
svelte-toolbelt:
specifier: ^0.9.1
version: 0.9.1(svelte@5.34.1)
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1

View file

@ -0,0 +1,30 @@
import { redirect } from '@sveltejs/kit';
const PARAM_NAME = 'redirect_to';
/** Redirect back to login and add provided url to the redirect_to parameter
*
* @param url
*/
export function redirectToLogin(url: URL): never {
const path = url.pathname;
const location = `/login?${PARAM_NAME}=${path}`;
redirect(303, location);
}
/** Redirect back to an authorized route either using the redirect_to parameter or the fallback if not provided
*
* @param url
* @param fallback
*/
export function redirectToAuthorized(url: URL, fallback = '/account'): never {
const to = getRedirectTo(url) ?? fallback;
redirect(303, to);
}
export function getRedirectTo(url: URL): string | null {
return url.searchParams.get(PARAM_NAME);
}

View file

@ -0,0 +1,18 @@
<script lang="ts">
import type { Props } from '.';
import { cn } from '$lib/utils/utils';
let { class: className, ...rest }: Props = $props();
</script>
<svg
class={cn('size-4 fill-current', className)}
viewBox="0 0 256 250"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid"
{...rest}
>
<path
d="M128.001 0C57.317 0 0 57.307 0 128.001c0 56.554 36.676 104.535 87.535 121.46 6.397 1.185 8.746-2.777 8.746-6.158 0-3.052-.12-13.135-.174-23.83-35.61 7.742-43.124-15.103-43.124-15.103-5.823-14.795-14.213-18.73-14.213-18.73-11.613-7.944.876-7.78.876-7.78 12.853.902 19.621 13.19 19.621 13.19 11.417 19.568 29.945 13.911 37.249 10.64 1.149-8.272 4.466-13.92 8.127-17.116-28.431-3.236-58.318-14.212-58.318-63.258 0-13.975 5-25.394 13.188-34.358-1.329-3.224-5.71-16.242 1.24-33.874 0 0 10.749-3.44 35.21 13.121 10.21-2.836 21.16-4.258 32.038-4.307 10.878.049 21.837 1.47 32.066 4.307 24.431-16.56 35.165-13.12 35.165-13.12 6.967 17.63 2.584 30.65 1.255 33.873 8.207 8.964 13.173 20.383 13.173 34.358 0 49.163-29.944 59.988-58.447 63.157 4.591 3.972 8.682 11.762 8.682 23.704 0 17.126-.148 30.91-.148 35.126 0 3.407 2.304 7.398 8.792 6.14C219.37 232.5 256 184.537 256 128.002 256 57.307 198.691 0 128.001 0Zm-80.06 182.34c-.282.636-1.283.827-2.194.39-.929-.417-1.45-1.284-1.15-1.922.276-.655 1.279-.838 2.205-.399.93.418 1.46 1.293 1.139 1.931Zm6.296 5.618c-.61.566-1.804.303-2.614-.591-.837-.892-.994-2.086-.375-2.66.63-.566 1.787-.301 2.626.591.838.903 1 2.088.363 2.66Zm4.32 7.188c-.785.545-2.067.034-2.86-1.104-.784-1.138-.784-2.503.017-3.05.795-.547 2.058-.055 2.861 1.075.782 1.157.782 2.522-.019 3.08Zm7.304 8.325c-.701.774-2.196.566-3.29-.49-1.119-1.032-1.43-2.496-.726-3.27.71-.776 2.213-.558 3.315.49 1.11 1.03 1.45 2.505.701 3.27Zm9.442 2.81c-.31 1.003-1.75 1.459-3.199 1.033-1.448-.439-2.395-1.613-2.103-2.626.301-1.01 1.747-1.484 3.207-1.028 1.446.436 2.396 1.602 2.095 2.622Zm10.744 1.193c.036 1.055-1.193 1.93-2.715 1.95-1.53.034-2.769-.82-2.786-1.86 0-1.065 1.202-1.932 2.733-1.958 1.522-.03 2.768.818 2.768 1.868Zm10.555-.405c.182 1.03-.875 2.088-2.387 2.37-1.485.271-2.861-.365-3.05-1.386-.184-1.056.893-2.114 2.376-2.387 1.514-.263 2.868.356 3.061 1.403Z"
/>
</svg>

View file

@ -0,0 +1,12 @@
import type { HTMLAttributes } from 'svelte/elements';
import GitHub from './github.svelte';
import TypeScript from './typescript.svelte';
import Svelte from './svelte.svelte';
export interface Props extends HTMLAttributes<SVGElement> {
class?: string;
width?: number;
height?: number;
}
export { GitHub, TypeScript, Svelte };

View file

@ -0,0 +1,23 @@
<script lang="ts">
import type { Props } from '.';
import { cn } from '$lib/utils/utils';
let { class: className, ...rest }: Props = $props();
</script>
<svg
class={cn('size-4', className)}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 107 128"
{...rest}
>
<title>svelte-logo</title>
<path
d="M94.1566,22.8189c-10.4-14.8851-30.94-19.2971-45.7914-9.8348L22.2825,29.6078A29.9234,29.9234,0,0,0,8.7639,49.6506a31.5136,31.5136,0,0,0,3.1076,20.2318A30.0061,30.0061,0,0,0,7.3953,81.0653a31.8886,31.8886,0,0,0,5.4473,24.1157c10.4022,14.8865,30.9423,19.2966,45.7914,9.8348L84.7167,98.3921A29.9177,29.9177,0,0,0,98.2353,78.3493,31.5263,31.5263,0,0,0,95.13,58.117a30,30,0,0,0,4.4743-11.1824,31.88,31.88,0,0,0-5.4473-24.1157"
style="fill:#ff3e00"
/>
<path
d="M45.8171,106.5815A20.7182,20.7182,0,0,1,23.58,98.3389a19.1739,19.1739,0,0,1-3.2766-14.5025,18.1886,18.1886,0,0,1,.6233-2.4357l.4912-1.4978,1.3363.9815a33.6443,33.6443,0,0,0,10.203,5.0978l.9694.2941-.0893.9675a5.8474,5.8474,0,0,0,1.052,3.8781,6.2389,6.2389,0,0,0,6.6952,2.485,5.7449,5.7449,0,0,0,1.6021-.7041L69.27,76.281a5.4306,5.4306,0,0,0,2.4506-3.631,5.7948,5.7948,0,0,0-.9875-4.3712,6.2436,6.2436,0,0,0-6.6978-2.4864,5.7427,5.7427,0,0,0-1.6.7036l-9.9532,6.3449a19.0329,19.0329,0,0,1-5.2965,2.3259,20.7181,20.7181,0,0,1-22.2368-8.2427,19.1725,19.1725,0,0,1-3.2766-14.5024,17.9885,17.9885,0,0,1,8.13-12.0513L55.8833,23.7472a19.0038,19.0038,0,0,1,5.3-2.3287A20.7182,20.7182,0,0,1,83.42,29.6611a19.1739,19.1739,0,0,1,3.2766,14.5025,18.4,18.4,0,0,1-.6233,2.4357l-.4912,1.4978-1.3356-.98a33.6175,33.6175,0,0,0-10.2037-5.1l-.9694-.2942.0893-.9675a5.8588,5.8588,0,0,0-1.052-3.878,6.2389,6.2389,0,0,0-6.6952-2.485,5.7449,5.7449,0,0,0-1.6021.7041L37.73,51.719a5.4218,5.4218,0,0,0-2.4487,3.63,5.7862,5.7862,0,0,0,.9856,4.3717,6.2437,6.2437,0,0,0,6.6978,2.4864,5.7652,5.7652,0,0,0,1.602-.7041l9.9519-6.3425a18.978,18.978,0,0,1,5.2959-2.3278,20.7181,20.7181,0,0,1,22.2368,8.2427,19.1725,19.1725,0,0,1,3.2766,14.5024,17.9977,17.9977,0,0,1-8.13,12.0532L51.1167,104.2528a19.0038,19.0038,0,0,1-5.3,2.3287"
style="fill:#fff"
/>
</svg>

View file

@ -0,0 +1,25 @@
<script lang="ts">
import type { Props } from '.';
import { cn } from '$lib/utils/utils';
let { class: className, ...rest }: Props = $props();
</script>
<svg
class={cn('size-4', className)}
fill="none"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
{...rest}
><rect fill="#3178c6" height="512" rx="50" width="512" /><rect
fill="#3178c6"
height="512"
rx="50"
width="512"
/><path
clip-rule="evenodd"
d="m316.939 407.424v50.061c8.138 4.172 17.763 7.3 28.875 9.386s22.823 3.129 35.135 3.129c11.999 0 23.397-1.147 34.196-3.442 10.799-2.294 20.268-6.075 28.406-11.342 8.138-5.266 14.581-12.15 19.328-20.65s7.121-19.007 7.121-31.522c0-9.074-1.356-17.026-4.069-23.857s-6.625-12.906-11.738-18.225c-5.112-5.319-11.242-10.091-18.389-14.315s-15.207-8.213-24.18-11.967c-6.573-2.712-12.468-5.345-17.685-7.9-5.217-2.556-9.651-5.163-13.303-7.822-3.652-2.66-6.469-5.476-8.451-8.448-1.982-2.973-2.974-6.336-2.974-10.091 0-3.441.887-6.544 2.661-9.308s4.278-5.136 7.512-7.118c3.235-1.981 7.199-3.52 11.894-4.615 4.696-1.095 9.912-1.642 15.651-1.642 4.173 0 8.581.313 13.224.938 4.643.626 9.312 1.591 14.008 2.894 4.695 1.304 9.259 2.947 13.694 4.928 4.434 1.982 8.529 4.276 12.285 6.884v-46.776c-7.616-2.92-15.937-5.084-24.962-6.492s-19.381-2.112-31.066-2.112c-11.895 0-23.163 1.278-33.805 3.833s-20.006 6.544-28.093 11.967c-8.086 5.424-14.476 12.333-19.171 20.729-4.695 8.395-7.043 18.433-7.043 30.114 0 14.914 4.304 27.638 12.912 38.172 8.607 10.533 21.675 19.45 39.204 26.751 6.886 2.816 13.303 5.579 19.25 8.291s11.086 5.528 15.415 8.448c4.33 2.92 7.747 6.101 10.252 9.543 2.504 3.441 3.756 7.352 3.756 11.733 0 3.233-.783 6.231-2.348 8.995s-3.939 5.162-7.121 7.196-7.147 3.624-11.894 4.771c-4.748 1.148-10.303 1.721-16.668 1.721-10.851 0-21.597-1.903-32.24-5.71-10.642-3.806-20.502-9.516-29.579-17.13zm-84.159-123.342h64.22v-41.082h-179v41.082h63.906v182.918h50.874z"
fill="#fff"
fill-rule="evenodd"
/></svg
>

View file

@ -15,22 +15,22 @@
destructive:
'bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 text-white shadow-2xs',
outline:
'bg-background hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 border shadow-2xs',
'bg-background hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input dark:hover:bg-input border shadow-2xs',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-2xs',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline'
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9'
}
icon: 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default'
}
size: 'default',
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];

View file

@ -0,0 +1,6 @@
import Root from './sidebar.svelte';
import Sidebar from './sidebar-sidebar.svelte';
import Inset from './sidebar-inset.svelte';
import Trigger from './sidebar-trigger.svelte';
export { Root, Sidebar, Inset, Trigger };

View file

@ -0,0 +1,10 @@
<script lang="ts">
import { cn } from '$lib/utils/utils';
import type { HTMLAttributes } from 'svelte/elements';
let { class: className, children, ...rest }: HTMLAttributes<HTMLDivElement> = $props();
</script>
<div {...rest} class={cn('bg-background col-start-2', className)}>
{@render children?.()}
</div>

View file

@ -0,0 +1,16 @@
<script lang="ts">
import { cn } from '$lib/utils/utils';
import { box } from 'svelte-toolbelt';
import type { HTMLAttributes } from 'svelte/elements';
import { useSidebarSidebar } from './sidebar.svelte.js';
let { class: className, children, ...rest }: HTMLAttributes<HTMLDivElement> = $props();
const sidebar = useSidebarSidebar();
</script>
{#if sidebar.root.showSidebar}
<div {...rest} class={cn('bg-sidebar col-start-1 h-screen w-[--sidebar-width]', className)}>
{@render children?.()}
</div>
{/if}

View file

@ -0,0 +1,29 @@
<script lang="ts">
import { Button, type ButtonElementProps } from '$lib/components/ui/button';
import { cn } from '$lib/utils/utils.js';
import { useSidebarTrigger } from './sidebar.svelte.js';
let {
class: className,
variant = 'ghost',
size = 'icon',
onclick,
children,
...rest
}: ButtonElementProps = $props();
const trigger = useSidebarTrigger();
</script>
<Button
{...rest}
{variant}
{size}
class={cn('', className)}
onclick={(e: Parameters<NonNullable<ButtonElementProps['onclick']>>[0]) => {
onclick?.(e);
trigger.toggle();
}}
>
{@render children?.()}
</Button>

View file

@ -0,0 +1,18 @@
<script lang="ts">
import { cn } from '$lib/utils/utils';
import type { HTMLAttributes } from 'svelte/elements';
import { useSidebar } from './sidebar.svelte.js';
let { children, ...rest }: HTMLAttributes<HTMLDivElement> = $props();
const sidebar = useSidebar();
</script>
<div
{...rest}
class={cn('[--sidebar-width:0px] md:grid md:grid-cols-[var(--sidebar-width)_1fr]', {
'[--sidebar-width:250px]': sidebar.showSidebar
})}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,50 @@
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
import { Context } from 'runed';
export class SidebarRootState {
open = $state(true);
openMobile = $state(false);
isMobile = new IsMobile();
showSidebar = $derived(this.isMobile.current ? this.openMobile : this.open);
constructor() {
this.toggle = this.toggle.bind(this);
}
toggle() {
if (this.isMobile.current) {
this.openMobile = !this.openMobile;
} else {
this.open = !this.open;
}
}
}
export class SidebarTriggerState {
constructor(readonly root: SidebarRootState) {
this.toggle = this.toggle.bind(this);
}
toggle() {
this.root.toggle();
}
}
export class SidebarSidebarState {
constructor(readonly root: SidebarRootState) {}
}
export const ctx = new Context<SidebarRootState>('sidebar-root-context');
export function useSidebar() {
return ctx.set(new SidebarRootState());
}
export function useSidebarTrigger() {
return new SidebarTriggerState(ctx.get());
}
export function useSidebarSidebar() {
return new SidebarSidebarState(ctx.get());
}

View file

@ -0,0 +1,9 @@
import { MediaQuery } from 'svelte/reactivity';
const DEFAULT_MOBILE_BREAKPOINT = 768;
export class IsMobile extends MediaQuery {
constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) {
super(`max-width: ${breakpoint - 1}px`);
}
}

View file

@ -1,18 +0,0 @@
<script lang="ts">
import Button from '$lib/components/ui/button/button.svelte';
import { authClient } from '$lib/backend/auth/client';
async function signInGithub() {
try {
console.log('signInGithub');
const data = await authClient.signIn.social({
provider: 'github'
});
console.log('signInGithub data', data);
} catch (e) {
console.log('signInGithub error', e);
}
}
</script>
<Button onClickPromise={signInGithub}>Sign In Github</Button>

View file

@ -0,0 +1,11 @@
import { redirectToLogin } from '$lib/backend/auth/redirect.js';
export async function load({ locals, url }) {
const session = await locals.auth();
if (!session) redirectToLogin(url);
return {
session
};
}

View file

@ -1,11 +1,13 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { active } from '$lib/actions/active.svelte';
import { authClient } from '$lib/backend/auth/client.js';
import Button from '$lib/components/ui/button/button.svelte';
import { LightSwitch } from '$lib/components/ui/light-switch';
import { ArrowLeftIcon } from '@lucide/svelte';
import { Avatar } from 'melt/components';
let { children } = $props();
let { data, children } = $props();
const navigation: { title: string; href: string }[] = [
{
@ -25,6 +27,12 @@
href: '/account/api-keys'
}
];
async function signOut() {
await authClient.signOut();
await goto('/login');
}
</script>
<div class="container mx-auto max-w-[1200px] space-y-8 pt-6 pb-24">
@ -35,27 +43,27 @@
</a>
<div class="flex place-items-center gap-2">
<LightSwitch variant="ghost" />
<Button variant="ghost">Sign out</Button>
<Button variant="ghost" onClickPromise={signOut}>Sign out</Button>
</div>
</header>
<div class="px-4 md:grid md:grid-cols-[280px_1fr]">
<div class="hidden md:col-start-1 md:block">
<div class="flex flex-col place-items-center gap-2">
<Avatar src="https://github.com/shadcn.png">
<Avatar src={data.session.user.image}>
{#snippet children(avatar)}
<img {...avatar.image} alt="Avatar" class="size-40 rounded-full" />
<span {...avatar.fallback}>JD</span>
<img {...avatar.image} alt="Your avatar" class="size-40 rounded-full" />
<span {...avatar.fallback}>{data.session.user.name}</span>
{/snippet}
</Avatar>
<div class="flex flex-col gap-1">
<h1 class="text-center text-2xl font-bold">John Doe</h1>
<span class="text-muted-foreground text-center text-sm">m@example.com</span>
<h1 class="text-center text-2xl font-bold">{data.session.user.name}</h1>
<span class="text-muted-foreground text-center text-sm">{data.session.user.email}</span>
</div>
</div>
</div>
<div class="space-y-8 pl-12 md:col-start-2">
<div
class="bg-card text-muted-foreground gap-2 flex w-fit place-items-center rounded-lg p-1 text-sm"
class="bg-card text-muted-foreground flex w-fit place-items-center gap-2 rounded-lg p-1 text-sm"
>
{#each navigation as tab (tab)}
<a

View file

@ -0,0 +1,44 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Sidebar from '$lib/components/ui/sidebar';
import { PanelLeftIcon } from '@lucide/svelte';
import { Avatar } from 'melt/components';
let { data, children } = $props();
</script>
<Sidebar.Root>
<Sidebar.Sidebar class="flex flex-col p-2">
<div class="flex place-items-center justify-center py-2">
<span class="text-center text-lg font-bold">Thom Chat</span>
</div>
<Button href="/chat" class="w-full">New Chat</Button>
<div class="flex flex-1 flex-col overflow-y-auto">
<!-- chats -->
</div>
<div class="py-2">
{#if data.session !== null}
<Button href="/account/api-keys" variant="ghost" class="h-auto w-full justify-start">
<Avatar src={data.session.user.image}>
{#snippet children(avatar)}
<img {...avatar.image} alt="Your avatar" class="size-10 rounded-full" />
<span {...avatar.fallback}>{data.session?.user.name}</span>
{/snippet}
</Avatar>
<div class="flex flex-col">
<span class="text-sm">{data.session?.user.name}</span>
<span class="text-muted-foreground text-xs">{data.session?.user.email}</span>
</div>
</Button>
{:else}
<Button href="/login" class="w-full">Login</Button>
{/if}
</div>
</Sidebar.Sidebar>
<Sidebar.Inset>
<Sidebar.Trigger class="fixed top-3 left-2">
<PanelLeftIcon />
</Sidebar.Trigger>
{@render children()}
</Sidebar.Inset>
</Sidebar.Root>

View file

@ -0,0 +1,27 @@
<script lang="ts">
import * as Icons from '$lib/components/icons';
import Button from '$lib/components/ui/button/button.svelte';
import { SendIcon } from '@lucide/svelte';
</script>
<div class="flex size-full place-items-center justify-center">
<div class="flex w-full max-w-lg flex-col place-items-center gap-1">
<form class="relative w-full h-18">
<textarea
class="border-input bg-background h-full ring-ring ring-offset-background w-full resize-none rounded-lg border p-2 text-sm ring-offset-2 outline-none focus-visible:ring-2"
placeholder="Ask me anything..."
></textarea>
<Button type="submit" size="icon" class="absolute bottom-1 right-1 size-8">
<SendIcon />
</Button>
</form>
<div class="flex w-full place-items-center justify-between gap-2">
<span class="text-muted-foreground text-xs">
Crafted by <Icons.Svelte class="inline size-3" /> wizards.
</span>
<a href="https://github.com/TGlide/thom-chat" class="text-muted-foreground text-xs">
Source on <Icons.GitHub class="ml-0.5 inline size-3" />
</a>
</div>
</div>
</div>

View file

@ -0,0 +1,9 @@
import { redirectToAuthorized } from '$lib/backend/auth/redirect';
export async function load({ locals, url }) {
const session = await locals.auth();
if (session) redirectToAuthorized(url);
return {};
}

View file

@ -0,0 +1,16 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Icons from '$lib/components/icons';
import { authClient } from '$lib/backend/auth/client.js';
async function signIn() {
await authClient.signIn.social({ provider: 'github', callbackURL: '/chat' });
}
</script>
<div class="flex h-svh flex-col place-items-center justify-center gap-4">
<h1 class="text-2xl font-bold">Sign in to thom.chat</h1>
<Button variant="outline" onClickPromise={signIn}>
<Icons.GitHub /> Continue with GitHub
</Button>
</div>