feat: cards
This commit is contained in:
5
client/src/lib/sharedState.svelte.ts
Normal file
5
client/src/lib/sharedState.svelte.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import type { User } from "./services/user/v1/user_pb"
|
||||
|
||||
export let userState: { user: User | undefined } = $state({
|
||||
user: undefined
|
||||
});
|
15
client/src/lib/ui/Avatar.svelte
Normal file
15
client/src/lib/ui/Avatar.svelte
Normal file
@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { userState } from '$lib/sharedState.svelte';
|
||||
import { Avatar } from 'bits-ui';
|
||||
</script>
|
||||
|
||||
<Avatar.Root class="flex h-full w-full items-center justify-center">
|
||||
<Avatar.Image
|
||||
src={userState.user?.profilePicture}
|
||||
alt={`${userState.user?.username}'s avatar`}
|
||||
class="rounded-full"
|
||||
/>
|
||||
<Avatar.Fallback class="font-medium uppercase"
|
||||
>{userState.user?.username.substring(0, 2)}</Avatar.Fallback
|
||||
>
|
||||
</Avatar.Root>
|
@ -20,7 +20,7 @@
|
||||
<Button.Root
|
||||
{type}
|
||||
class={cn(
|
||||
'bg-sky text-crust hover:brightness-120 w-fit cursor-pointer rounded p-2 px-4 text-sm font-medium transition-all',
|
||||
'bg-sky text-crust flex justify-center items-center hover:brightness-120 focus:outline-sky w-fit cursor-pointer rounded p-2 px-4 text-sm font-medium transition-all focus:outline-2 focus:outline-offset-1',
|
||||
className
|
||||
)}
|
||||
{onclick}
|
||||
|
@ -1,141 +1,170 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, ArrowRight, Minus, Calendar } from '@lucide/svelte';
|
||||
import { ArrowLeft, ArrowRight, Minus, Calendar, X } from '@lucide/svelte';
|
||||
import { DateRangePicker, type DateRange } from 'bits-ui';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { getLocalTimeZone } from '@internationalized/date';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
let {
|
||||
className,
|
||||
start = $bindable(),
|
||||
end = $bindable(),
|
||||
onchange
|
||||
}: {
|
||||
className?: string;
|
||||
start?: Date;
|
||||
end?: Date;
|
||||
onchange?: (start: Date, end: Date) => void;
|
||||
onchange?: (start?: Date, end?: Date) => void;
|
||||
} = $props();
|
||||
|
||||
let daterange: DateRange | undefined = $state();
|
||||
let daterange: DateRange = $state({
|
||||
start: undefined,
|
||||
end: undefined
|
||||
});
|
||||
let rerender = $state(false);
|
||||
</script>
|
||||
|
||||
<DateRangePicker.Root
|
||||
bind:value={daterange}
|
||||
onValueChange={(v) => {
|
||||
if (v.start && v.end) {
|
||||
start = v.start.toDate(getLocalTimeZone());
|
||||
end = v.end.toDate(getLocalTimeZone());
|
||||
if (onchange) {
|
||||
onchange(start, end);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="bg-mantle border-surface-0 hover:border-surface-2 flex items-center justify-center gap-2 rounded border p-1 text-sm drop-shadow-md transition-all"
|
||||
<!-- Need to rerender because setting to undefined doesn't work -->
|
||||
{#key rerender}
|
||||
<DateRangePicker.Root
|
||||
bind:value={daterange}
|
||||
onValueChange={(v) => {
|
||||
if (v.start && v.end) {
|
||||
start = v.start.toDate(getLocalTimeZone());
|
||||
end = v.end.toDate(getLocalTimeZone());
|
||||
if (onchange) {
|
||||
onchange(start, end);
|
||||
}
|
||||
}
|
||||
}}
|
||||
class={cn(className)}
|
||||
>
|
||||
<DateRangePicker.Label />
|
||||
{#each ['start', 'end'] as const as type}
|
||||
<DateRangePicker.Input {type}>
|
||||
{#snippet children({ segments })}
|
||||
{#each segments as { part, value }}
|
||||
<div class="inline-block select-none">
|
||||
{#if part === 'literal'}
|
||||
<DateRangePicker.Segment {part} class="text-overlay-0 p-1">
|
||||
{value}
|
||||
</DateRangePicker.Segment>
|
||||
{:else}
|
||||
<DateRangePicker.Segment
|
||||
{part}
|
||||
class="aria-[valuetext=Empty]:text-overlay-0 hover:bg-surface-0 focus:bg-surface-0 focus:outline-sky rounded p-0.5 transition-all focus:outline focus:outline-offset-1"
|
||||
>
|
||||
{value}
|
||||
</DateRangePicker.Segment>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</DateRangePicker.Input>
|
||||
{#if type === 'start'}
|
||||
<div aria-hidden="true">
|
||||
<Minus size="10" />
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
<DateRangePicker.Trigger
|
||||
class="text-overlay-2 hover:bg-surface-0 ml-1 cursor-pointer rounded p-1 transition-all"
|
||||
<div
|
||||
class="bg-mantle border-surface-0 hover:border-surface-2 flex items-center rounded border pl-2 text-sm drop-shadow-md transition-all"
|
||||
>
|
||||
<Calendar size="20" />
|
||||
</DateRangePicker.Trigger>
|
||||
</div>
|
||||
<DateRangePicker.Content forceMount>
|
||||
{#snippet child({ props, open })}
|
||||
{#if open}
|
||||
<div
|
||||
{...props}
|
||||
class="absolute z-50"
|
||||
transition:fade={{
|
||||
duration: 100
|
||||
}}
|
||||
>
|
||||
<DateRangePicker.Calendar
|
||||
class="border-surface-0 bg-mantle mt-1 rounded border p-3 drop-shadow-md"
|
||||
<div class="grow flex items-center justify-center">
|
||||
{#each ['start', 'end'] as const as type}
|
||||
<DateRangePicker.Input {type}>
|
||||
{#snippet children({ segments })}
|
||||
{#each segments as { part, value }}
|
||||
<div class="inline-block select-none">
|
||||
{#if part === 'literal'}
|
||||
<DateRangePicker.Segment {part} class="text-overlay-0 p-1">
|
||||
{value}
|
||||
</DateRangePicker.Segment>
|
||||
{:else}
|
||||
<DateRangePicker.Segment
|
||||
{part}
|
||||
class="aria-[valuetext=Empty]:text-overlay-0 hover:bg-surface-0 focus:bg-surface-0 focus:outline-sky rounded p-0.5 transition-all focus:outline focus:outline-offset-1"
|
||||
>
|
||||
{value}
|
||||
</DateRangePicker.Segment>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</DateRangePicker.Input>
|
||||
{#if type === 'start'}
|
||||
<div aria-hidden="true" class="px-1">
|
||||
<Minus size="10" />
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<DateRangePicker.Trigger
|
||||
class="text-overlay-2 hover:bg-surface-0 grow flex justify-center items-center focus:outline-sky ml-1 cursor-pointer p-2 transition-all focus:outline focus:outline-offset-1"
|
||||
>
|
||||
<Calendar size="20" />
|
||||
</DateRangePicker.Trigger>
|
||||
<button
|
||||
class="text-overlay-2 hover:bg-surface-0 focus:outline-sky cursor-pointer rounded-r p-2 transition-all focus:outline focus:outline-offset-1"
|
||||
onclick={() => {
|
||||
if (daterange) {
|
||||
daterange.end = undefined;
|
||||
daterange.start = undefined;
|
||||
}
|
||||
start = undefined;
|
||||
end = undefined;
|
||||
if (onchange) {
|
||||
onchange(start, end);
|
||||
}
|
||||
rerender = !rerender;
|
||||
}}
|
||||
>
|
||||
<X size="20" />
|
||||
</button>
|
||||
</div>
|
||||
<DateRangePicker.Content forceMount>
|
||||
{#snippet child({ props, open })}
|
||||
{#if open}
|
||||
<div
|
||||
{...props}
|
||||
class="absolute z-50"
|
||||
transition:fade={{
|
||||
duration: 100
|
||||
}}
|
||||
>
|
||||
{#snippet children({ months, weekdays })}
|
||||
<DateRangePicker.Header class="flex items-center justify-between">
|
||||
<DateRangePicker.PrevButton
|
||||
class="hover:bg-surface-0 inline-flex size-10 cursor-pointer items-center justify-center rounded transition-all active:scale-[0.98]"
|
||||
>
|
||||
<ArrowLeft />
|
||||
</DateRangePicker.PrevButton>
|
||||
<DateRangePicker.Heading class="select-none font-medium" />
|
||||
<DateRangePicker.NextButton
|
||||
class="hover:bg-surface-0 inline-flex size-10 cursor-pointer items-center justify-center rounded transition-all active:scale-[0.98]"
|
||||
>
|
||||
<ArrowRight />
|
||||
</DateRangePicker.NextButton>
|
||||
</DateRangePicker.Header>
|
||||
<div class="flex flex-col space-y-4 pt-4 sm:flex-row sm:space-x-4 sm:space-y-0">
|
||||
{#each months as month}
|
||||
<DateRangePicker.Grid class="w-full border-collapse select-none space-y-1">
|
||||
<DateRangePicker.GridHead>
|
||||
<DateRangePicker.GridRow class="mb-1 flex w-full justify-between">
|
||||
{#each weekdays as day}
|
||||
<DateRangePicker.HeadCell
|
||||
class="text-overlay-0 font-normal! w-10 rounded text-xs"
|
||||
>
|
||||
{day.slice(0, 2)}
|
||||
</DateRangePicker.HeadCell>
|
||||
{/each}
|
||||
</DateRangePicker.GridRow>
|
||||
</DateRangePicker.GridHead>
|
||||
<DateRangePicker.GridBody>
|
||||
{#each month.weeks as weekDates}
|
||||
<DateRangePicker.GridRow class="flex w-full">
|
||||
{#each weekDates as date}
|
||||
<DateRangePicker.Cell
|
||||
{date}
|
||||
month={month.value}
|
||||
class="p-0! relative m-0 size-10 overflow-visible text-center text-sm focus-within:relative focus-within:z-20"
|
||||
<DateRangePicker.Calendar
|
||||
class="border-surface-0 bg-mantle mt-1 rounded border p-3 drop-shadow-md"
|
||||
>
|
||||
{#snippet children({ months, weekdays })}
|
||||
<DateRangePicker.Header class="flex items-center justify-between">
|
||||
<DateRangePicker.PrevButton
|
||||
class="hover:bg-surface-0 inline-flex size-10 cursor-pointer items-center justify-center rounded transition-all active:scale-[0.98]"
|
||||
>
|
||||
<ArrowLeft />
|
||||
</DateRangePicker.PrevButton>
|
||||
<DateRangePicker.Heading class="select-none font-medium" />
|
||||
<DateRangePicker.NextButton
|
||||
class="hover:bg-surface-0 inline-flex size-10 cursor-pointer items-center justify-center rounded transition-all active:scale-[0.98]"
|
||||
>
|
||||
<ArrowRight />
|
||||
</DateRangePicker.NextButton>
|
||||
</DateRangePicker.Header>
|
||||
<div class="flex flex-col space-y-4 pt-4 sm:flex-row sm:space-x-4 sm:space-y-0">
|
||||
{#each months as month}
|
||||
<DateRangePicker.Grid class="w-full border-collapse select-none space-y-1">
|
||||
<DateRangePicker.GridHead>
|
||||
<DateRangePicker.GridRow class="mb-1 flex w-full justify-between">
|
||||
{#each weekdays as day}
|
||||
<DateRangePicker.HeadCell
|
||||
class="text-overlay-0 font-normal! w-10 rounded text-xs"
|
||||
>
|
||||
<DateRangePicker.Day
|
||||
class={'hover:border-sky focus-visible:ring-foreground! data-selected:rounded-none data-selection-end:rounded-r data-selection-start:rounded-l data-highlighted:bg-surface-0 data-selected:bg-surface-1 data-selection-end:bg-surface-2 data-selection-start:bg-surface-2 data-disabled:text-text/30 data-unavailable:text-overlay-0 data-disabled:pointer-events-none data-outside-month:pointer-events-none data-highlighted:rounded-none data-unavailable:line-through group relative inline-flex size-10 items-center justify-center overflow-visible whitespace-nowrap rounded border border-transparent bg-transparent p-0 text-sm font-normal transition-all'}
|
||||
>
|
||||
<div
|
||||
class="bg-sky group-data-selected:bg-background group-data-today:block absolute top-[5px] hidden size-1 rounded-full transition-all"
|
||||
></div>
|
||||
{date.day}
|
||||
</DateRangePicker.Day>
|
||||
</DateRangePicker.Cell>
|
||||
{day.slice(0, 2)}
|
||||
</DateRangePicker.HeadCell>
|
||||
{/each}
|
||||
</DateRangePicker.GridRow>
|
||||
{/each}
|
||||
</DateRangePicker.GridBody>
|
||||
</DateRangePicker.Grid>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
</DateRangePicker.Calendar>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DateRangePicker.Content>
|
||||
</DateRangePicker.Root>
|
||||
</DateRangePicker.GridHead>
|
||||
<DateRangePicker.GridBody>
|
||||
{#each month.weeks as weekDates}
|
||||
<DateRangePicker.GridRow class="flex w-full">
|
||||
{#each weekDates as date}
|
||||
<DateRangePicker.Cell
|
||||
{date}
|
||||
month={month.value}
|
||||
class="p-0! relative m-0 size-10 overflow-visible text-center text-sm focus-within:relative focus-within:z-20"
|
||||
>
|
||||
<DateRangePicker.Day
|
||||
class={'hover:border-sky focus-visible:ring-foreground! data-selected:rounded-none data-selection-end:rounded-r data-selection-start:rounded-l data-highlighted:bg-surface-0 data-selected:bg-surface-1 data-selection-end:bg-surface-2 data-selection-start:bg-surface-2 data-disabled:text-text/30 data-unavailable:text-overlay-0 data-disabled:pointer-events-none data-outside-month:pointer-events-none data-highlighted:rounded-none data-unavailable:line-through group relative inline-flex size-10 items-center justify-center overflow-visible whitespace-nowrap rounded border border-transparent bg-transparent p-0 text-sm font-normal transition-all'}
|
||||
>
|
||||
<div
|
||||
class="bg-sky group-data-selected:bg-background group-data-today:block absolute top-[5px] hidden size-1 rounded-full transition-all"
|
||||
></div>
|
||||
{date.day}
|
||||
</DateRangePicker.Day>
|
||||
</DateRangePicker.Cell>
|
||||
{/each}
|
||||
</DateRangePicker.GridRow>
|
||||
{/each}
|
||||
</DateRangePicker.GridBody>
|
||||
</DateRangePicker.Grid>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
</DateRangePicker.Calendar>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DateRangePicker.Content>
|
||||
</DateRangePicker.Root>
|
||||
{/key}
|
||||
|
51
client/src/lib/ui/Input.svelte
Normal file
51
client/src/lib/ui/Input.svelte
Normal file
@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils';
|
||||
import { X } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
name,
|
||||
value = $bindable(''),
|
||||
type = 'text',
|
||||
placeholder,
|
||||
className,
|
||||
onchange
|
||||
}: {
|
||||
name?: string;
|
||||
value?: string | number;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
onchange?: (e: Event) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'border-surface-0 hover:border-surface-2 flex items-center justify-between gap-1 rounded border p-0 drop-shadow-md transition-all',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<input
|
||||
id={name}
|
||||
{name}
|
||||
{type}
|
||||
{placeholder}
|
||||
class="focus:outline-sky grow rounded-l p-2 text-sm transition-all focus:outline focus:outline-offset-1"
|
||||
bind:value
|
||||
{onchange}
|
||||
/>
|
||||
<button
|
||||
class="text-overlay-2 hover:bg-surface-0 focus:outline-sky cursor-pointer rounded-r p-2 transition-all focus:outline focus:outline-offset-1"
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
if (value) {
|
||||
value = '';
|
||||
if (onchange) {
|
||||
onchange(e);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<X size="20" />
|
||||
</button>
|
||||
</div>
|
@ -1,18 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { X } from '@lucide/svelte';
|
||||
import { Dialog } from 'bits-ui';
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
trigger,
|
||||
title,
|
||||
content,
|
||||
open = $bindable(false)
|
||||
}: { trigger: Snippet; content: Snippet; open?: boolean } = $props();
|
||||
}: {
|
||||
trigger: Snippet<[Record<string, unknown>]>;
|
||||
title: Snippet;
|
||||
content: Snippet;
|
||||
open?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Trigger>
|
||||
{@render trigger()}
|
||||
{#snippet child({ props })}
|
||||
{@render trigger(props)}
|
||||
{/snippet}
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay forceMount>
|
||||
@ -30,8 +39,8 @@
|
||||
{/snippet}
|
||||
</Dialog.Overlay>
|
||||
<Dialog.Content forceMount>
|
||||
{#snippet child({ props, open })}
|
||||
{#if open}
|
||||
{#snippet child({ props, open: propopen })}
|
||||
{#if propopen}
|
||||
<div
|
||||
{...props}
|
||||
transition:fade={{
|
||||
@ -41,6 +50,20 @@
|
||||
<div
|
||||
class="bg-mantle border-surface-0 fixed inset-0 left-[50%] top-[50%] z-50 size-fit w-96 -translate-x-1/2 -translate-y-1/2 transform overflow-y-auto rounded-xl border pb-1 drop-shadow-md"
|
||||
>
|
||||
<div class="border-surface-0 flex justify-between border-b p-2">
|
||||
<h1 class="grow truncate p-1 text-center text-xl font-bold">
|
||||
{@render title()}
|
||||
</h1>
|
||||
<button
|
||||
tabindex="-1"
|
||||
class="text-overlay-2 hover:bg-surface-0 focus:outline-sky cursor-pointer rounded p-1 transition-all focus:outline focus:outline-offset-1"
|
||||
onclick={() => {
|
||||
open = false;
|
||||
}}
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
{@render content()}
|
||||
</div>
|
||||
</div>
|
||||
|
63
client/src/lib/ui/Pagination.svelte
Normal file
63
client/src/lib/ui/Pagination.svelte
Normal file
@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils';
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
|
||||
import { Pagination } from 'bits-ui';
|
||||
|
||||
let {
|
||||
count = $bindable(),
|
||||
limit = $bindable(),
|
||||
offset = $bindable(0),
|
||||
className,
|
||||
onchange
|
||||
}: {
|
||||
count: number;
|
||||
limit: number;
|
||||
offset?: number;
|
||||
className?: string;
|
||||
onchange?: (e: number) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#key count && limit}
|
||||
<Pagination.Root
|
||||
{count}
|
||||
perPage={limit}
|
||||
onPageChange={(e) => {
|
||||
offset = (e - 1) * limit;
|
||||
window.scrollTo(0, 0);
|
||||
onchange?.(e);
|
||||
}}
|
||||
>
|
||||
{#snippet children({ pages, range })}
|
||||
<div class={cn('mb-2 flex items-center justify-center gap-2', className)}>
|
||||
<Pagination.PrevButton
|
||||
class="hover:bg-surface-0 disabled:text-overlay-0 inline-flex cursor-pointer items-center justify-center rounded p-2 transition-all disabled:cursor-not-allowed hover:disabled:bg-transparent"
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Pagination.PrevButton>
|
||||
<div class="flex items-center gap-2">
|
||||
{#each pages as page (page.key)}
|
||||
{#if page.type === 'ellipsis'}
|
||||
<div class="select-none font-medium">...</div>
|
||||
{:else}
|
||||
<Pagination.Page
|
||||
{page}
|
||||
class="hover:bg-surface-0 data-selected:bg-surface-0 data-selected:text-background inline-flex size-10 cursor-pointer select-none items-center justify-center rounded bg-transparent font-medium transition-all disabled:cursor-not-allowed disabled:opacity-50 hover:disabled:bg-transparent"
|
||||
>
|
||||
{page.value}
|
||||
</Pagination.Page>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<Pagination.NextButton
|
||||
class="hover:bg-surface-0 disabled:text-overlay-0 inline-flex cursor-pointer items-center justify-center rounded p-2 transition-all disabled:cursor-not-allowed hover:disabled:bg-transparent"
|
||||
>
|
||||
<ChevronRight />
|
||||
</Pagination.NextButton>
|
||||
</div>
|
||||
<p class="text-overlay-2 text-center text-sm">
|
||||
Showing {range.start} - {range.end}
|
||||
</p>
|
||||
{/snippet}
|
||||
</Pagination.Root>
|
||||
{/key}
|
82
client/src/lib/ui/Select.svelte
Normal file
82
client/src/lib/ui/Select.svelte
Normal file
@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils';
|
||||
import { Check, ChevronsDown, ChevronsUp, ChevronsUpDown, X } from '@lucide/svelte';
|
||||
import { Select } from 'bits-ui';
|
||||
|
||||
let {
|
||||
value = $bindable('10'),
|
||||
placeholder = 'Select an item',
|
||||
items = [],
|
||||
defaultValue = '',
|
||||
className,
|
||||
onchange
|
||||
}: {
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
items: { value: string; label: string; disabled?: boolean }[];
|
||||
defaultValue?: string;
|
||||
className?: string;
|
||||
onchange?: (e: string) => void;
|
||||
} = $props();
|
||||
|
||||
const selectedLabel = $derived(value ? items.find((i) => i.value === value)?.label : placeholder);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'border-surface-0 bg-mantle hover:border-surface-2 flex items-center justify-between rounded border p-0 drop-shadow-md transition-all',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Select.Root type="single" {items} bind:value onValueChange={onchange}>
|
||||
<Select.Trigger
|
||||
class="focus:outline-sky data-placeholder:text-overlay-0 gap-2 inline-flex grow cursor-pointer select-none items-center justify-between rounded-l py-2 pl-2 text-sm transition-colors focus:outline focus:outline-offset-1"
|
||||
aria-label={placeholder}
|
||||
>
|
||||
{selectedLabel}
|
||||
<ChevronsUpDown class="text-overlay-0" size="20" />
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Content
|
||||
class="focus-override border-surface-0 bg-mantle shadow-popover data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 outline-hidden z-50 select-none rounded border p-1"
|
||||
sideOffset={10}
|
||||
>
|
||||
<Select.ScrollUpButton class="flex w-full items-center justify-center">
|
||||
<ChevronsUp size="20" />
|
||||
</Select.ScrollUpButton>
|
||||
<Select.Viewport class="p-1">
|
||||
{#each items as item, i (i + item.value)}
|
||||
<Select.Item
|
||||
class="data-disabled:cursor-not-allowed data-highlighted:bg-surface-0 outline-hidden data-disabled:opacity-50 flex h-10 w-full cursor-pointer select-none items-center gap-4 rounded px-5 py-3 text-sm capitalize"
|
||||
value={item.value}
|
||||
label={item.label}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{#snippet children({ selected })}
|
||||
{item.label}
|
||||
{#if selected}
|
||||
<div class="ml-auto">
|
||||
<Check size="20" />
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Viewport>
|
||||
<Select.ScrollDownButton class="flex w-full items-center justify-center">
|
||||
<ChevronsDown size="20" />
|
||||
</Select.ScrollDownButton>
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
<button
|
||||
class="text-overlay-2 hover:bg-surface-0 focus:outline-sky cursor-pointer rounded-r p-2 transition-all focus:outline focus:outline-offset-1"
|
||||
type="button"
|
||||
onclick={() => {
|
||||
value = defaultValue;
|
||||
onchange?.(value);
|
||||
}}
|
||||
>
|
||||
<X size="20" />
|
||||
</button>
|
||||
</div>
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
Reference in New Issue
Block a user