feat: better components
This commit is contained in:
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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()}
|
||||
|
@ -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>
|
||||
|
Reference in New Issue
Block a user