diff --git a/package.json b/package.json index 2e5361a..8d4da65 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2b5c70..d37171b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/lib/backend/auth/redirect.ts b/src/lib/backend/auth/redirect.ts new file mode 100644 index 0000000..870dcdd --- /dev/null +++ b/src/lib/backend/auth/redirect.ts @@ -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); +} diff --git a/src/lib/components/icons/github.svelte b/src/lib/components/icons/github.svelte new file mode 100644 index 0000000..529cf49 --- /dev/null +++ b/src/lib/components/icons/github.svelte @@ -0,0 +1,18 @@ + + + + + diff --git a/src/lib/components/icons/index.ts b/src/lib/components/icons/index.ts new file mode 100644 index 0000000..61e6d6a --- /dev/null +++ b/src/lib/components/icons/index.ts @@ -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 { + class?: string; + width?: number; + height?: number; +} + +export { GitHub, TypeScript, Svelte }; diff --git a/src/lib/components/icons/svelte.svelte b/src/lib/components/icons/svelte.svelte new file mode 100644 index 0000000..fa4f28c --- /dev/null +++ b/src/lib/components/icons/svelte.svelte @@ -0,0 +1,23 @@ + + + + svelte-logo + + + diff --git a/src/lib/components/icons/typescript.svelte b/src/lib/components/icons/typescript.svelte new file mode 100644 index 0000000..9f3be88 --- /dev/null +++ b/src/lib/components/icons/typescript.svelte @@ -0,0 +1,25 @@ + + + diff --git a/src/lib/components/ui/button/button.svelte b/src/lib/components/ui/button/button.svelte index 4981447..aae9659 100644 --- a/src/lib/components/ui/button/button.svelte +++ b/src/lib/components/ui/button/button.svelte @@ -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['variant']; diff --git a/src/lib/components/ui/sidebar/index.ts b/src/lib/components/ui/sidebar/index.ts new file mode 100644 index 0000000..ccf6d7e --- /dev/null +++ b/src/lib/components/ui/sidebar/index.ts @@ -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 }; \ No newline at end of file diff --git a/src/lib/components/ui/sidebar/sidebar-inset.svelte b/src/lib/components/ui/sidebar/sidebar-inset.svelte new file mode 100644 index 0000000..ee88493 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-inset.svelte @@ -0,0 +1,10 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sidebar/sidebar-sidebar.svelte b/src/lib/components/ui/sidebar/sidebar-sidebar.svelte new file mode 100644 index 0000000..1747e88 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-sidebar.svelte @@ -0,0 +1,16 @@ + + +{#if sidebar.root.showSidebar} +
+ {@render children?.()} +
+{/if} diff --git a/src/lib/components/ui/sidebar/sidebar-trigger.svelte b/src/lib/components/ui/sidebar/sidebar-trigger.svelte new file mode 100644 index 0000000..8469820 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar-trigger.svelte @@ -0,0 +1,29 @@ + + + diff --git a/src/lib/components/ui/sidebar/sidebar.svelte b/src/lib/components/ui/sidebar/sidebar.svelte new file mode 100644 index 0000000..5876623 --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar.svelte @@ -0,0 +1,18 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/sidebar/sidebar.svelte.ts b/src/lib/components/ui/sidebar/sidebar.svelte.ts new file mode 100644 index 0000000..d4ac4cb --- /dev/null +++ b/src/lib/components/ui/sidebar/sidebar.svelte.ts @@ -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('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()); +} diff --git a/src/lib/hooks/is-mobile.svelte.ts b/src/lib/hooks/is-mobile.svelte.ts new file mode 100644 index 0000000..acbe8ef --- /dev/null +++ b/src/lib/hooks/is-mobile.svelte.ts @@ -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`); + } +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f1da6cd..e69de29 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,18 +0,0 @@ - - - diff --git a/src/routes/account/+layout.server.ts b/src/routes/account/+layout.server.ts new file mode 100644 index 0000000..987f3c7 --- /dev/null +++ b/src/routes/account/+layout.server.ts @@ -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 + }; +} diff --git a/src/routes/account/+layout.svelte b/src/routes/account/+layout.svelte index ccd0e3a..1678b9f 100644 --- a/src/routes/account/+layout.svelte +++ b/src/routes/account/+layout.svelte @@ -1,11 +1,13 @@
@@ -35,27 +43,27 @@
- +
{#each navigation as tab (tab)} + 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(); + + + + +
+ Thom Chat +
+ +
+ +
+
+ {#if data.session !== null} + + {:else} + + {/if} +
+
+ + + + + {@render children()} + +
diff --git a/src/routes/chat/+page.svelte b/src/routes/chat/+page.svelte index e69de29..01a653b 100644 --- a/src/routes/chat/+page.svelte +++ b/src/routes/chat/+page.svelte @@ -0,0 +1,27 @@ + + +
diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts new file mode 100644 index 0000000..48d93e5 --- /dev/null +++ b/src/routes/login/+page.server.ts @@ -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 {}; +} diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte new file mode 100644 index 0000000..2483e97 --- /dev/null +++ b/src/routes/login/+page.svelte @@ -0,0 +1,16 @@ + + +
+

Sign in to thom.chat

+ +