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

@ -0,0 +1,5 @@
import type { User } from "./services/user/v1/user_pb"
export let userState: { user: User | undefined } = $state({
user: undefined
});

View 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>

View File

@ -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}

View File

@ -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}

View 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>

View File

@ -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>

View 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}

View 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>