feat: better components

This commit is contained in:
2025-05-12 11:27:33 -04:00
parent 398ddde169
commit cdeaa13d92
135 changed files with 10487 additions and 2088 deletions

View File

@ -1,60 +1,43 @@
<script lang="ts">
import * as Sheet from '$lib/ui/sheet';
import * as DropdownMenu from '$lib/ui/dropdown-menu';
import * as Avatar from '$lib/ui/avatar';
import {
LayoutGrid,
Settings,
LogOut,
Menu,
LayoutList,
Book,
House,
type Icon as IconType
Sun,
Moon,
Settings
} from '@lucide/svelte';
import { NavigationMenu, Popover, Separator, Dialog } from 'bits-ui';
import { fade, fly, slide } from 'svelte/transition';
import { Button } from '$lib/ui/button';
import { toggleMode } from 'mode-watcher';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { AuthClient, UserClient } from '$lib/transport';
import { page } from '$app/state';
import { cn } from '$lib/utils';
import { userState } from '$lib/sharedState.svelte';
import Avatar from '$lib/ui/Avatar.svelte';
import type { Snippet } from 'svelte';
import type { PageData } from './$types';
let { children } = $props();
interface Props {
data: PageData | undefined;
children?: Snippet;
}
let { data = $bindable(), children }: Props = $props();
UserClient.getUser({}).then((res) => {
userState.user = res.user;
});
let sidebarOpen = $state(false);
let popupOpen = $state(false);
type MenuItem = {
name: string;
href: string;
icon: typeof IconType;
};
const menuItems: MenuItem[] = [
{
name: 'Home',
href: '/',
icon: House
},
{
name: 'Items',
href: '/items/',
icon: LayoutList
},
{
name: 'Docs',
href: '/docs/',
icon: Book
}
];
async function logout() {
await AuthClient.logout({});
await goto('/auth');
toast.success('logged out successfully');
toast.success('successfully logged out');
userState.user = undefined;
if (sidebarOpen) {
@ -63,163 +46,183 @@
}
</script>
<header
class="border-surface-0 bg-mantle fixed z-50 flex h-[50px] w-full items-center justify-between border-b p-2 px-6 drop-shadow-md"
>
<div class="flex items-center gap-4">
<Dialog.Root bind:open={sidebarOpen}>
<Dialog.Trigger class="hover:bg-surface-0 cursor-pointer rounded p-1 px-3 transition-all">
<Menu />
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay forceMount>
{#snippet child({ props, open })}
{#if open}
<div
{...props}
transition:fade={{
duration: 150
}}
>
<div class="fixed inset-0 z-50 mt-[50px] bg-black/50"></div>
</div>
{/if}
{/snippet}
</Dialog.Overlay>
<Dialog.Content forceMount>
{#snippet child({ props, open })}
{#if open}
<div
class="bg-mantle border-surface-0 fixed inset-0 z-50 mt-[50px] flex w-60 flex-col justify-between border-r drop-shadow-md"
{...props}
transition:slide={{
axis: 'x'
}}
>
<NavigationMenu.Root orientation="vertical">
<NavigationMenu.List
class="flex w-full flex-col gap-2 overflow-x-hidden overflow-y-auto p-2"
>
{#each menuItems as item (item.name)}
{@const Icon = item.icon}
<NavigationMenu.Item>
<NavigationMenu.Link
class={cn(
'hover:bg-surface-0 flex gap-2 rounded-lg p-2 whitespace-nowrap transition-all select-none',
page.url.pathname === item.href && 'bg-surface-0'
)}
href={item.href}
onSelect={() => {
if (sidebarOpen) {
sidebarOpen = false;
}
}}
>
<Icon />
<span>{item.name}</span>
</NavigationMenu.Link>
</NavigationMenu.Item>
{/each}
</NavigationMenu.List>
</NavigationMenu.Root>
<div class="border-surface-0 flex flex-col gap-2 border-t p-2">
<a
href="/settings"
class="hover:bg-surface-0 flex items-center gap-2 rounded-lg p-2 transition-all select-none"
onclick={() => {
if (sidebarOpen) {
sidebarOpen = false;
}
}}
>
<Settings />
<span>Settings</span>
</a>
<button
class="hover:bg-surface-0 flex w-full cursor-pointer items-center gap-2 rounded-lg p-2 whitespace-nowrap transition-all"
onclick={logout}
>
<LogOut size="20" />
Log out
</button>
</div>
</div>
{/if}
{/snippet}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<a href="/" class="flex items-center gap-2 text-2xl font-bold tracking-wider select-none">
<div class="flex min-h-screen flex-col justify-between gap-2">
<header
class="bg-mantle border-surface text-text flex h-14 w-full items-center justify-between border-b px-4 py-4 lg:px-10"
>
<!-- Left -->
<a href="/" class="flex select-none items-center gap-2 text-2xl font-bold tracking-wider">
TrevStack
<LayoutGrid />
</a>
<!-- Center -->
<div class="bg-crust hidden items-center gap-3 rounded-md p-1 lg:flex">
<Button variant="ghost" class="hover:bg-based" href="/">
<House />
Home
</Button>
<Button variant="ghost" class="hover:bg-based" href="/items">
<LayoutList />
Items
</Button>
<Button variant="ghost" class="hover:bg-based" href="/docs">
<Book />
Docs
</Button>
</div>
<!-- Right -->
<div class="flex items-center gap-4">
<DropdownMenu.Root>
<DropdownMenu.Trigger class="hidden lg:flex">
{#snippet child({ props })}
<Avatar.Root {...props}>
{#if userState.user?.profilePictureId}
<Avatar.Image
src={`/file/${userState.user.profilePictureId}`}
alt={`${userState.user?.username}'s avatar`}
/>
{/if}
<Avatar.Fallback class="hover:bg-surface-1"
>{userState.user?.username.substring(0, 2).toUpperCase()}</Avatar.Fallback
>
</Avatar.Root>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item
class="hidden dark:flex"
onclick={() => {
toggleMode();
}}
>
<Sun />
Light Mode
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex dark:hidden"
onclick={() => {
toggleMode();
}}
>
<Moon />
Dark Mode
</DropdownMenu.Item>
<DropdownMenu.Link class="flex" href="/settings">
<Settings />
Settings
</DropdownMenu.Link>
<DropdownMenu.Item
class="flex"
onclick={() => {
logout();
}}
>
<LogOut />
Log Out
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<Sheet.Root bind:open={sidebarOpen}>
<Sheet.Trigger>
{#snippet child({ props })}
<Button variant="outline" {...props}>
<Menu />
</Button>
{/snippet}
</Sheet.Trigger>
<Sheet.Content class="flex flex-col justify-between overflow-auto pt-12">
<div class="flex flex-col gap-1">
<Button
variant="outline"
class="w-full"
href="/"
onclick={() => {
sidebarOpen = false;
}}
>
<House />
Home
</Button>
<Button
variant="outline"
class="w-full"
href="/items"
onclick={() => {
sidebarOpen = false;
}}
>
<LayoutList />
Items
</Button>
<Button
variant="outline"
class="w-full"
href="/docs"
onclick={() => {
sidebarOpen = false;
}}
>
<Book />
Docs
</Button>
</div>
<div class="flex flex-col gap-1">
<span class="text-text font-bold">Settings</span>
<Button
variant="outline"
class="hidden w-full dark:flex"
onclick={() => {
toggleMode();
}}
>
<Sun />
Light Mode
</Button>
<Button
variant="outline"
class="flex w-full dark:hidden"
onclick={() => {
toggleMode();
}}
>
<Moon />
Dark Mode
</Button>
<Button
variant="outline"
class="flex w-full"
href="/settings"
onclick={() => {
sidebarOpen = false;
}}
>
<Settings />
Settings
</Button>
<Button
variant="outline"
class="flex w-full"
onclick={() => {
logout();
}}
>
<LogOut />
Log Out
</Button>
</div>
</Sheet.Content>
</Sheet.Root>
</div>
</header>
<div class="grow">
{@render children?.()}
</div>
<NavigationMenu.Root class="hidden md:block">
<NavigationMenu.List class="flex gap-2">
{#each menuItems as item (item.name)}
<NavigationMenu.Item>
<NavigationMenu.Link
class={cn(
'hover:bg-surface-0 flex gap-2 rounded-lg p-1 px-2 transition-all select-none',
page.url.pathname === item.href && 'bg-surface-0'
)}
href={item.href}
>
<span>{item.name}</span>
</NavigationMenu.Link>
</NavigationMenu.Item>
{/each}
</NavigationMenu.List>
<NavigationMenu.Viewport class="absolute" />
</NavigationMenu.Root>
<Popover.Root bind:open={popupOpen}>
<Popover.Trigger
class="outline-surface-2 bg-text text-crust h-9 w-9 cursor-pointer rounded-full text-sm outline outline-offset-2 transition-all hover:brightness-120"
>
<Avatar />
</Popover.Trigger>
<Popover.Content forceMount>
{#snippet child({ wrapperProps, props, open })}
{#if open}
<div {...wrapperProps}>
<div
class="bg-mantle border-surface-0 m-1 rounded border drop-shadow-md transition-all"
{...props}
transition:fly
>
<a
class="hover:bg-surface-0 flex items-center gap-1 p-3 px-4 text-sm"
href="/settings"
onclick={() => {
if (popupOpen) {
popupOpen = false;
}
}}
>
<Settings size="20" />
Settings
</a>
<Separator.Root class="bg-surface-0 h-px" />
<button
class="hover:bg-surface-0 flex w-full cursor-pointer items-center gap-1 p-3 px-4 text-sm transition-all"
onclick={logout}
>
<LogOut size="20" />
Log out
</button>
</div>
</div>
{/if}
{/snippet}
</Popover.Content>
</Popover.Root>
</header>
<div class="overflow-auto pt-[50px]">
{@render children()}
<footer class="border-surface text-subtext bg-mantle flex justify-center border-t py-1 text-xs">
v. version
</footer>
</div>

View File

@ -1,4 +1,4 @@
<div class="flex h-[calc(100vh-50px)]">
<div class="flex h-body">
<div class="m-auto flex flex-col gap-2 p-4">
<h1 class="decoration-sky text-4xl font-bold underline underline-offset-4">
Welcome to TrevStack

View File

@ -1,383 +1,312 @@
<script lang="ts">
import * as Select from '$lib/ui/select';
import * as Dialog from '$lib/ui/dialog';
import * as Form from '$lib/ui/form';
import * as Table from '$lib/ui/table';
import { ItemClient } from '$lib/transport';
import { Plus, Trash, Pencil } from '@lucide/svelte';
import { Skeleton } from '$lib/ui/skeleton';
import { Plus, Trash, Pencil, LoaderCircle } from '@lucide/svelte';
import { Button } from '$lib/ui/button';
import { timestampFromDate, timestampDate } from '@bufbuild/protobuf/wkt';
import { toast } from 'svelte-sonner';
import { ConnectError } from '@connectrpc/connect';
import Modal from '$lib/ui/Modal.svelte';
import Button from '$lib/ui/Button.svelte';
import DateRangePicker from '$lib/ui/DateRangePicker.svelte';
import Input from '$lib/ui/Input.svelte';
import Select from '$lib/ui/Select.svelte';
import { SvelteMap } from 'svelte/reactivity';
import type { Item } from '$lib/connect/item/v1/item_pb';
import Pagination from '$lib/ui/Pagination.svelte';
import { Input } from '$lib/ui/input';
import { ItemService, type Item } from '$lib/connect/item/v1/item_pb';
import { DateRangePicker } from '$lib/ui/daterangepicker';
import { coolForm, newState } from '$lib/coolforms';
import { Card } from '$lib/ui/card';
import { Pager } from '$lib/ui/pager';
import { cn } from '$lib/utils';
// Config
let limit: number = $state(10);
let offset: number = $state(0);
let start: Date | undefined = $state();
let end: Date | undefined = $state();
let filter = $state('');
// Items
let items = $state(getItems());
let count: number = $state(0);
// Open
let addedOpen = $state(false);
let deletesOpen: SvelteMap<bigint, boolean> = new SvelteMap();
let editsOpen: SvelteMap<bigint, boolean> = new SvelteMap();
async function getItems() {
return await ItemClient.getItems({
limit: limit,
offset: offset,
start: start ? timestampFromDate(start) : undefined,
end: end ? timestampFromDate(end) : undefined,
filter: filter
}).then((resp) => {
count = Number(resp.count);
return resp.items;
});
}
async function updateItems() {
let i = getItems();
i.then(() => {
items = i;
});
}
const get = coolForm(ItemClient, ItemService.method.getItems, {
init: {
limit: 10,
offset: 0
},
start: true,
reset: false
});
</script>
<div class="mx-4 my-2 flex flex-wrap items-center justify-center gap-2">
<Input bind:value={filter} className="bg-mantle" placeholder="Filter" onchange={updateItems} />
<Select
items={[
{
label: '10 Items',
value: '10'
},
{
label: '25 Items',
value: '25'
},
{
label: '100 Items',
value: '100'
},
{
label: '250 Items',
value: '250'
}
]}
placeholder="Items per page"
<LoaderCircle class={cn('invisible animate-spin', get.loading() && 'visible')} />
<Input
bind:value={get.input.filter}
class="w-md bg-based"
placeholder="Filter"
onchange={() => {
offset = 0;
updateItems();
get.submit();
}}
/>
<Select.Root
type="single"
bind:value={() => get.input.limit?.toString(), (limit) => (get.input.limit = Number(limit))}
onValueChange={() => {
get.submit();
}}
>
<Select.Trigger class="w-32">
{get.input.limit} Items
</Select.Trigger>
<Select.Content>
<Select.Item value="10">10 Items</Select.Item>
<Select.Item value="25">25 Items</Select.Item>
<Select.Item value="100">100 Items</Select.Item>
<Select.Item value="250">250 Items</Select.Item>
</Select.Content>
</Select.Root>
<DateRangePicker
start={get.input.start ? timestampDate(get.input.start) : undefined}
end={get.input.end ? timestampDate(get.input.end) : undefined}
onchange={(start, end) => {
get.input.start = timestampFromDate(start);
get.input.end = timestampFromDate(end);
get.submit();
}}
defaultValue="10"
bind:value={() => limit.toString(), (v) => (limit = parseInt(v))}
/>
<DateRangePicker bind:start bind:end onchange={updateItems} />
</div>
{#snippet editModal(item: Item)}
<Modal
bind:open={
() =>
editsOpen.has(item.id!)
? editsOpen.get(item.id!)!
: editsOpen.set(item.id!, false) && editsOpen.get(item.id!)!,
(value) => editsOpen.set(item.id!, value)
{@const s = newState({ open: false })}
{@const update = coolForm(ItemClient, ItemService.method.updateItem, {
init: {
id: item.id,
name: item.name,
description: item.description,
price: item.price,
quantity: item.quantity
},
onResult: () => {
get.submit();
s.open = false;
}
>
{#snippet trigger(props)}
<Button {...props} className="bg-text">
<Pencil />
</Button>
{/snippet}
})}
{#snippet title()}
Edit '{item.name}'
{/snippet}
<Dialog.Root bind:open={s.open}>
<Dialog.Trigger>
{#snippet child({ props })}
<Button {...props}>
<Pencil />
</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Edit '{item.name}'</Dialog.Title>
</Dialog.Header>
<form use:update.impair class="flex flex-col gap-2">
<Input type="hidden" bind:value={update.input.id} />
{#snippet content()}
<form
onsubmit={async (e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const formData = new FormData(form);
const name = formData.get('name')?.toString();
const description = formData.get('description')?.toString();
const price = formData.get('price')?.toString();
const quantity = formData.get('quantity')?.toString();
<Form.Field name="name">
<Form.Label />
<Input bind:value={update.input.name} />
<Form.Errors bind:errors={update.errors.name} />
</Form.Field>
try {
await ItemClient.updateItem({
id: item.id,
name: name,
description: description,
price: price ? parseFloat(price) : undefined,
quantity: quantity ? parseInt(quantity) : undefined
});
<Form.Field name="description">
<Form.Label />
<Input bind:value={update.input.description} />
<Form.Errors bind:errors={update.errors.description} />
</Form.Field>
toast.success(`item "${name}" saved`);
editsOpen.set(item.id, false);
await updateItems();
} catch (err) {
const error = ConnectError.from(err);
toast.error(error.rawMessage);
}
}}
>
<div class="flex flex-col gap-4 p-3">
<div class="flex flex-col gap-1">
<label for="name" class="text-sm">Name</label>
<Input name="name" type="text" value={item.name} />
</div>
<div class="flex flex-col gap-1">
<label for="description" class="text-sm">Description</label>
<Input name="description" type="text" value={item.description} />
</div>
<div class="flex flex-col gap-1">
<label for="price" class="text-sm">Price</label>
<Input name="price" type="number" value={item.price} />
</div>
<div class="flex flex-col gap-1">
<label for="quantity" class="text-sm">Quantity</label>
<Input name="quantity" type="number" value={item.quantity} />
</div>
<Button type="submit">Submit</Button>
</div>
<Form.Field name="price">
<Form.Label />
<Input type="number" bind:value={update.input.price} />
<Form.Errors bind:errors={update.errors.price} />
</Form.Field>
<Form.Field name="quantity">
<Form.Label />
<Input type="number" bind:value={update.input.quantity} />
<Form.Errors bind:errors={update.errors.quantity} />
</Form.Field>
<Form.Errors bind:errors={update.errors.form} />
<Button type="submit" loading={update.loading()}>Submit</Button>
</form>
{/snippet}
</Modal>
</Dialog.Content>
</Dialog.Root>
{/snippet}
{#snippet deleteModal(item: Item)}
<Modal
bind:open={
() =>
deletesOpen.has(item.id!)
? deletesOpen.get(item.id!)!
: deletesOpen.set(item.id!, false) && deletesOpen.get(item.id!)!,
(value) => deletesOpen.set(item.id!, value)
{@const s = newState({ open: false })}
{@const remove = coolForm(ItemClient, ItemService.method.deleteItem, {
init: {
id: item.id
},
onResult: () => {
get.submit();
s.open = false;
}
>
{#snippet trigger(props)}
<Button {...props} className="bg-red">
<Trash />
</Button>
{/snippet}
})}
{#snippet title()}
Delete '{item.name}'
{/snippet}
<Dialog.Root bind:open={s.open}>
<Dialog.Trigger>
{#snippet child({ props })}
<Button variant="red" {...props}>
<Trash />
</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Delete '{item.name}'</Dialog.Title>
<Dialog.Description>Are you sure you want to delete {item.name}?</Dialog.Description>
</Dialog.Header>
<form use:remove.impair class="flex flex-col gap-2">
<Input type="hidden" bind:value={remove.input.id} />
{#snippet content()}
<form
onsubmit={async (e) => {
e.preventDefault();
try {
await ItemClient.deleteItem({
id: item.id
});
toast.success(`item "${item.name}" deleted`);
deletesOpen.set(item.id!, false);
await updateItems();
} catch (err) {
const error = ConnectError.from(err);
toast.error(error.rawMessage);
}
}}
>
<div class="flex flex-col gap-4 p-3">
<span class="text-center">Are you sure you want to delete "{item.name}"?</span>
<div class="flex justify-center gap-4">
<Button type="submit">Submit</Button>
</div>
</div>
<Button type="submit" loading={remove.loading()}>Delete</Button>
</form>
{/snippet}
</Modal>
</Dialog.Content>
</Dialog.Root>
{/snippet}
<div
class="border-surface-0 bg-mantle mx-4 my-2 hidden overflow-x-auto rounded border-x border-t drop-shadow-md sm:block"
>
<table class="w-full table-auto border-collapse text-left rtl:text-right">
<thead>
<tr class="border-surface-0 border-b">
<th scope="col" class="text-subtext-0 px-6 py-3 font-normal">Added</th>
<th scope="col" class="text-subtext-0 px-6 py-3 font-normal">Name</th>
<th scope="col" class="text-subtext-0 px-6 py-3 font-normal">Description</th>
<th scope="col" class="text-subtext-0 px-6 py-3 font-normal">Price</th>
<th scope="col" class="text-subtext-0 px-6 py-3 font-normal">Quantity</th>
<th class="w-0"></th>
</tr>
</thead>
<tbody>
{#await items}
<tr class="border-surface-0 border-b">
<td class="px-6 py-3"><div class="bg-surface-2 m-2 h-3 animate-pulse rounded"></div></td>
<td class="px-6 py-3"><div class="bg-surface-2 m-2 h-3 animate-pulse rounded"></div></td>
<td class="px-6 py-3"><div class="bg-surface-2 m-2 h-3 animate-pulse rounded"></div></td>
<td class="px-6 py-3"><div class="bg-surface-2 m-2 h-3 animate-pulse rounded"></div></td>
<td class="px-6 py-3"><div class="bg-surface-2 m-2 h-3 animate-pulse rounded"></div></td>
<td class="w-8"></td>
</tr>
{:then items}
{#each items as item (item.id)}
<tr class="border-surface-0 border-b">
<td class="px-6 py-3">
{item.added ? timestampDate(item.added).toLocaleString() : ''}
</td>
<td class="px-6 py-3">{item.name}</td>
<td class="px-6 py-3">{item.description}</td>
<td class="px-6 py-3">${item.price}</td>
<td class="px-6 py-3">{item.quantity}</td>
<td class="pr-2">
<div class="flex gap-2">
{@render editModal(item)}
{@render deleteModal(item)}
</div>
</td>
</tr>
{#snippet createModal()}
{@const s = newState({ open: false })}
{@const create = coolForm(ItemClient, ItemService.method.createItem, {
onResult: () => {
get.submit();
s.open = false;
}
})}
<Dialog.Root bind:open={s.open}>
<Dialog.Trigger>
{#snippet child({ props })}
<Button class="w-14" {...props}>
<Plus />
</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Add item</Dialog.Title>
</Dialog.Header>
<form use:create.impair class="flex flex-col gap-2">
<Form.Field name="name">
<Form.Label />
<Input bind:value={create.input.name} />
<Form.Errors bind:errors={create.errors.name} />
</Form.Field>
<Form.Field name="description">
<Form.Label />
<Input bind:value={create.input.description} />
<Form.Errors bind:errors={create.errors.description} />
</Form.Field>
<Form.Field name="price">
<Form.Label />
<Input type="number" bind:value={create.input.price} />
<Form.Errors bind:errors={create.errors.price} />
</Form.Field>
<Form.Field name="quantity">
<Form.Label />
<Input type="number" bind:value={create.input.quantity} />
<Form.Errors bind:errors={create.errors.quantity} />
</Form.Field>
<Form.Errors bind:errors={create.errors.form} />
<Button type="submit" loading={create.loading()}>Submit</Button>
</form>
</Dialog.Content>
</Dialog.Root>
{/snippet}
<div class="hidden px-4 sm:block">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Added</Table.Head>
<Table.Head>Name</Table.Head>
<Table.Head>Description</Table.Head>
<Table.Head>Price</Table.Head>
<Table.Head>Quantity</Table.Head>
<Table.Head></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if get.loading() && get.output.items.length == 0}
<Table.Row>
<Table.Cell><Skeleton class="h-5 w-full" /></Table.Cell>
<Table.Cell><Skeleton class="h-5 w-full" /></Table.Cell>
<Table.Cell><Skeleton class="h-5 w-full" /></Table.Cell>
<Table.Cell><Skeleton class="h-5 w-full" /></Table.Cell>
<Table.Cell><Skeleton class="h-5 w-full" /></Table.Cell>
<Table.Cell class="w-0"></Table.Cell>
</Table.Row>
{:else}
{#each get.output.items as item}
<Table.Row>
<Table.Cell>{item.added ? timestampDate(item.added).toLocaleString() : ''}</Table.Cell>
<Table.Cell>{item.name}</Table.Cell>
<Table.Cell>{item.description}</Table.Cell>
<Table.Cell>{item.price}</Table.Cell>
<Table.Cell>{item.quantity}</Table.Cell>
<Table.Cell class="w-0">
{@render editModal(item)}
{@render deleteModal(item)}
</Table.Cell>
</Table.Row>
{/each}
{/await}
</tbody>
</table>
{/if}
</Table.Body>
</Table.Root>
</div>
<div class="flex flex-wrap justify-center gap-2 px-4 sm:hidden">
{#await items}
<div
class="border-surface-0 bg-mantle flex w-full flex-wrap gap-6 rounded border p-5 drop-shadow-md"
>
<div class="flex flex-col">
<span class="text-subtext-0 text-sm">Added</span>
<div class="bg-surface-2 m-2 h-3 animate-pulse rounded"></div>
</div>
<div class="flex flex-col">
<span class="text-subtext-0 text-sm">Name</span>
<div class="bg-surface-2 m-2 h-3 animate-pulse rounded"></div>
</div>
<div class="flex flex-col">
<span class="text-subtext-0 text-sm">Description</span>
<div class="bg-surface-2 m-2 h-3 animate-pulse rounded"></div>
</div>
<div class="flex flex-col">
<span class="text-subtext-0 text-sm">Price</span>
<div class="bg-surface-2 m-2 h-3 animate-pulse rounded"></div>
</div>
<div class="flex flex-col">
<span class="text-subtext-0 text-sm">Quantity</span>
<div class="bg-surface-2 m-2 h-3 animate-pulse rounded"></div>
</div>
</div>
{:then items}
{#each items as item (item.id)}
<div
class="border-surface-0 bg-mantle flex w-full flex-wrap gap-6 rounded border p-5 drop-shadow-md"
>
{#if get.loading() && get.output.items.length == 0}
<span>Loading</span>
{:else}
{#each get.output.items as item (item.id)}
<Card class="flex flex-wrap gap-4">
<div class="flex flex-col">
<span class="text-subtext-0 text-sm">Added</span>
<span class="text-subtext text-sm">Added</span>
<span class="truncate"
>{item.added ? timestampDate(item.added).toLocaleString() : ''}</span
>
</div>
<div class="flex flex-col">
<span class="text-subtext-0 text-sm">Name</span>
<span class="text-subtext text-sm">Name</span>
<span class="truncate">{item.name}</span>
</div>
<div class="flex flex-col">
<span class="text-subtext-0 text-sm">Description</span>
<span class="text-subtext text-sm">Description</span>
<span class="truncate">{item.description}</span>
</div>
<div class="flex flex-col">
<span class="text-subtext-0 text-sm">Price</span>
<span class="text-subtext text-sm">Price</span>
<span class="truncate">${item.price}</span>
</div>
<div class="flex flex-col">
<span class="text-subtext-0 text-sm">Quantity</span>
<span class="text-subtext text-sm">Quantity</span>
<span class="truncate">{item.quantity}</span>
</div>
<div class="ml-auto flex justify-end gap-2">
<div class="ml-auto flex justify-end gap-1 self-end">
{@render editModal(item)}
{@render deleteModal(item)}
</div>
</div>
</Card>
{/each}
{/await}
{/if}
</div>
<div class="mx-4 mt-2 mb-4 flex justify-end sm:mt-1">
<Modal bind:open={addedOpen}>
{#snippet trigger(props)}
<Button {...props} className="bg-sky">
<Plus />
</Button>
{/snippet}
{#snippet title()}
Add Item
{/snippet}
{#snippet content()}
<form
onsubmit={async (e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const formData = new FormData(form);
const name = formData.get('name')?.toString();
const description = formData.get('description')?.toString();
const price = formData.get('price')?.toString();
const quantity = formData.get('quantity')?.toString();
try {
await ItemClient.createItem({
name: name,
description: description,
price: parseFloat(price ?? '0'),
quantity: parseInt(quantity ?? '0')
});
form.reset();
toast.success(`item "${name}" added`);
addedOpen = false;
await updateItems();
} catch (err) {
const error = ConnectError.from(err);
toast.error(error.rawMessage);
}
}}
>
<div class="flex flex-col gap-4 p-3">
<div class="flex flex-col gap-1">
<label for="name" class="text-sm">Name</label>
<Input name="name" type="text" />
</div>
<div class="flex flex-col gap-1">
<label for="description" class="text-sm">Description</label>
<Input name="description" type="text" />
</div>
<div class="flex flex-col gap-1">
<label for="price" class="text-sm">Price</label>
<Input name="price" type="text" />
</div>
<div class="flex flex-col gap-1">
<label for="quantity" class="text-sm">Quantity</label>
<Input name="quantity" type="text" />
</div>
<Button type="submit">Submit</Button>
</div>
</form>
{/snippet}
</Modal>
<div class="mx-4 mb-4 mt-2 flex justify-end sm:mt-1">
{@render createModal()}
</div>
<div class="py-4">
<Pagination bind:count bind:limit bind:offset onchange={updateItems} />
<Pager
count={Number(get.output.count)}
limit={get.input.limit}
bind:offset={get.input.offset}
onsubmit={() => {
get.submit();
}}
/>
</div>

View File

@ -1,186 +1,189 @@
<script lang="ts">
import * as Avatar from '$lib/ui/avatar';
import * as Dialog from '$lib/ui/dialog';
import * as Form from '$lib/ui/form';
import { UserClient } from '$lib/transport';
import Button from '$lib/ui/Button.svelte';
import Modal from '$lib/ui/Modal.svelte';
import Input from '$lib/ui/Input.svelte';
import { ConnectError } from '@connectrpc/connect';
import { Separator } from 'bits-ui';
import { Button } from '$lib/ui/button';
import { Input } from '$lib/ui/input';
import { toast } from 'svelte-sonner';
import { userState } from '$lib/sharedState.svelte';
import Avatar from '$lib/ui/Avatar.svelte';
import { coolForm, newState } from '$lib/coolforms';
import { UserService } from '$lib/connect/user/v1/user_pb';
import { Card } from '$lib/ui/card';
import { Separator } from '$lib/ui/separator';
import { startRegistration } from '@simplewebauthn/browser';
import { ConnectError } from '@connectrpc/connect';
let openChangeProfilePicture = $state(false);
let key = $state('');
const updatePassword = coolForm(UserClient, UserService.method.updatePassword, {
reset: true,
onResult: () => {
toast.success('Successfully updated password');
}
});
async function createPasskey() {
const optionsJSON = JSON.parse(
(await UserClient.beginPasskeyRegistration({})).optionsJson
).publicKey;
const attResp = await startRegistration({ optionsJSON });
try {
await UserClient.finishPasskeyRegistration({
attestation: JSON.stringify(attResp)
});
} catch (e) {
if (!(e instanceof ConnectError)) {
throw e;
}
toast.error(e.message);
return;
}
toast.success('Added new passkey');
}
</script>
<div class="flex h-[calc(100vh-50px)]">
{#snippet apiKeyModal()}
{@const s = newState({ open: false, key: '' })}
{@const apikey = coolForm(UserClient, UserService.method.getAPIKey, {
onResult: (result) => {
s.key = result.key;
}
})}
<Dialog.Root bind:open={s.open}>
<Dialog.Trigger>
{#snippet child({ props })}
<Button {...props}>Generate API Key</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content>
{#if !s.key}
<Dialog.Header>
<Dialog.Title>Generate API Key</Dialog.Title>
</Dialog.Header>
<form use:apikey.impair class="flex flex-col gap-2">
<Form.Field name="password">
<Form.Label />
<Input type="password" bind:value={apikey.input.password} />
<Form.Errors bind:errors={apikey.errors.password} />
</Form.Field>
<Form.Field name="confirm_password">
<Form.Label />
<Input type="password" bind:value={apikey.input.confirmPassword} />
<Form.Errors bind:errors={apikey.errors.confirmPassword} />
</Form.Field>
<Form.Errors bind:errors={apikey.errors.form} />
<Button type="submit" loading={apikey.loading()}>Submit</Button>
</form>
{:else}
<Dialog.Header>
<Dialog.Title>API Key</Dialog.Title>
</Dialog.Header>
<span class="break-all">{s.key}</span>
{/if}
</Dialog.Content>
</Dialog.Root>
{/snippet}
{#snippet profilePictureModal()}
{@const s = newState({ open: false })}
{@const updatepp = coolForm(UserClient, UserService.method.updateProfilePicture, {
onSubmit: async (form, input) => {
const file = form.get('file');
if (file instanceof File) {
input.fileName = file.name;
input.data = await file.bytes();
}
return input;
},
onResult: () => {
s.open = false;
}
})}
<Dialog.Root bind:open={s.open}>
<Dialog.Trigger>
{#snippet child({ props })}
<Button {...props}>Change Profile Picture</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Change Profile Picture</Dialog.Title>
</Dialog.Header>
<form use:updatepp.impair class="flex flex-col gap-2">
<Form.Field name="file">
<Form.Label />
<Input type="file" />
<Form.Errors bind:errors={updatepp.errors.data} />
</Form.Field>
<Form.Errors bind:errors={updatepp.errors.form} />
<Button type="submit" loading={updatepp.loading()}>Submit</Button>
</form>
</Dialog.Content>
</Dialog.Root>
{/snippet}
<div class="h-body flex">
<div class="m-auto flex w-96 flex-col gap-4 p-4">
<div class="flex items-center justify-center gap-4">
<div
class="outline-surface-2 bg-text text-crust h-9 w-9 rounded-full text-sm outline outline-offset-2 select-none"
>
<Avatar />
</div>
<Avatar.Root class="size-12">
{#if userState.user?.profilePictureId}
<Avatar.Image
src={`/file/${userState.user.profilePictureId}`}
alt={`${userState.user?.username}'s avatar`}
/>
{/if}
<Avatar.Fallback class="text-md"
>{userState.user?.username.substring(0, 2).toUpperCase()}</Avatar.Fallback
>
</Avatar.Root>
<h1 class="overflow-x-hidden text-2xl font-medium">{userState.user?.username}</h1>
</div>
<Separator.Root class="bg-surface-0 h-px" />
<Separator />
<div class="flex flex-wrap justify-around gap-2">
<Modal>
{#snippet trigger(props)}
<Button {...props} className="bg-text">Generate API Key</Button>
{/snippet}
<div class="flex flex-wrap justify-center gap-2">
{@render apiKeyModal()}
{#snippet title()}
Generate API Key
{/snippet}
{@render profilePictureModal()}
{#snippet content()}
{#if key == ''}
<form
onsubmit={async (e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const formData = new FormData(form);
try {
const response = await UserClient.getAPIKey({
password: formData.get('password')?.toString(),
confirmPassword: formData.get('confirm-password')?.toString()
});
if (response.key) {
key = response.key;
form.reset();
}
} catch (err) {
const error = ConnectError.from(err);
toast.error(error.rawMessage);
}
}}
>
<div class="flex flex-col gap-4 p-3">
<div class="flex flex-col gap-1">
<label for="password" class="text-sm">Password</label>
<Input name="password" type="password" />
</div>
<div class="flex flex-col gap-1">
<label for="confirm-password" class="text-sm">Confirm Password</label>
<Input name="confirm-password" type="password" />
</div>
<Button type="submit">Submit</Button>
</div>
</form>
{:else}
<div class="p-3">
<span class="text-wrap break-all">{key}</span>
</div>
{/if}
{/snippet}
</Modal>
<Modal bind:open={openChangeProfilePicture}>
{#snippet trigger(props)}
<Button {...props} className="bg-text">Change Profile Picture</Button>
{/snippet}
{#snippet title()}
Change Profile Picture
{/snippet}
{#snippet content()}
<form
onsubmit={async (e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
let fileInput = document.getElementById('file') as HTMLInputElement;
let file = fileInput.files?.[0];
if (!file) {
toast.error('No file selected');
return;
}
const data = await file.bytes();
try {
const response = await UserClient.updateProfilePicture({
fileName: file.name,
data: data
});
if (response.user) {
toast.success('Profile picture updated');
form.reset();
openChangeProfilePicture = false;
userState.user = response.user;
}
} catch (err) {
const error = ConnectError.from(err);
toast.error(error.rawMessage);
}
}}
>
<div class="flex flex-col gap-4 p-3">
<div class="flex flex-col gap-1">
<label for="file" class="text-sm">Profile Picture</label>
<Input name="file" type="file" />
</div>
<Button type="submit">Submit</Button>
</div>
</form>
{/snippet}
</Modal>
<Button
className="bg-text"
onclick={async () => {
if (userState.user) {
//await createPasskey(userState.user.username, userState.user.id, "what");
}
}}>Register Device</Button
>
<Button onclick={createPasskey}>Register Device</Button>
</div>
<form
onsubmit={async (e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const formData = new FormData(form);
<Card>
<form use:updatePassword.impair class="flex flex-col gap-2">
<Form.Field name="old_password">
<Form.Label />
<Input type="password" bind:value={updatePassword.input.oldPassword} />
<Form.Errors bind:errors={updatePassword.errors.oldPassword} />
</Form.Field>
try {
await UserClient.updatePassword({
oldPassword: formData.get('old-password')?.toString(),
newPassword: formData.get('new-password')?.toString(),
confirmPassword: formData.get('confirm-password')?.toString()
});
<Form.Field name="new_password">
<Form.Label />
<Input type="password" bind:value={updatePassword.input.newPassword} />
<Form.Errors bind:errors={updatePassword.errors.newPassword} />
</Form.Field>
toast.success('password updated successfully');
form.reset();
} catch (err) {
const error = ConnectError.from(err);
toast.error(error.rawMessage);
}
}}
class="bg-mantle border-surface-0 rounded border p-4 drop-shadow-md"
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label for="old-password" class="text-sm">Old Password</label>
<Input name="old-password" type="password" />
</div>
<div class="flex flex-col gap-1">
<label for="new-password" class="text-sm">New Password</label>
<Input name="new-password" type="password" />
</div>
<div class="flex flex-col gap-1">
<label for="confirm-password" class="text-sm">Confirm New Password</label>
<Input name="confirm-password" type="password" />
</div>
<Button type="submit">Submit</Button>
</div>
</form>
<Form.Field name="confirm_password">
<Form.Label />
<Input type="password" bind:value={updatePassword.input.confirmPassword} />
<Form.Errors bind:errors={updatePassword.errors.confirmPassword} />
</Form.Field>
<Form.Errors bind:errors={updatePassword.errors.form} />
<Button type="submit" loading={updatePassword.loading()}>Submit</Button>
</form>
</Card>
</div>
</div>

View File

@ -1,16 +1,12 @@
<script>
import '../app.css';
import { Toaster } from 'svelte-sonner';
import { Sonner } from '$lib/ui/sonner';
import { ModeWatcher } from 'mode-watcher';
let { children } = $props();
</script>
<Toaster
toastOptions={{
classes: {
toast: '!bg-mantle !text-text !border-surface-0'
}
}}
/>
<ModeWatcher />
<Sonner />
{@render children()}

View File

@ -1,119 +1,130 @@
<script lang="ts">
import { Tabs } from 'bits-ui';
import { cn } from '$lib/utils';
import * as Tabs from '$lib/ui/tabs';
import * as Form from '$lib/ui/form';
import { Input } from '$lib/ui/input';
import { Button } from '$lib/ui/button';
import { AuthClient } from '$lib/transport';
import { goto } from '$app/navigation';
import { ConnectError } from '@connectrpc/connect';
import { toast } from 'svelte-sonner';
import Button from '$lib/ui/Button.svelte';
import Input from '$lib/ui/Input.svelte';
import { AuthService } from '$lib/connect/user/v1/auth_pb';
import { coolForm } from '$lib/coolforms';
import { Card } from '$lib/ui/card';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { startAuthentication } from '@simplewebauthn/browser';
import { ConnectError } from '@connectrpc/connect';
import { Fingerprint } from '@lucide/svelte';
let tab = $state('login');
let tabValue = $state('login');
async function redirect() {
if (page.url.searchParams.has('redir')) {
const uri = decodeURIComponent(page.url.searchParams.get('redir')!);
await goto(uri);
return;
}
await goto('/');
}
const login = coolForm(AuthClient, AuthService.method.login, {
onResult: () => {
redirect();
}
});
const signup = coolForm(AuthClient, AuthService.method.signUp, {
onResult: () => {
tabValue = 'login';
toast.success('Successfully created account, please log in');
}
});
async function passkeyLogin() {
const optionsJSON = JSON.parse(
(
await AuthClient.beginPasskeyLogin({
username: login.input.username
})
).optionsJson
).publicKey;
const attResp = await startAuthentication({ optionsJSON });
try {
await AuthClient.finishPasskeyLogin({
username: login.input.username,
attestation: JSON.stringify(attResp)
});
} catch (e) {
if (!(e instanceof ConnectError)) {
throw e;
}
toast.error(e.message);
return;
}
await redirect();
}
</script>
<div class="flex h-screen flex-col items-center justify-center">
<Tabs.Root bind:value={tab} class="w-[390px] p-3">
<Tabs.List
class="bg-mantle border-surface-0 flex w-full justify-around gap-1 rounded-lg border p-1 drop-shadow-md"
>
<Tabs.Trigger
value="login"
class={cn(
'hover:bg-surface-0 grow cursor-pointer rounded p-2 transition-all',
tab == 'login' && 'bg-surface-0'
)}>Log In</Tabs.Trigger
>
<Tabs.Trigger
value="signup"
class={cn(
'hover:bg-surface-0 grow cursor-pointer rounded p-2 transition-all',
tab == 'signup' && 'bg-surface-0'
)}>Sign Up</Tabs.Trigger
>
<Tabs.Root bind:value={tabValue} class="sm:min-w-sm min-w-full px-2">
<Tabs.List class="w-full">
<Tabs.Trigger value="login">Log In</Tabs.Trigger>
<Tabs.Trigger value="signup">Sign Up</Tabs.Trigger>
</Tabs.List>
<Tabs.Content
value="login"
class="bg-mantle border-surface-0 mt-2 rounded-lg border p-6 drop-shadow-md"
>
<form
onsubmit={async (e) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const username = formData.get('login-username')?.toString();
const password = formData.get('login-password')?.toString();
const redir = page.url.searchParams.get('redir') || '/';
<Tabs.Content value="login">
<Card>
<form use:login.impair class="flex flex-col gap-2">
<Form.Field name="username">
<Form.Label />
<Input bind:value={login.input.username} autocomplete="username webauthn" />
<Form.Errors bind:errors={login.errors.username} />
</Form.Field>
try {
const response = await AuthClient.login({
username: username,
password: password
});
<Form.Field name="password">
<Form.Label />
<Input type="password" bind:value={login.input.password} />
<Form.Errors bind:errors={login.errors.password} />
</Form.Field>
if (response.token && username) {
goto(redir);
}
} catch (err) {
const error = ConnectError.from(err);
toast.error(error.rawMessage);
}
}}
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label for="login-username" class="text-sm">Username</label>
<Input name="login-username" />
<Form.Errors bind:errors={login.errors.form} />
<div class="flex gap-1">
<Button type="submit" loading={login.loading()} class="grow">Submit</Button>
{#if login.input.username}
<Button type="button" onclick={passkeyLogin}><Fingerprint /></Button>
{/if}
</div>
<div class="flex flex-col gap-1">
<label for="login-password" class="text-sm">Password</label>
<Input name="login-password" type="password" />
</div>
<Button type="submit">Submit</Button>
</div>
</form>
</form>
</Card>
</Tabs.Content>
<Tabs.Content
value="signup"
class="bg-mantle border-surface-0 mt-2 rounded-lg border p-6 drop-shadow-md"
>
<form
onsubmit={async (e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const formData = new FormData(form);
<Tabs.Content value="signup">
<Card>
<form use:signup.impair class="flex flex-col gap-2">
<Form.Field name="username">
<Form.Label />
<Input bind:value={signup.input.username} />
<Form.Errors bind:errors={signup.errors.username} />
</Form.Field>
try {
await AuthClient.signUp({
username: formData.get('signup-username')?.toString(),
password: formData.get('signup-password')?.toString(),
confirmPassword: formData.get('signup-confirm-password')?.toString()
});
<Form.Field name="password">
<Form.Label />
<Input type="password" bind:value={signup.input.password} />
<Form.Errors bind:errors={signup.errors.password} />
</Form.Field>
toast.success('account created successfully, please log in');
form.reset();
tab = 'login';
} catch (err) {
const error = ConnectError.from(err);
toast.error(error.rawMessage);
}
}}
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label for="signup-username" class="text-sm">Username</label>
<Input name="signup-username" />
</div>
<div class="flex flex-col gap-1">
<label for="signup-password" class="text-sm">Password</label>
<Input name="signup-password" type="password" />
</div>
<div class="flex flex-col gap-1">
<label for="signup-confirm-password" class="text-sm">Confirm Password</label>
<Input name="signup-confirm-password" type="password" />
</div>
<Button type="submit">Submit</Button>
</div>
</form>
<Form.Field name="confirm_password">
<Form.Label />
<Input type="password" bind:value={signup.input.confirmPassword} />
<Form.Errors bind:errors={signup.errors.confirmPassword} />
</Form.Field>
<Form.Errors bind:errors={signup.errors.form} />
<Button type="submit" loading={signup.loading()}>Submit</Button>
</form>
</Card>
</Tabs.Content>
</Tabs.Root>
</div>