summaryrefslogtreecommitdiff
path: root/frontend/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/lib')
-rw-r--r--frontend/src/lib/components/ui/alert/alert-action.svelte20
-rw-r--r--frontend/src/lib/components/ui/alert/alert-description.svelte23
-rw-r--r--frontend/src/lib/components/ui/alert/alert-title.svelte23
-rw-r--r--frontend/src/lib/components/ui/alert/alert.svelte43
-rw-r--r--frontend/src/lib/components/ui/alert/index.ts17
-rw-r--r--frontend/src/lib/components/ui/badge/badge.svelte49
-rw-r--r--frontend/src/lib/components/ui/badge/index.ts2
-rw-r--r--frontend/src/lib/components/ui/button/button.svelte82
-rw-r--r--frontend/src/lib/components/ui/button/index.ts17
-rw-r--r--frontend/src/lib/components/ui/card/card-action.svelte23
-rw-r--r--frontend/src/lib/components/ui/card/card-content.svelte20
-rw-r--r--frontend/src/lib/components/ui/card/card-description.svelte20
-rw-r--r--frontend/src/lib/components/ui/card/card-footer.svelte20
-rw-r--r--frontend/src/lib/components/ui/card/card-header.svelte23
-rw-r--r--frontend/src/lib/components/ui/card/card-title.svelte20
-rw-r--r--frontend/src/lib/components/ui/card/card.svelte22
-rw-r--r--frontend/src/lib/components/ui/card/index.ts25
-rw-r--r--frontend/src/lib/components/ui/chart/chart-container.svelte80
-rw-r--r--frontend/src/lib/components/ui/chart/chart-style.svelte37
-rw-r--r--frontend/src/lib/components/ui/chart/chart-tooltip.svelte180
-rw-r--r--frontend/src/lib/components/ui/chart/chart-utils.ts68
-rw-r--r--frontend/src/lib/components/ui/chart/index.ts6
-rw-r--r--frontend/src/lib/components/ui/input/index.ts7
-rw-r--r--frontend/src/lib/components/ui/input/input.svelte48
-rw-r--r--frontend/src/lib/components/ui/label/index.ts7
-rw-r--r--frontend/src/lib/components/ui/label/label.svelte20
-rw-r--r--frontend/src/lib/components/ui/select/index.ts40
-rw-r--r--frontend/src/lib/components/ui/select/select-content.svelte45
-rw-r--r--frontend/src/lib/components/ui/select/select-group-heading.svelte21
-rw-r--r--frontend/src/lib/components/ui/select/select-group.svelte17
-rw-r--r--frontend/src/lib/components/ui/select/select-item.svelte42
-rw-r--r--frontend/src/lib/components/ui/select/select-label.svelte20
-rw-r--r--frontend/src/lib/components/ui/select/select-portal.svelte7
-rw-r--r--frontend/src/lib/components/ui/select/select-scroll-down-button.svelte20
-rw-r--r--frontend/src/lib/components/ui/select/select-scroll-up-button.svelte20
-rw-r--r--frontend/src/lib/components/ui/select/select-separator.svelte18
-rw-r--r--frontend/src/lib/components/ui/select/select-trigger.svelte29
-rw-r--r--frontend/src/lib/components/ui/select/select-value.svelte7
-rw-r--r--frontend/src/lib/components/ui/select/select.svelte11
-rw-r--r--frontend/src/lib/components/ui/separator/index.ts7
-rw-r--r--frontend/src/lib/components/ui/separator/separator.svelte21
-rw-r--r--frontend/src/lib/components/ui/table/index.ts28
-rw-r--r--frontend/src/lib/components/ui/table/table-body.svelte15
-rw-r--r--frontend/src/lib/components/ui/table/table-caption.svelte20
-rw-r--r--frontend/src/lib/components/ui/table/table-cell.svelte15
-rw-r--r--frontend/src/lib/components/ui/table/table-footer.svelte20
-rw-r--r--frontend/src/lib/components/ui/table/table-head.svelte15
-rw-r--r--frontend/src/lib/components/ui/table/table-header.svelte20
-rw-r--r--frontend/src/lib/components/ui/table/table-row.svelte20
-rw-r--r--frontend/src/lib/components/ui/table/table.svelte17
-rw-r--r--frontend/src/lib/components/ui/tabs/index.ts18
-rw-r--r--frontend/src/lib/components/ui/tabs/tabs-content.svelte17
-rw-r--r--frontend/src/lib/components/ui/tabs/tabs-list.svelte40
-rw-r--r--frontend/src/lib/components/ui/tabs/tabs-trigger.svelte23
-rw-r--r--frontend/src/lib/components/ui/tabs/tabs.svelte19
-rw-r--r--frontend/src/lib/utils.ts13
56 files changed, 1527 insertions, 0 deletions
diff --git a/frontend/src/lib/components/ui/alert/alert-action.svelte b/frontend/src/lib/components/ui/alert/alert-action.svelte
new file mode 100644
index 0000000..c60d787
--- /dev/null
+++ b/frontend/src/lib/components/ui/alert/alert-action.svelte
@@ -0,0 +1,20 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from "$lib/utils.js";
+ import type { HTMLAttributes } from "svelte/elements";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="alert-action"
+ class={cn("absolute top-1.5 right-2", className)}
+ {...restProps}
+>
+ {@render children?.()}
+</div>
diff --git a/frontend/src/lib/components/ui/alert/alert-description.svelte b/frontend/src/lib/components/ui/alert/alert-description.svelte
new file mode 100644
index 0000000..c5c6223
--- /dev/null
+++ b/frontend/src/lib/components/ui/alert/alert-description.svelte
@@ -0,0 +1,23 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from "$lib/utils.js";
+ import type { HTMLAttributes } from "svelte/elements";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="alert-description"
+ class={cn(
+ "text-muted-foreground text-xs/relaxed text-balance md:text-pretty [&_p:not(:last-child)]:mb-4 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+</div>
diff --git a/frontend/src/lib/components/ui/alert/alert-title.svelte b/frontend/src/lib/components/ui/alert/alert-title.svelte
new file mode 100644
index 0000000..371164e
--- /dev/null
+++ b/frontend/src/lib/components/ui/alert/alert-title.svelte
@@ -0,0 +1,23 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from "$lib/utils.js";
+ import type { HTMLAttributes } from "svelte/elements";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="alert-title"
+ class={cn(
+ "font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+</div>
diff --git a/frontend/src/lib/components/ui/alert/alert.svelte b/frontend/src/lib/components/ui/alert/alert.svelte
new file mode 100644
index 0000000..e8f3489
--- /dev/null
+++ b/frontend/src/lib/components/ui/alert/alert.svelte
@@ -0,0 +1,43 @@
+<script lang="ts" module>
+ import { type VariantProps, tv } from "tailwind-variants";
+
+ export const alertVariants = tv({
+ base: "grid gap-0.5 rounded-lg border px-2 py-1.5 text-left text-xs/relaxed has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-1.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-3.5 group/alert relative w-full",
+ variants: {
+ variant: {
+ default: "bg-card text-card-foreground",
+ destructive: "text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ });
+
+ export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
+</script>
+
+<script lang="ts">
+ import { cn, type WithElementRef } from "$lib/utils.js";
+ import type { HTMLAttributes } from "svelte/elements";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ variant = "default",
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
+ variant?: AlertVariant;
+ } = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="alert"
+ role="alert"
+ class={cn(alertVariants({ variant }), className)}
+ {...restProps}
+>
+ {@render children?.()}
+</div>
diff --git a/frontend/src/lib/components/ui/alert/index.ts b/frontend/src/lib/components/ui/alert/index.ts
new file mode 100644
index 0000000..501249b
--- /dev/null
+++ b/frontend/src/lib/components/ui/alert/index.ts
@@ -0,0 +1,17 @@
+import Action from "./alert-action.svelte";
+import Description from "./alert-description.svelte";
+import Title from "./alert-title.svelte";
+import Root from "./alert.svelte";
+export { alertVariants, type AlertVariant } from "./alert.svelte";
+
+export {
+ Root,
+ Description,
+ Title,
+ Action,
+ //
+ Root as Alert,
+ Description as AlertDescription,
+ Title as AlertTitle,
+ Action as AlertAction,
+};
diff --git a/frontend/src/lib/components/ui/badge/badge.svelte b/frontend/src/lib/components/ui/badge/badge.svelte
new file mode 100644
index 0000000..0b34af3
--- /dev/null
+++ b/frontend/src/lib/components/ui/badge/badge.svelte
@@ -0,0 +1,49 @@
+<script lang="ts" module>
+ import { type VariantProps, tv } from "tailwind-variants";
+
+ export const badgeVariants = tv({
+ base: "h-5 gap-1 rounded-full border border-transparent px-2 py-0.5 text-[0.625rem] font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-2.5! group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none",
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
+ destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
+ outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground bg-input/20 dark:bg-input/30",
+ ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ });
+
+ export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
+</script>
+
+<script lang="ts">
+ import { cn, type WithElementRef } from "$lib/utils.js";
+ import type { HTMLAnchorAttributes } from "svelte/elements";
+
+ let {
+ ref = $bindable(null),
+ href,
+ class: className,
+ variant = "default",
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAnchorAttributes> & {
+ variant?: BadgeVariant;
+ } = $props();
+</script>
+
+<svelte:element
+ this={href ? "a" : "span"}
+ bind:this={ref}
+ data-slot="badge"
+ {href}
+ class={cn(badgeVariants({ variant }), className)}
+ {...restProps}
+>
+ {@render children?.()}
+</svelte:element>
diff --git a/frontend/src/lib/components/ui/badge/index.ts b/frontend/src/lib/components/ui/badge/index.ts
new file mode 100644
index 0000000..64e0aa9
--- /dev/null
+++ b/frontend/src/lib/components/ui/badge/index.ts
@@ -0,0 +1,2 @@
+export { default as Badge } from "./badge.svelte";
+export { badgeVariants, type BadgeVariant } from "./badge.svelte";
diff --git a/frontend/src/lib/components/ui/button/button.svelte b/frontend/src/lib/components/ui/button/button.svelte
new file mode 100644
index 0000000..7669a5e
--- /dev/null
+++ b/frontend/src/lib/components/ui/button/button.svelte
@@ -0,0 +1,82 @@
+<script lang="ts" module>
+ import { type VariantProps, tv } from "tailwind-variants";
+ import { cn, type WithElementRef } from "$lib/utils.js";
+ import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
+
+ export const buttonVariants = tv({
+ base: "focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium focus-visible:ring-2 aria-invalid:ring-2 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/80",
+ outline: "border-border dark:bg-input/30 hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
+ secondary: "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+ ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
+ destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-7 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+ xs: "h-5 gap-1 rounded-sm px-2 text-[0.625rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-2.5",
+ sm: "h-6 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
+ lg: "h-8 gap-1 px-2.5 text-xs/relaxed has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-4",
+ icon: "size-7 [&_svg:not([class*='size-'])]:size-3.5",
+ "icon-xs": "size-5 rounded-sm [&_svg:not([class*='size-'])]:size-2.5",
+ "icon-sm": "size-6 [&_svg:not([class*='size-'])]:size-3",
+ "icon-lg": "size-8 [&_svg:not([class*='size-'])]:size-4",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ });
+
+ export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
+ export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
+
+ export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
+ WithElementRef<HTMLAnchorAttributes> & {
+ variant?: ButtonVariant;
+ size?: ButtonSize;
+ };
+</script>
+
+<script lang="ts">
+ let {
+ class: className,
+ variant = "default",
+ size = "default",
+ ref = $bindable(null),
+ href = undefined,
+ type = "button",
+ disabled,
+ children,
+ ...restProps
+ }: ButtonProps = $props();
+</script>
+
+{#if href}
+ <a
+ bind:this={ref}
+ data-slot="button"
+ class={cn(buttonVariants({ variant, size }), className)}
+ href={disabled ? undefined : href}
+ aria-disabled={disabled}
+ role={disabled ? "link" : undefined}
+ tabindex={disabled ? -1 : undefined}
+ {...restProps}
+ >
+ {@render children?.()}
+ </a>
+{:else}
+ <button
+ bind:this={ref}
+ data-slot="button"
+ class={cn(buttonVariants({ variant, size }), className)}
+ {type}
+ {disabled}
+ {...restProps}
+ >
+ {@render children?.()}
+ </button>
+{/if}
diff --git a/frontend/src/lib/components/ui/button/index.ts b/frontend/src/lib/components/ui/button/index.ts
new file mode 100644
index 0000000..fb585d7
--- /dev/null
+++ b/frontend/src/lib/components/ui/button/index.ts
@@ -0,0 +1,17 @@
+import Root, {
+ type ButtonProps,
+ type ButtonSize,
+ type ButtonVariant,
+ buttonVariants,
+} from "./button.svelte";
+
+export {
+ Root,
+ type ButtonProps as Props,
+ //
+ Root as Button,
+ buttonVariants,
+ type ButtonProps,
+ type ButtonSize,
+ type ButtonVariant,
+};
diff --git a/frontend/src/lib/components/ui/card/card-action.svelte b/frontend/src/lib/components/ui/card/card-action.svelte
new file mode 100644
index 0000000..53b3861
--- /dev/null
+++ b/frontend/src/lib/components/ui/card/card-action.svelte
@@ -0,0 +1,23 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from "$lib/utils.js";
+ import type { HTMLAttributes } from "svelte/elements";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="card-action"
+ class={cn(
+ "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+</div>
diff --git a/frontend/src/lib/components/ui/card/card-content.svelte b/frontend/src/lib/components/ui/card/card-content.svelte
new file mode 100644
index 0000000..bf5dac7
--- /dev/null
+++ b/frontend/src/lib/components/ui/card/card-content.svelte
@@ -0,0 +1,20 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from "$lib/utils.js";
+ import type { HTMLAttributes } from "svelte/elements";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="card-content"
+ class={cn("px-(--card-spacing)", className)}
+ {...restProps}
+>
+ {@render children?.()}
+</div>
diff --git a/frontend/src/lib/components/ui/card/card-description.svelte b/frontend/src/lib/components/ui/card/card-description.svelte
new file mode 100644
index 0000000..5d5c09a
--- /dev/null
+++ b/frontend/src/lib/components/ui/card/card-description.svelte
@@ -0,0 +1,20 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from "$lib/utils.js";
+ import type { HTMLAttributes } from "svelte/elements";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
+</script>
+
+<p
+ bind:this={ref}
+ data-slot="card-description"
+ class={cn("text-muted-foreground text-xs/relaxed", className)}
+ {...restProps}
+>
+ {@render children?.()}
+</p>
diff --git a/frontend/src/lib/components/ui/card/card-footer.svelte b/frontend/src/lib/components/ui/card/card-footer.svelte
new file mode 100644
index 0000000..ce4a2c0
--- /dev/null
+++ b/frontend/src/lib/components/ui/card/card-footer.svelte
@@ -0,0 +1,20 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from "$lib/utils.js";
+ import type { HTMLAttributes } from "svelte/elements";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="card-footer"
+ class={cn("rounded-b-lg px-(--card-spacing) [.border-t]:pt-(--card-spacing) flex items-center", className)}
+ {...restProps}
+>
+ {@render children?.()}
+</div>
diff --git a/frontend/src/lib/components/ui/card/card-header.svelte b/frontend/src/lib/components/ui/card/card-header.svelte
new file mode 100644
index 0000000..019a3f9
--- /dev/null
+++ b/frontend/src/lib/components/ui/card/card-header.svelte
@@ -0,0 +1,23 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from "$lib/utils.js";
+ import type { HTMLAttributes } from "svelte/elements";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="card-header"
+ class={cn(
+ "gap-1 rounded-t-lg px-(--card-spacing) [.border-b]:pb-(--card-spacing) group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+</div>
diff --git a/frontend/src/lib/components/ui/card/card-title.svelte b/frontend/src/lib/components/ui/card/card-title.svelte
new file mode 100644
index 0000000..d95ec39
--- /dev/null
+++ b/frontend/src/lib/components/ui/card/card-title.svelte
@@ -0,0 +1,20 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from "$lib/utils.js";
+ import type { HTMLAttributes } from "svelte/elements";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="card-title"
+ class={cn("text-sm font-medium", className)}
+ {...restProps}
+>
+ {@render children?.()}
+</div>
diff --git a/frontend/src/lib/components/ui/card/card.svelte b/frontend/src/lib/components/ui/card/card.svelte
new file mode 100644
index 0000000..345eaf2
--- /dev/null
+++ b/frontend/src/lib/components/ui/card/card.svelte
@@ -0,0 +1,22 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from "$lib/utils.js";
+ import type { HTMLAttributes } from "svelte/elements";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ size = "default",
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="card"
+ data-size={size}
+ class={cn("ring-foreground/10 bg-card text-card-foreground gap-(--card-spacing) overflow-hidden rounded-lg py-(--card-spacing) text-xs/relaxed ring-1 [--card-spacing:--spacing(4)] has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] *:[img:first-child]:rounded-t-lg *:[img:last-child]:rounded-b-lg group/card flex flex-col", className)}
+ {...restProps}
+>
+ {@render children?.()}
+</div>
diff --git a/frontend/src/lib/components/ui/card/index.ts b/frontend/src/lib/components/ui/card/index.ts
new file mode 100644
index 0000000..5705f13
--- /dev/null
+++ b/frontend/src/lib/components/ui/card/index.ts
@@ -0,0 +1,25 @@
+import Action from "./card-action.svelte";
+import Content from "./card-content.svelte";
+import Description from "./card-description.svelte";
+import Footer from "./card-footer.svelte";
+import Header from "./card-header.svelte";
+import Title from "./card-title.svelte";
+import Root from "./card.svelte";
+
+export {
+ Root,
+ Content,
+ Description,
+ Footer,
+ Header,
+ Title,
+ Action,
+ //
+ Root as Card,
+ Content as CardContent,
+ Description as CardDescription,
+ Footer as CardFooter,
+ Header as CardHeader,
+ Title as CardTitle,
+ Action as CardAction,
+};
diff --git a/frontend/src/lib/components/ui/chart/chart-container.svelte b/frontend/src/lib/components/ui/chart/chart-container.svelte
new file mode 100644
index 0000000..3687ea0
--- /dev/null
+++ b/frontend/src/lib/components/ui/chart/chart-container.svelte
@@ -0,0 +1,80 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from "$lib/utils.js";
+ import ChartStyle from "./chart-style.svelte";
+ import { setChartContext, type ChartConfig } from "./chart-utils.js";
+ import type { HTMLAttributes } from "svelte/elements";
+
+ const uid = $props.id();
+
+ let {
+ ref = $bindable(null),
+ id = uid,
+ class: className,
+ children,
+ config,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLElement>> & {
+ config: ChartConfig;
+ } = $props();
+
+ const chartId = $derived(`chart-${id || uid.replace(/:/g, "")}`);
+
+ setChartContext({
+ get config() {
+ return config;
+ },
+ });
+</script>
+
+<div
+ bind:this={ref}
+ data-chart={chartId}
+ data-slot="chart"
+ class={cn(
+ "flex aspect-video justify-center overflow-visible text-xs",
+ // Overrides
+ //
+ // Stroke around dots/marks when hovering
+ "[&_.lc-highlight-point]:stroke-transparent",
+ // override the default stroke color of lines
+ "[&_.lc-line]:stroke-border/50",
+
+ // by default, layerchart shows a line intersecting the point when hovering, this hides that
+ "[&_.lc-highlight-line]:stroke-0",
+
+ // by default, when you hover a point on a stacked series chart, it will drop the opacity
+ // of the other series, this overrides that
+ "[&_.lc-area-path]:opacity-100 [&_.lc-highlight-line]:opacity-100 [&_.lc-highlight-point]:opacity-100 [&_.lc-spline-path]:opacity-100 [&_.lc-text]:text-xs [&_.lc-text-svg]:overflow-visible",
+
+ // We don't want the little tick lines between the axis labels and the chart, so we remove
+ // the stroke. The alternative is to manually disable `tickMarks` on the x/y axis of every
+ // chart.
+ "[&_.lc-axis-tick]:stroke-0",
+
+ // We don't want to display the rule on the x/y axis, as there is already going to be
+ // a grid line there and rule ends up overlapping the marks because it is rendered after
+ // the marks
+ "[&_.lc-rule-x-line:not(.lc-grid-x-rule)]:stroke-0 [&_.lc-rule-y-line:not(.lc-grid-y-rule)]:stroke-0",
+ "[&_.lc-grid-x-radial-circle]:stroke-border [&_.lc-grid-x-radial-line]:stroke-border",
+ "[&_.lc-grid-y-radial-circle]:stroke-border [&_.lc-grid-y-radial-line]:stroke-border",
+
+ // Legend adjustments
+ "[&_.lc-legend-swatch-button]:items-center [&_.lc-legend-swatch-button]:gap-1.5",
+ "[&_.lc-legend-swatch-group]:items-center [&_.lc-legend-swatch-group]:gap-4",
+ "[&_.lc-legend-swatch]:size-2.5 [&_.lc-legend-swatch]:rounded-[2px]",
+
+ // Labels
+ "[&_.lc-labels-text:not([fill])]:fill-foreground [&_text]:stroke-transparent",
+
+ // Tick labels on th x/y axes
+ "[&_.lc-axis-tick-label]:fill-muted-foreground [&_.lc-axis-tick-label]:font-normal",
+ "[&_.lc-tooltip-rects-g]:fill-transparent",
+ "[&_.lc-layout-svg-g]:fill-transparent",
+ "[&_.lc-root-container]:w-full",
+ className
+ )}
+ {...restProps}
+>
+ <ChartStyle id={chartId} {config} />
+ {@render children?.()}
+</div>
diff --git a/frontend/src/lib/components/ui/chart/chart-style.svelte b/frontend/src/lib/components/ui/chart/chart-style.svelte
new file mode 100644
index 0000000..74c2b8a
--- /dev/null
+++ b/frontend/src/lib/components/ui/chart/chart-style.svelte
@@ -0,0 +1,37 @@
+<script lang="ts">
+ import { THEMES, type ChartConfig } from "./chart-utils.js";
+
+ let { id, config }: { id: string; config: ChartConfig } = $props();
+
+ const colorConfig = $derived(
+ config ? Object.entries(config).filter(([, config]) => config.theme || config.color) : null
+ );
+
+ const themeContents = $derived.by(() => {
+ if (!colorConfig || !colorConfig.length) return;
+
+ const themeContents = [];
+ for (const [_theme, prefix] of Object.entries(THEMES)) {
+ let content = `${prefix} [data-chart=${id}] {\n`;
+ const color = colorConfig.map(([key, itemConfig]) => {
+ const theme = _theme as keyof typeof itemConfig.theme;
+ const color = itemConfig.theme?.[theme] || itemConfig.color;
+ return color ? `\t--color-${key}: ${color};` : null;
+ });
+
+ content += color.join("\n") +"\n}";
+
+ themeContents.push(content);
+ }
+
+ return themeContents.join("\n");
+ });
+</script>
+
+{#if themeContents}
+ {#key id}
+ <svelte:element this={"style"}>
+ {themeContents}
+ </svelte:element>
+ {/key}
+{/if}
diff --git a/frontend/src/lib/components/ui/chart/chart-tooltip.svelte b/frontend/src/lib/components/ui/chart/chart-tooltip.svelte
new file mode 100644
index 0000000..8811436
--- /dev/null
+++ b/frontend/src/lib/components/ui/chart/chart-tooltip.svelte
@@ -0,0 +1,180 @@
+<script lang="ts">
+ import { getChartContext, Tooltip as TooltipPrimitive } from "layerchart";
+ import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
+ import { getPayloadConfigFromPayload, useChart, type TooltipPayload } from "./chart-utils.js";
+ import type { Snippet } from "svelte";
+ import type { HTMLAttributes } from "svelte/elements";
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ function defaultFormatter(value: any, _payload: TooltipPayload[]) {
+ return `${value}`;
+ }
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ hideLabel = false,
+ indicator = "dot",
+ hideIndicator = false,
+ labelKey,
+ label,
+ labelFormatter = defaultFormatter,
+ labelClassName,
+ formatter,
+ nameKey,
+ color,
+ ...restProps
+ }: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> & {
+ hideLabel?: boolean;
+ label?: string;
+ indicator?: "line" | "dot" | "dashed";
+ nameKey?: string;
+ labelKey?: string;
+ hideIndicator?: boolean;
+ labelClassName?: string;
+ labelFormatter?: // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ((value: any, payload: TooltipPayload[]) => string | number | Snippet) | null;
+ formatter?: Snippet<
+ [
+ {
+ value: unknown;
+ name: string;
+ item: TooltipPayload;
+ index: number;
+ payload: TooltipPayload[];
+ },
+ ]
+ >;
+ } = $props();
+
+ const chart = useChart();
+ const chartCtx = getChartContext();
+
+ // Filter to series with defined values (important for item-based charts like Pie/Arc
+ // where only the hovered item has a value)
+ const visibleSeries = $derived(
+ chartCtx.tooltip.series.filter((s: TooltipPayload) => s.value !== undefined)
+ );
+
+ const formattedLabel = $derived.by(() => {
+ if (hideLabel || !visibleSeries?.length) return null;
+
+ const [item] = visibleSeries;
+ const tooltipData = chartCtx.tooltip.data;
+
+ // Get the x-axis label value from the raw tooltip data (e.g. a Date or month string)
+ const dataLabel = tooltipData != null ? chartCtx.x(tooltipData) : undefined;
+
+ const key = labelKey ?? item?.label ?? item?.key ?? "value";
+ const itemConfig = getPayloadConfigFromPayload(
+ chart.config,
+ item,
+ key,
+ tooltipData as Record<string, unknown> | null
+ );
+
+ let value: unknown;
+ if (!labelKey && typeof label === "string") {
+ value = chart.config[label as keyof typeof chart.config]?.label ?? label;
+ } else if (labelKey) {
+ value = itemConfig?.label ?? dataLabel;
+ } else {
+ value = dataLabel;
+ }
+
+ if (value === undefined) return null;
+ if (!labelFormatter) return value;
+ return labelFormatter(value, visibleSeries);
+ });
+
+ const nestLabel = $derived(visibleSeries.length === 1 && indicator !== "dot");
+</script>
+
+{#snippet TooltipLabel()}
+ {#if formattedLabel}
+ <div class={cn("font-medium", labelClassName)}>
+ {#if typeof formattedLabel === "function"}
+ {@render formattedLabel()}
+ {:else}
+ {formattedLabel}
+ {/if}
+ </div>
+ {/if}
+{/snippet}
+
+<TooltipPrimitive.Root variant="none">
+ <div
+ bind:this={ref}
+ class={cn(
+ "grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
+ className
+ )}
+ {...restProps}
+ >
+ {#if !nestLabel}
+ {@render TooltipLabel()}
+ {/if}
+ <div class="grid gap-1.5">
+ {#each visibleSeries as item, i (item.key + i)}
+ {@const key = `${nameKey || item.key || item.label || "value"}`}
+ {@const itemConfig = getPayloadConfigFromPayload(
+ chart.config,
+ item,
+ key,
+ chartCtx.tooltip.data
+ )}
+ {@const indicatorColor = color || item.config?.color || item.color}
+ <div
+ class={cn(
+ "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
+ indicator === "dot" && "items-center"
+ )}
+ >
+ {#if formatter && item.value !== undefined && item.label}
+ {@render formatter({
+ value: item.value,
+ name: item.label,
+ item,
+ index: i,
+ payload: visibleSeries,
+ })}
+ {:else}
+ {#if itemConfig?.icon}
+ <itemConfig.icon />
+ {:else if !hideIndicator}
+ <div
+ style="--color-bg: {indicatorColor}; --color-border: {indicatorColor};"
+ class={cn("shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)", {
+ "h-2.5 w-2.5": indicator === "dot",
+ "h-full w-1": indicator === "line",
+ "w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
+ "my-0.5": nestLabel && indicator === "dashed",
+ })}
+ ></div>
+ {/if}
+ <div
+ class={cn(
+ "flex flex-1 shrink-0 justify-between leading-none",
+ nestLabel ? "items-end" : "items-center"
+ )}
+ >
+ <div class="grid gap-1.5">
+ {#if nestLabel}
+ {@render TooltipLabel()}
+ {/if}
+ <span class="text-muted-foreground">
+ {itemConfig?.label || item.label}
+ </span>
+ </div>
+ {#if item.value !== undefined}
+ <span class="font-mono font-medium text-foreground tabular-nums">
+ {item.value.toLocaleString()}
+ </span>
+ {/if}
+ </div>
+ {/if}
+ </div>
+ {/each}
+ </div>
+ </div>
+</TooltipPrimitive.Root>
diff --git a/frontend/src/lib/components/ui/chart/chart-utils.ts b/frontend/src/lib/components/ui/chart/chart-utils.ts
new file mode 100644
index 0000000..6e1f000
--- /dev/null
+++ b/frontend/src/lib/components/ui/chart/chart-utils.ts
@@ -0,0 +1,68 @@
+import { getContext, setContext, type Component, type Snippet } from "svelte";
+import type { Tooltip } from "layerchart";
+
+export const THEMES = { light: "", dark: ".dark" } as const;
+
+export type ChartConfig = {
+ [k in string]: {
+ label?: string;
+ icon?: Component;
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record<keyof typeof THEMES, string> }
+ );
+};
+
+export type ExtractSnippetParams<T> = T extends Snippet<[infer P]> ? P : never;
+
+export type TooltipPayload = Tooltip.TooltipSeries;
+
+// Helper to extract item config from a payload.
+export function getPayloadConfigFromPayload(
+ config: ChartConfig,
+ payload: TooltipPayload,
+ key: string,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ data?: Record<string, any> | null
+) {
+ if (typeof payload !== "object" || payload === null) return undefined;
+
+ const payloadConfig =
+ "config" in payload && typeof payload.config === "object" && payload.config !== null
+ ? payload.config
+ : undefined;
+
+ let configLabelKey: string = key;
+
+ if (payload.key === key) {
+ configLabelKey = payload.key;
+ } else if (payload.label === key) {
+ configLabelKey = payload.label;
+ } else if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
+ configLabelKey = payload[key as keyof typeof payload] as string;
+ } else if (
+ payloadConfig !== undefined &&
+ key in payloadConfig &&
+ typeof payloadConfig[key as keyof typeof payloadConfig] === "string"
+ ) {
+ configLabelKey = payloadConfig[key as keyof typeof payloadConfig] as string;
+ } else if (data != null && key in data && typeof data[key] === "string") {
+ configLabelKey = data[key] as string;
+ }
+
+ return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
+}
+
+type ChartContextValue = {
+ config: ChartConfig;
+};
+
+const chartContextKey = Symbol("chart-context");
+
+export function setChartContext(value: ChartContextValue) {
+ return setContext(chartContextKey, value);
+}
+
+export function useChart() {
+ return getContext<ChartContextValue>(chartContextKey);
+}
diff --git a/frontend/src/lib/components/ui/chart/index.ts b/frontend/src/lib/components/ui/chart/index.ts
new file mode 100644
index 0000000..f22375e
--- /dev/null
+++ b/frontend/src/lib/components/ui/chart/index.ts
@@ -0,0 +1,6 @@
+import ChartContainer from "./chart-container.svelte";
+import ChartTooltip from "./chart-tooltip.svelte";
+
+export { getPayloadConfigFromPayload, type ChartConfig } from "./chart-utils.js";
+
+export { ChartContainer, ChartTooltip, ChartContainer as Container, ChartTooltip as Tooltip };
diff --git a/frontend/src/lib/components/ui/input/index.ts b/frontend/src/lib/components/ui/input/index.ts
new file mode 100644
index 0000000..f47b6d3
--- /dev/null
+++ b/frontend/src/lib/components/ui/input/index.ts
@@ -0,0 +1,7 @@
+import Root from "./input.svelte";
+
+export {
+ Root,
+ //
+ Root as Input,
+};
diff --git a/frontend/src/lib/components/ui/input/input.svelte b/frontend/src/lib/components/ui/input/input.svelte
new file mode 100644
index 0000000..549c6b8
--- /dev/null
+++ b/frontend/src/lib/components/ui/input/input.svelte
@@ -0,0 +1,48 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from "$lib/utils.js";
+ import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
+
+ type InputType = Exclude<HTMLInputTypeAttribute, "file">;
+
+ type Props = WithElementRef<
+ Omit<HTMLInputAttributes, "type"> &
+ ({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
+ >;
+
+ let {
+ ref = $bindable(null),
+ value = $bindable(),
+ type,
+ files = $bindable(),
+ class: className,
+ "data-slot": dataSlot = "input",
+ ...restProps
+ }: Props = $props();
+</script>
+
+{#if type === "file"}
+ <input
+ bind:this={ref}
+ data-slot={dataSlot}
+ class={cn(
+ "bg-input/20 dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-7 rounded-md border px-2 py-0.5 text-sm transition-colors file:h-6 file:text-xs/relaxed file:font-medium focus-visible:ring-2 aria-invalid:ring-2 md:text-xs/relaxed w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
+ className
+ )}
+ type="file"
+ bind:files
+ bind:value
+ {...restProps}
+ />
+{:else}
+ <input
+ bind:this={ref}
+ data-slot={dataSlot}
+ class={cn(
+ "bg-input/20 dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-7 rounded-md border px-2 py-0.5 text-sm transition-colors file:h-6 file:text-xs/relaxed file:font-medium focus-visible:ring-2 aria-invalid:ring-2 md:text-xs/relaxed w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
+ className
+ )}
+ {type}
+ bind:value
+ {...restProps}
+ />
+{/if}
diff --git a/frontend/src/lib/components/ui/label/index.ts b/frontend/src/lib/components/ui/label/index.ts
new file mode 100644
index 0000000..8bfca0b
--- /dev/null
+++ b/frontend/src/lib/components/ui/label/index.ts
@@ -0,0 +1,7 @@
+import Root from "./label.svelte";
+
+export {
+ Root,
+ //
+ Root as Label,
+};
diff --git a/frontend/src/lib/components/ui/label/label.svelte b/frontend/src/lib/components/ui/label/label.svelte
new file mode 100644
index 0000000..f1e8c35
--- /dev/null
+++ b/frontend/src/lib/components/ui/label/label.svelte
@@ -0,0 +1,20 @@
+<script lang="ts">
+ import { Label as LabelPrimitive } from "bits-ui";
+ import { cn } from "$lib/utils.js";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: LabelPrimitive.RootProps = $props();
+</script>
+
+<LabelPrimitive.Root
+ bind:ref
+ data-slot="label"
+ class={cn(
+ "gap-2 text-xs/relaxed leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
+ className
+ )}
+ {...restProps}
+/>
diff --git a/frontend/src/lib/components/ui/select/index.ts b/frontend/src/lib/components/ui/select/index.ts
new file mode 100644
index 0000000..4960d45
--- /dev/null
+++ b/frontend/src/lib/components/ui/select/index.ts
@@ -0,0 +1,40 @@
+import Content from "./select-content.svelte";
+import GroupHeading from "./select-group-heading.svelte";
+import Group from "./select-group.svelte";
+import Item from "./select-item.svelte";
+import Label from "./select-label.svelte";
+import Portal from "./select-portal.svelte";
+import ScrollDownButton from "./select-scroll-down-button.svelte";
+import ScrollUpButton from "./select-scroll-up-button.svelte";
+import Separator from "./select-separator.svelte";
+import Trigger from "./select-trigger.svelte";
+import Value from "./select-value.svelte";
+import Root from "./select.svelte";
+
+export {
+ Root,
+ Group,
+ Label,
+ Item,
+ Content,
+ Trigger,
+ Separator,
+ ScrollDownButton,
+ ScrollUpButton,
+ GroupHeading,
+ Portal,
+ Value,
+ //
+ Root as Select,
+ Group as SelectGroup,
+ Label as SelectLabel,
+ Item as SelectItem,
+ Content as SelectContent,
+ Trigger as SelectTrigger,
+ Separator as SelectSeparator,
+ ScrollDownButton as SelectScrollDownButton,
+ ScrollUpButton as SelectScrollUpButton,
+ GroupHeading as SelectGroupHeading,
+ Portal as SelectPortal,
+ Value as SelectValue,
+};
diff --git a/frontend/src/lib/components/ui/select/select-content.svelte b/frontend/src/lib/components/ui/select/select-content.svelte
new file mode 100644
index 0000000..3ec8408
--- /dev/null
+++ b/frontend/src/lib/components/ui/select/select-content.svelte
@@ -0,0 +1,45 @@
+<script lang="ts">
+ import { Select as SelectPrimitive } from "bits-ui";
+ import { cn, type WithoutChild } from "$lib/utils.js";
+ import type { WithoutChildrenOrChild } from "$lib/utils.js";
+ import SelectPortal from "./select-portal.svelte";
+ import SelectScrollDownButton from "./select-scroll-down-button.svelte";
+ import SelectScrollUpButton from "./select-scroll-up-button.svelte";
+ import type { ComponentProps } from "svelte";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ sideOffset = 4,
+ portalProps,
+ children,
+ preventScroll = true,
+ ...restProps
+ }: WithoutChild<SelectPrimitive.ContentProps> & {
+ portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SelectPortal>>;
+ } = $props();
+</script>
+
+<SelectPortal {...portalProps}>
+ <SelectPrimitive.Content
+ bind:ref
+ {sideOffset}
+ {preventScroll}
+ data-slot="select-content"
+ class={cn(
+ "bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-32 rounded-lg shadow-md ring-1 duration-100 relative z-50 max-h-(--bits-select-content-available-height) origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto",
+ className
+ )}
+ {...restProps}
+ >
+ <SelectScrollUpButton />
+ <SelectPrimitive.Viewport
+ class={cn(
+ "h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1"
+ )}
+ >
+ {@render children?.()}
+ </SelectPrimitive.Viewport>
+ <SelectScrollDownButton />
+ </SelectPrimitive.Content>
+</SelectPortal>
diff --git a/frontend/src/lib/components/ui/select/select-group-heading.svelte b/frontend/src/lib/components/ui/select/select-group-heading.svelte
new file mode 100644
index 0000000..1fab5f0
--- /dev/null
+++ b/frontend/src/lib/components/ui/select/select-group-heading.svelte
@@ -0,0 +1,21 @@
+<script lang="ts">
+ import { Select as SelectPrimitive } from "bits-ui";
+ import { cn } from "$lib/utils.js";
+ import type { ComponentProps } from "svelte";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
+</script>
+
+<SelectPrimitive.GroupHeading
+ bind:ref
+ data-slot="select-group-heading"
+ class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
+ {...restProps}
+>
+ {@render children?.()}
+</SelectPrimitive.GroupHeading>
diff --git a/frontend/src/lib/components/ui/select/select-group.svelte b/frontend/src/lib/components/ui/select/select-group.svelte
new file mode 100644
index 0000000..f666cb2
--- /dev/null
+++ b/frontend/src/lib/components/ui/select/select-group.svelte
@@ -0,0 +1,17 @@
+<script lang="ts">
+ import { Select as SelectPrimitive } from "bits-ui";
+ import { cn } from "$lib/utils.js";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: SelectPrimitive.GroupProps = $props();
+</script>
+
+<SelectPrimitive.Group
+ bind:ref
+ data-slot="select-group"
+ class={cn("scroll-my-1 p-1", className)}
+ {...restProps}
+/>
diff --git a/frontend/src/lib/components/ui/select/select-item.svelte b/frontend/src/lib/components/ui/select/select-item.svelte
new file mode 100644
index 0000000..446dfdc
--- /dev/null
+++ b/frontend/src/lib/components/ui/select/select-item.svelte
@@ -0,0 +1,42 @@
+<script lang="ts">
+ import { Select as SelectPrimitive } from "bits-ui";
+ import CheckIcon from '@lucide/svelte/icons/check';
+ import { cn, type WithoutChild } from "$lib/utils.js";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ value,
+ label,
+ children: childrenProp,
+ ...restProps
+ }: WithoutChild<SelectPrimitive.ItemProps> = $props();
+</script>
+
+<SelectPrimitive.Item
+ bind:ref
+ {value}
+ {label}
+ data-slot="select-item"
+ class={cn(
+ "focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground min-h-7 gap-2 rounded-md px-2 py-1 text-xs/relaxed [&_svg:not([class*='size-'])]:size-3.5 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ "data-highlighted:bg-accent data-highlighted:text-accent-foreground",
+ className
+ )}
+ {...restProps}
+>
+ {#snippet children({ selected, highlighted })}
+ <span class="pointer-events-none absolute right-2 flex items-center justify-center">
+ {#if selected}
+ <CheckIcon class="pointer-events-none" />
+ {/if}
+ </span>
+ <span class="flex flex-1 gap-2 shrink-0 whitespace-nowrap">
+ {#if childrenProp}
+ {@render childrenProp({ selected, highlighted })}
+ {:else}
+ {label || value}
+ {/if}
+ </span>
+ {/snippet}
+</SelectPrimitive.Item>
diff --git a/frontend/src/lib/components/ui/select/select-label.svelte b/frontend/src/lib/components/ui/select/select-label.svelte
new file mode 100644
index 0000000..4696025
--- /dev/null
+++ b/frontend/src/lib/components/ui/select/select-label.svelte
@@ -0,0 +1,20 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from "$lib/utils.js";
+ import type { HTMLAttributes } from "svelte/elements";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
+</script>
+
+<div
+ bind:this={ref}
+ data-slot="select-label"
+ class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
+ {...restProps}
+>
+ {@render children?.()}
+</div>
diff --git a/frontend/src/lib/components/ui/select/select-portal.svelte b/frontend/src/lib/components/ui/select/select-portal.svelte
new file mode 100644
index 0000000..424bcdd
--- /dev/null
+++ b/frontend/src/lib/components/ui/select/select-portal.svelte
@@ -0,0 +1,7 @@
+<script lang="ts">
+ import { Select as SelectPrimitive } from "bits-ui";
+
+ let { ...restProps }: SelectPrimitive.PortalProps = $props();
+</script>
+
+<SelectPrimitive.Portal {...restProps} />
diff --git a/frontend/src/lib/components/ui/select/select-scroll-down-button.svelte b/frontend/src/lib/components/ui/select/select-scroll-down-button.svelte
new file mode 100644
index 0000000..7bc111e
--- /dev/null
+++ b/frontend/src/lib/components/ui/select/select-scroll-down-button.svelte
@@ -0,0 +1,20 @@
+<script lang="ts">
+ import { Select as SelectPrimitive } from "bits-ui";
+ import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
+ import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
+</script>
+
+<SelectPrimitive.ScrollDownButton
+ bind:ref
+ data-slot="select-scroll-down-button"
+ class={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-3.5", className)}
+ {...restProps}
+>
+ <ChevronDownIcon />
+</SelectPrimitive.ScrollDownButton>
diff --git a/frontend/src/lib/components/ui/select/select-scroll-up-button.svelte b/frontend/src/lib/components/ui/select/select-scroll-up-button.svelte
new file mode 100644
index 0000000..6c066ff
--- /dev/null
+++ b/frontend/src/lib/components/ui/select/select-scroll-up-button.svelte
@@ -0,0 +1,20 @@
+<script lang="ts">
+ import { Select as SelectPrimitive } from "bits-ui";
+ import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
+ import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
+</script>
+
+<SelectPrimitive.ScrollUpButton
+ bind:ref
+ data-slot="select-scroll-up-button"
+ class={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-3.5", className)}
+ {...restProps}
+>
+ <ChevronUpIcon />
+</SelectPrimitive.ScrollUpButton>
diff --git a/frontend/src/lib/components/ui/select/select-separator.svelte b/frontend/src/lib/components/ui/select/select-separator.svelte
new file mode 100644
index 0000000..aac5ca3
--- /dev/null
+++ b/frontend/src/lib/components/ui/select/select-separator.svelte
@@ -0,0 +1,18 @@
+<script lang="ts">
+ import { Separator } from "$lib/components/ui/separator/index.js";
+ import { cn } from "$lib/utils.js";
+ import type { Separator as SeparatorPrimitive } from "bits-ui";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: SeparatorPrimitive.RootProps = $props();
+</script>
+
+<Separator
+ bind:ref
+ data-slot="select-separator"
+ class={cn("bg-border/50 -mx-1 my-1 h-px pointer-events-none", className)}
+ {...restProps}
+/>
diff --git a/frontend/src/lib/components/ui/select/select-trigger.svelte b/frontend/src/lib/components/ui/select/select-trigger.svelte
new file mode 100644
index 0000000..3acff3b
--- /dev/null
+++ b/frontend/src/lib/components/ui/select/select-trigger.svelte
@@ -0,0 +1,29 @@
+<script lang="ts">
+ import { Select as SelectPrimitive } from "bits-ui";
+ import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
+ import { cn, type WithoutChild } from "$lib/utils.js";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ size = "default",
+ ...restProps
+ }: WithoutChild<SelectPrimitive.TriggerProps> & {
+ size?: "sm" | "default";
+ } = $props();
+</script>
+
+<SelectPrimitive.Trigger
+ bind:ref
+ data-slot="select-trigger"
+ data-size={size}
+ class={cn(
+ "border-input data-placeholder:text-muted-foreground bg-input/20 dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-md border px-2 py-1.5 text-xs/relaxed transition-colors focus-visible:ring-2 aria-invalid:ring-2 data-[size=default]:h-7 data-[size=sm]:h-6 *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-3.5 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+ <ChevronDownIcon class="text-muted-foreground size-3.5 pointer-events-none" />
+</SelectPrimitive.Trigger>
diff --git a/frontend/src/lib/components/ui/select/select-value.svelte b/frontend/src/lib/components/ui/select/select-value.svelte
new file mode 100644
index 0000000..ff34b0e
--- /dev/null
+++ b/frontend/src/lib/components/ui/select/select-value.svelte
@@ -0,0 +1,7 @@
+<script lang="ts">
+ import { Select as SelectPrimitive } from "bits-ui";
+
+ let { ref = $bindable(null), ...restProps }: SelectPrimitive.ValueProps = $props();
+</script>
+
+<SelectPrimitive.Value bind:ref data-slot="select-value" {...restProps} />
diff --git a/frontend/src/lib/components/ui/select/select.svelte b/frontend/src/lib/components/ui/select/select.svelte
new file mode 100644
index 0000000..05eb663
--- /dev/null
+++ b/frontend/src/lib/components/ui/select/select.svelte
@@ -0,0 +1,11 @@
+<script lang="ts">
+ import { Select as SelectPrimitive } from "bits-ui";
+
+ let {
+ open = $bindable(false),
+ value = $bindable(),
+ ...restProps
+ }: SelectPrimitive.RootProps = $props();
+</script>
+
+<SelectPrimitive.Root bind:open bind:value={value as never} {...restProps} />
diff --git a/frontend/src/lib/components/ui/separator/index.ts b/frontend/src/lib/components/ui/separator/index.ts
new file mode 100644
index 0000000..82442d2
--- /dev/null
+++ b/frontend/src/lib/components/ui/separator/index.ts
@@ -0,0 +1,7 @@
+import Root from "./separator.svelte";
+
+export {
+ Root,
+ //
+ Root as Separator,
+};
diff --git a/frontend/src/lib/components/ui/separator/separator.svelte b/frontend/src/lib/components/ui/separator/separator.svelte
new file mode 100644
index 0000000..e91b176
--- /dev/null
+++ b/frontend/src/lib/components/ui/separator/separator.svelte
@@ -0,0 +1,21 @@
+<script lang="ts">
+ import { Separator as SeparatorPrimitive } from "bits-ui";
+ import { cn } from "$lib/utils.js";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ "data-slot": dataSlot = "separator",
+ ...restProps
+ }: SeparatorPrimitive.RootProps = $props();
+</script>
+
+<SeparatorPrimitive.Root
+ bind:ref
+ data-slot={dataSlot}
+ class={cn(
+ "shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
+ className
+ )}
+ {...restProps}
+/>
diff --git a/frontend/src/lib/components/ui/table/index.ts b/frontend/src/lib/components/ui/table/index.ts
new file mode 100644
index 0000000..841ee58
--- /dev/null
+++ b/frontend/src/lib/components/ui/table/index.ts
@@ -0,0 +1,28 @@
+import Body from "./table-body.svelte";
+import Caption from "./table-caption.svelte";
+import Cell from "./table-cell.svelte";
+import Footer from "./table-footer.svelte";
+import Head from "./table-head.svelte";
+import Header from "./table-header.svelte";
+import Row from "./table-row.svelte";
+import Root from "./table.svelte";
+
+export {
+ Root,
+ Body,
+ Caption,
+ Cell,
+ Footer,
+ Head,
+ Header,
+ Row,
+ //
+ Root as Table,
+ Body as TableBody,
+ Caption as TableCaption,
+ Cell as TableCell,
+ Footer as TableFooter,
+ Head as TableHead,
+ Header as TableHeader,
+ Row as TableRow,
+};
diff --git a/frontend/src/lib/components/ui/table/table-body.svelte b/frontend/src/lib/components/ui/table/table-body.svelte
new file mode 100644
index 0000000..935feae
--- /dev/null
+++ b/frontend/src/lib/components/ui/table/table-body.svelte
@@ -0,0 +1,15 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from "$lib/utils.js";
+ import type { HTMLAttributes } from "svelte/elements";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
+</script>
+
+<tbody bind:this={ref} data-slot="table-body" class={cn("[&_tr:last-child]:border-0", className)} {...restProps}>
+ {@render children?.()}
+</tbody>
diff --git a/frontend/src/lib/components/ui/table/table-caption.svelte b/frontend/src/lib/components/ui/table/table-caption.svelte
new file mode 100644
index 0000000..b7a3e98
--- /dev/null
+++ b/frontend/src/lib/components/ui/table/table-caption.svelte
@@ -0,0 +1,20 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from "$lib/utils.js";
+ import type { HTMLAttributes } from "svelte/elements";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
+</script>
+
+<caption
+ bind:this={ref}
+ data-slot="table-caption"
+ class={cn("text-muted-foreground mt-4 text-xs", className)}
+ {...restProps}
+>
+ {@render children?.()}
+</caption>
diff --git a/frontend/src/lib/components/ui/table/table-cell.svelte b/frontend/src/lib/components/ui/table/table-cell.svelte
new file mode 100644
index 0000000..a998bf6
--- /dev/null
+++ b/frontend/src/lib/components/ui/table/table-cell.svelte
@@ -0,0 +1,15 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from "$lib/utils.js";
+ import type { HTMLTdAttributes } from "svelte/elements";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLTdAttributes> = $props();
+</script>
+
+<td bind:this={ref} data-slot="table-cell" class={cn("p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)} {...restProps}>
+ {@render children?.()}
+</td>
diff --git a/frontend/src/lib/components/ui/table/table-footer.svelte b/frontend/src/lib/components/ui/table/table-footer.svelte
new file mode 100644
index 0000000..b9b14eb
--- /dev/null
+++ b/frontend/src/lib/components/ui/table/table-footer.svelte
@@ -0,0 +1,20 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from "$lib/utils.js";
+ import type { HTMLAttributes } from "svelte/elements";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
+</script>
+
+<tfoot
+ bind:this={ref}
+ data-slot="table-footer"
+ class={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
+ {...restProps}
+>
+ {@render children?.()}
+</tfoot>
diff --git a/frontend/src/lib/components/ui/table/table-head.svelte b/frontend/src/lib/components/ui/table/table-head.svelte
new file mode 100644
index 0000000..267c4e0
--- /dev/null
+++ b/frontend/src/lib/components/ui/table/table-head.svelte
@@ -0,0 +1,15 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from "$lib/utils.js";
+ import type { HTMLThAttributes } from "svelte/elements";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLThAttributes> = $props();
+</script>
+
+<th bind:this={ref} data-slot="table-head" class={cn("text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)} {...restProps}>
+ {@render children?.()}
+</th>
diff --git a/frontend/src/lib/components/ui/table/table-header.svelte b/frontend/src/lib/components/ui/table/table-header.svelte
new file mode 100644
index 0000000..f47d259
--- /dev/null
+++ b/frontend/src/lib/components/ui/table/table-header.svelte
@@ -0,0 +1,20 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from "$lib/utils.js";
+ import type { HTMLAttributes } from "svelte/elements";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
+</script>
+
+<thead
+ bind:this={ref}
+ data-slot="table-header"
+ class={cn("[&_tr]:border-b", className)}
+ {...restProps}
+>
+ {@render children?.()}
+</thead>
diff --git a/frontend/src/lib/components/ui/table/table-row.svelte b/frontend/src/lib/components/ui/table/table-row.svelte
new file mode 100644
index 0000000..c7b05c8
--- /dev/null
+++ b/frontend/src/lib/components/ui/table/table-row.svelte
@@ -0,0 +1,20 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from "$lib/utils.js";
+ import type { HTMLAttributes } from "svelte/elements";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLAttributes<HTMLTableRowElement>> = $props();
+</script>
+
+<tr
+ bind:this={ref}
+ data-slot="table-row"
+ class={cn("hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors has-aria-expanded:bg-muted/50", className)}
+ {...restProps}
+>
+ {@render children?.()}
+</tr>
diff --git a/frontend/src/lib/components/ui/table/table.svelte b/frontend/src/lib/components/ui/table/table.svelte
new file mode 100644
index 0000000..b2b477f
--- /dev/null
+++ b/frontend/src/lib/components/ui/table/table.svelte
@@ -0,0 +1,17 @@
+<script lang="ts">
+ import { cn, type WithElementRef } from "$lib/utils.js";
+ import type { HTMLTableAttributes } from "svelte/elements";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ children,
+ ...restProps
+ }: WithElementRef<HTMLTableAttributes> = $props();
+</script>
+
+<div data-slot="table-container" class="relative w-full overflow-x-auto">
+ <table bind:this={ref} data-slot="table" class={cn("w-full caption-bottom text-xs", className)} {...restProps}>
+ {@render children?.()}
+ </table>
+</div>
diff --git a/frontend/src/lib/components/ui/tabs/index.ts b/frontend/src/lib/components/ui/tabs/index.ts
new file mode 100644
index 0000000..581b188
--- /dev/null
+++ b/frontend/src/lib/components/ui/tabs/index.ts
@@ -0,0 +1,18 @@
+import Content from "./tabs-content.svelte";
+import Trigger from "./tabs-trigger.svelte";
+import Root from "./tabs.svelte";
+import List, { tabsListVariants, type TabsListVariant } from "./tabs-list.svelte";
+
+export {
+ Root,
+ Content,
+ List,
+ Trigger,
+ tabsListVariants,
+ type TabsListVariant,
+ //
+ Root as Tabs,
+ Content as TabsContent,
+ List as TabsList,
+ Trigger as TabsTrigger,
+};
diff --git a/frontend/src/lib/components/ui/tabs/tabs-content.svelte b/frontend/src/lib/components/ui/tabs/tabs-content.svelte
new file mode 100644
index 0000000..b9e4530
--- /dev/null
+++ b/frontend/src/lib/components/ui/tabs/tabs-content.svelte
@@ -0,0 +1,17 @@
+<script lang="ts">
+ import { Tabs as TabsPrimitive } from "bits-ui";
+ import { cn } from "$lib/utils.js";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: TabsPrimitive.ContentProps = $props();
+</script>
+
+<TabsPrimitive.Content
+ bind:ref
+ data-slot="tabs-content"
+ class={cn("text-xs/relaxed flex-1 outline-none", className)}
+ {...restProps}
+/>
diff --git a/frontend/src/lib/components/ui/tabs/tabs-list.svelte b/frontend/src/lib/components/ui/tabs/tabs-list.svelte
new file mode 100644
index 0000000..4445c0f
--- /dev/null
+++ b/frontend/src/lib/components/ui/tabs/tabs-list.svelte
@@ -0,0 +1,40 @@
+<script lang="ts" module>
+ import { tv, type VariantProps } from "tailwind-variants";
+
+ export const tabsListVariants = tv({
+ base: "rounded-lg p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list inline-flex w-fit items-center justify-center text-muted-foreground group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col",
+ variants: {
+ variant: {
+ default: "bg-muted",
+ line: "gap-1 bg-transparent",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ });
+
+ export type TabsListVariant = VariantProps<typeof tabsListVariants>["variant"];
+</script>
+
+<script lang="ts">
+ import { Tabs as TabsPrimitive } from "bits-ui";
+ import { cn } from "$lib/utils.js";
+
+ let {
+ ref = $bindable(null),
+ variant = "default",
+ class: className,
+ ...restProps
+ }: TabsPrimitive.ListProps & {
+ variant?: TabsListVariant;
+ } = $props();
+</script>
+
+<TabsPrimitive.List
+ bind:ref
+ data-slot="tabs-list"
+ data-variant={variant}
+ class={cn(tabsListVariants({ variant }), className)}
+ {...restProps}
+/>
diff --git a/frontend/src/lib/components/ui/tabs/tabs-trigger.svelte b/frontend/src/lib/components/ui/tabs/tabs-trigger.svelte
new file mode 100644
index 0000000..c243f83
--- /dev/null
+++ b/frontend/src/lib/components/ui/tabs/tabs-trigger.svelte
@@ -0,0 +1,23 @@
+<script lang="ts">
+ import { Tabs as TabsPrimitive } from "bits-ui";
+ import { cn } from "$lib/utils.js";
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ ...restProps
+ }: TabsPrimitive.TriggerProps = $props();
+</script>
+
+<TabsPrimitive.Trigger
+ bind:ref
+ data-slot="tabs-trigger"
+ class={cn(
+ "gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-xs font-medium group-data-vertical/tabs:py-[calc(--spacing(1.25))] [&_svg:not([class*='size-'])]:size-3.5 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 relative inline-flex h-[calc(100%_-_1px)] flex-1 items-center justify-center whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
+ "data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
+ "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
+ className
+ )}
+ {...restProps}
+/>
diff --git a/frontend/src/lib/components/ui/tabs/tabs.svelte b/frontend/src/lib/components/ui/tabs/tabs.svelte
new file mode 100644
index 0000000..a7b461a
--- /dev/null
+++ b/frontend/src/lib/components/ui/tabs/tabs.svelte
@@ -0,0 +1,19 @@
+<script lang="ts">
+ import { Tabs as TabsPrimitive } from "bits-ui";
+ import { cn } from "$lib/utils.js";
+
+ let {
+ ref = $bindable(null),
+ value = $bindable(""),
+ class: className,
+ ...restProps
+ }: TabsPrimitive.RootProps = $props();
+</script>
+
+<TabsPrimitive.Root
+ bind:ref
+ bind:value
+ data-slot="tabs"
+ class={cn("gap-2 group/tabs flex data-horizontal:flex-col", className)}
+ {...restProps}
+/>
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts
new file mode 100644
index 0000000..55b3a91
--- /dev/null
+++ b/frontend/src/lib/utils.ts
@@ -0,0 +1,13 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
+export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
+export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };