feat: cards

This commit is contained in:
2025-03-17 06:47:49 -04:00
parent 4adedd96ee
commit 71c69b7b80
18 changed files with 966 additions and 776 deletions

View File

@ -9,17 +9,20 @@
House,
type Icon as IconType
} from '@lucide/svelte';
import { NavigationMenu, Popover, Separator, Dialog, Avatar } from 'bits-ui';
import { NavigationMenu, Popover, Separator, Dialog } from 'bits-ui';
import { fade, fly, slide } from 'svelte/transition';
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';
let { children } = $props();
let user = UserClient.getUser({}).then((res) => {
return res.user;
UserClient.getUser({}).then((res) => {
userState.user = res.user;
});
let sidebarOpen = $state(false);
@ -52,6 +55,7 @@
await AuthClient.logout({});
await goto('/auth');
toast.success('logged out successfully');
userState.user = undefined;
if (sidebarOpen) {
sidebarOpen = false;
@ -175,16 +179,9 @@
<Popover.Root bind:open={popupOpen}>
<Popover.Trigger
class="outline-surface-2 hover:brightness-120 bg-text text-crust h-9 w-9 cursor-pointer rounded-full outline outline-offset-2 text-sm transition-all"
class="outline-surface-2 hover:brightness-120 bg-text text-crust h-9 w-9 cursor-pointer rounded-full text-sm outline outline-offset-2 transition-all"
>
{#await user then user}
<Avatar.Root class="flex h-full w-full items-center justify-center">
<Avatar.Image src={user?.profilePicture} alt={`${user?.username}'s avatar`} class="rounded-full" />
<Avatar.Fallback class="font-medium uppercase"
>{user?.username.substring(0, 2)}</Avatar.Fallback
>
</Avatar.Root>
{/await}
<Avatar />
</Popover.Trigger>
<Popover.Content forceMount>
{#snippet child({ wrapperProps, props, open })}
@ -223,6 +220,6 @@
</Popover.Root>
</header>
<div class="pt-[50px] overflow-auto">
<div class="overflow-auto pt-[50px]">
{@render children()}
</div>

View File

@ -1,19 +1,23 @@
<script lang="ts">
import { ItemClient } from '$lib/transport';
import { Plus, Trash, Pencil, Calendar, Minus, ArrowLeft, ArrowRight } from '@lucide/svelte';
import { Plus, Trash, Pencil } from '@lucide/svelte';
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/services/item/v1/item_pb';
import Pagination from '$lib/ui/Pagination.svelte';
// Config
let limit: number = $state(10);
let offset: number = $state(0);
let start = $state(new Date(new Date().setDate(new Date().getDate() - 1)));
let end = $state(new Date());
let start: Date | undefined = $state();
let end: Date | undefined = $state();
let filter = $state('');
// Items
@ -29,8 +33,8 @@
return await ItemClient.getItems({
limit: limit,
offset: offset,
start: timestampFromDate(start),
end: timestampFromDate(end),
start: start ? timestampFromDate(start) : undefined,
end: end ? timestampFromDate(end) : undefined,
filter: filter
}).then((resp) => {
count = Number(resp.count);
@ -45,18 +49,172 @@
}
</script>
<div class="mx-4 my-2 flex items-center justify-center gap-4">
<input
type="text"
placeholder="Filter..."
class="border-surface-0 hover:border-surface-2 w-70 bg-mantle rounded border p-2 text-sm drop-shadow-md transition-all"
<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}
/>
<DateRangePicker bind:start bind:end />
<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"
onchange={() => {
offset = 0;
updateItems();
}}
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)
}
>
{#snippet trigger(props)}
<Button {...props} className="bg-text">
<Pencil />
</Button>
{/snippet}
{#snippet title()}
Edit '{item.name}'
{/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 {
const response = await ItemClient.updateItem({
item: {
id: item.id,
name: name,
description: description,
price: parseFloat(price ?? '0'),
quantity: parseInt(quantity ?? '0')
}
});
if (response.item && item.id) {
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>
{/snippet}
</Modal>
{/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)
}
>
{#snippet trigger(props)}
<Button {...props} className="bg-red">
<Trash />
</Button>
{/snippet}
{#snippet title()}
Delete '{item.name}'
{/snippet}
{#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>
</form>
{/snippet}
</Modal>
{/snippet}
<div
class="border-surface-0 bg-mantle mx-4 my-2 overflow-x-auto rounded border-x border-t drop-shadow-md"
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>
@ -87,156 +245,12 @@
</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.price}</td>
<td class="px-6 py-3">{item.quantity}</td>
<td class="pr-2">
<div class="flex gap-2">
<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)
}
>
{#snippet trigger()}
<Button className="bg-text">
<Pencil />
</Button>
{/snippet}
{#snippet content()}
<h1 class="border-surface-0 border-b py-3 text-center text-xl font-bold">
Edit {item.name}
</h1>
<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 {
const response = await ItemClient.updateItem({
item: {
id: item.id,
name: name,
description: description,
price: parseFloat(price ?? '0'),
quantity: parseInt(quantity ?? '0')
}
});
if (response.item && item.id) {
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
id="name"
name="name"
type="text"
class="border-surface-0 rounded border p-2 text-sm"
value={item.name}
/>
</div>
<div class="flex flex-col gap-1">
<label for="description" class="text-sm">Description</label>
<input
id="description"
name="description"
type="text"
class="border-surface-0 rounded border p-2 text-sm"
value={item.description}
/>
</div>
<div class="flex flex-col gap-1">
<label for="price" class="text-sm">Price</label>
<input
id="price"
name="price"
type="number"
class="border-surface-0 rounded border p-2 text-sm"
value={item.price}
/>
</div>
<div class="flex flex-col gap-1">
<label for="quantity" class="text-sm">Quantity</label>
<input
id="quantity"
name="quantity"
type="number"
class="border-surface-0 rounded border p-2 text-sm"
value={item.quantity}
/>
</div>
<Button type="submit">Submit</Button>
</div>
</form>
{/snippet}
</Modal>
<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)
}
>
{#snippet trigger()}
<Button className="bg-red">
<Trash />
</Button>
{/snippet}
{#snippet content()}
<h1 class="border-surface-0 border-b py-3 text-center text-xl font-bold">
Delete {item.name}
</h1>
<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>
</form>
{/snippet}
</Modal>
{@render editModal(item)}
{@render deleteModal(item)}
</div>
</td>
</tr>
@ -246,16 +260,81 @@
</table>
</div>
<div class="mx-4 mt-1 flex justify-end">
<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-col gap-2 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}
<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>
<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="truncate">{item.name}</span>
</div>
<div class="flex flex-col">
<span class="text-subtext-0 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="truncate">${item.price}</span>
</div>
<div class="flex flex-col">
<span class="text-subtext-0 text-sm">Quantity</span>
<span class="truncate">{item.quantity}</span>
</div>
<div class="flex justify-end ml-auto gap-2">
{@render editModal(item)}
{@render deleteModal(item)}
</div>
</div>
{/each}
{/await}
</div>
<div class="mx-4 mb-4 mt-2 flex justify-end sm:mt-1">
<Modal bind:open={addedOpen}>
{#snippet trigger()}
<Button className="bg-sky">
{#snippet trigger(props)}
<Button {...props} className="bg-sky">
<Plus />
</Button>
{/snippet}
{#snippet title()}
Add Item
{/snippet}
{#snippet content()}
<h1 class="border-surface-0 border-b py-3 text-center text-xl font-bold">Add Item</h1>
<form
onsubmit={async (e) => {
e.preventDefault();
@ -291,39 +370,19 @@
<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
id="name"
name="name"
type="text"
class="border-surface-0 rounded border p-2 text-sm"
/>
<Input name="name" type="text" />
</div>
<div class="flex flex-col gap-1">
<label for="description" class="text-sm">Description</label>
<input
id="description"
name="description"
type="text"
class="border-surface-0 rounded border p-2 text-sm"
/>
<Input name="description" type="text" />
</div>
<div class="flex flex-col gap-1">
<label for="price" class="text-sm">Price</label>
<input
id="price"
name="price"
type="number"
class="border-surface-0 rounded border p-2 text-sm"
/>
<Input name="price" type="text" />
</div>
<div class="flex flex-col gap-1">
<label for="quantity" class="text-sm">Quantity</label>
<input
id="quantity"
name="quantity"
type="number"
class="border-surface-0 rounded border p-2 text-sm"
/>
<Input name="quantity" type="text" />
</div>
<Button type="submit">Submit</Button>
</div>
@ -331,3 +390,7 @@
{/snippet}
</Modal>
</div>
<div class="py-4">
<Pagination bind:count bind:limit bind:offset onchange={updateItems} />
</div>

View File

@ -2,46 +2,41 @@
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 { Avatar, Separator } from 'bits-ui';
import { Separator } from 'bits-ui';
import { toast } from 'svelte-sonner';
import { userState } from '$lib/sharedState.svelte';
import Avatar from '$lib/ui/Avatar.svelte';
let user = UserClient.getUser({}).then((res) => {
return res.user;
});
let openChangeProfilePicture = $state(false);
let key = $state('');
</script>
<div class="flex h-[calc(100vh-50px)]">
<div class="m-auto flex w-96 flex-col gap-4 p-4">
{#await user then user}
<div class="flex items-center justify-center gap-4">
<div
class="outline-surface-2 bg-text text-crust h-9 w-9 select-none rounded-full outline outline-offset-2 text-sm"
>
<Avatar.Root class="flex h-full w-full items-center justify-center">
<Avatar.Image src={user?.profilePicture} alt={`${user?.username}'s avatar`} class="rounded-full" />
<Avatar.Fallback class="font-medium uppercase"
>{user?.username.substring(0, 2)}</Avatar.Fallback
>
</Avatar.Root>
</div>
<h1 class="overflow-x-hidden text-2xl font-medium">{user?.username}</h1>
<div class="flex items-center justify-center gap-4">
<div
class="outline-surface-2 bg-text text-crust h-9 w-9 select-none rounded-full text-sm outline outline-offset-2"
>
<Avatar />
</div>
{/await}
<h1 class="overflow-x-hidden text-2xl font-medium">{userState.user?.username}</h1>
</div>
<Separator.Root class="bg-surface-0 h-px" />
<div class="flex justify-around gap-2">
<Modal>
{#snippet trigger()}
<Button className="bg-text">Generate API Key</Button>
{#snippet trigger(props)}
<Button {...props} className="bg-text">Generate API Key</Button>
{/snippet}
{#snippet title()}
Generate API Key
{/snippet}
{#snippet content()}
<h1 class="border-surface-0 border-b py-3 text-center text-xl font-bold">
Generate API Key
</h1>
{#if key == ''}
<form
onsubmit={async (e) => {
@ -68,21 +63,11 @@
<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
id="password"
name="password"
type="password"
class="border-surface-0 rounded border p-2 text-sm"
/>
<Input name="password" type="password" />
</div>
<div class="flex flex-col gap-1">
<label for="confirm-password" class="text-sm">Confirm Password</label>
<input
id="confirm-password"
name="confirm-password"
type="password"
class="border-surface-0 rounded border p-2 text-sm"
/>
<Input name="confirm-password" type="password" />
</div>
<Button type="submit">Submit</Button>
</div>
@ -95,15 +80,16 @@
{/snippet}
</Modal>
<Modal>
{#snippet trigger()}
<Button className="bg-text">Change Profile Picture</Button>
<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()}
<h1 class="border-surface-0 border-b py-3 text-center text-xl font-bold">
Change Profile Picture
</h1>
<form
onsubmit={async (e) => {
e.preventDefault();
@ -122,13 +108,14 @@
try {
const response = await UserClient.updateProfilePicture({
fileName: file.name,
data: data,
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);
@ -139,12 +126,7 @@
<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
id="file"
name="file"
type="file"
class="border-surface-0 rounded border p-2 text-sm"
/>
<Input name="file" type="file" />
</div>
<Button type="submit">Submit</Button>
</div>
@ -178,30 +160,15 @@
<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
id="old-password"
name="old-password"
type="password"
class="border-surface-0 rounded border p-2 text-sm"
/>
<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
id="new-password"
name="new-password"
type="password"
class="border-surface-0 rounded border p-2 text-sm"
/>
<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
id="confirm-password"
name="confirm-password"
type="password"
class="border-surface-0 rounded border p-2 text-sm"
/>
<Input name="confirm-password" type="password" />
</div>
<Button type="submit">Submit</Button>
</div>

View File

@ -6,6 +6,7 @@
import { ConnectError } from '@connectrpc/connect';
import { toast } from 'svelte-sonner';
import Button from '$lib/ui/Button.svelte';
import Input from '$lib/ui/Input.svelte';
let tab = $state('login');
</script>
@ -59,21 +60,11 @@
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label for="login-username" class="text-sm">Username</label>
<input
id="login-username"
name="login-username"
type="text"
class="border-surface-0 rounded border p-2 text-sm"
/>
<Input name="login-username" />
</div>
<div class="flex flex-col gap-1">
<label for="login-password" class="text-sm">Password</label>
<input
id="login-password"
name="login-password"
type="password"
class="border-surface-0 rounded border p-2 text-sm"
/>
<Input name="login-password" type="password" />
</div>
<Button type="submit">Submit</Button>
</div>
@ -108,30 +99,15 @@
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label for="signup-username" class="text-sm">Username</label>
<input
id="signup-username"
name="signup-username"
type="text"
class="border-surface-0 rounded border p-2 text-sm"
/>
<Input name="signup-username" />
</div>
<div class="flex flex-col gap-1">
<label for="signup-password" class="text-sm">Password</label>
<input
id="signup-password"
name="signup-password"
type="password"
class="border-surface-0 rounded border p-2 text-sm"
/>
<Input name="signup-password" />
</div>
<div class="flex flex-col gap-1">
<label for="signup-confirm-password" class="text-sm">Confirm Password</label>
<input
id="signup-confirm-password"
name="signup-confirm-password"
type="password"
class="border-surface-0 rounded border p-2 text-sm"
/>
<Input name="signup-confirm-password" />
</div>
<Button type="submit">Submit</Button>
</div>