feat: add API client, types, layout, and all page routes
This commit is contained in:
parent
2e4e3fba50
commit
b140c55cda
71 changed files with 3237 additions and 58 deletions
192
frontend/src/lib/api.ts
Normal file
192
frontend/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { PUBLIC_API_BASE } from '$env/static/public';
|
||||
import type {
|
||||
LabResult,
|
||||
MedicationEntry,
|
||||
MedicationUsage,
|
||||
Product,
|
||||
ProductInventory,
|
||||
Routine,
|
||||
RoutineStep,
|
||||
SkinConditionSnapshot
|
||||
} from './types';
|
||||
|
||||
// ─── Core fetch helpers ──────────────────────────────────────────────────────
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const url = `${PUBLIC_API_BASE}${path}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json', ...init.headers },
|
||||
...init
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(detail?.detail ?? res.statusText);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
|
||||
patch: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
del: (path: string) => request<void>(path, { method: 'DELETE' })
|
||||
};
|
||||
|
||||
// ─── Products ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProductListParams {
|
||||
category?: string;
|
||||
brand?: string;
|
||||
targets?: string[];
|
||||
is_medication?: boolean;
|
||||
is_tool?: boolean;
|
||||
}
|
||||
|
||||
export function getProducts(params: ProductListParams = {}): Promise<Product[]> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.category) q.set('category', params.category);
|
||||
if (params.brand) q.set('brand', params.brand);
|
||||
if (params.targets) params.targets.forEach((t) => q.append('targets', t));
|
||||
if (params.is_medication != null) q.set('is_medication', String(params.is_medication));
|
||||
if (params.is_tool != null) q.set('is_tool', String(params.is_tool));
|
||||
const qs = q.toString();
|
||||
return api.get(`/products${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export const getProduct = (id: string): Promise<Product> => api.get(`/products/${id}`);
|
||||
export const createProduct = (body: Record<string, unknown>): Promise<Product> =>
|
||||
api.post('/products', body);
|
||||
export const updateProduct = (id: string, body: Record<string, unknown>): Promise<Product> =>
|
||||
api.patch(`/products/${id}`, body);
|
||||
export const deleteProduct = (id: string): Promise<void> => api.del(`/products/${id}`);
|
||||
|
||||
export const getInventory = (productId: string): Promise<ProductInventory[]> =>
|
||||
api.get(`/products/${productId}/inventory`);
|
||||
export const createInventory = (
|
||||
productId: string,
|
||||
body: Record<string, unknown>
|
||||
): Promise<ProductInventory> => api.post(`/products/${productId}/inventory`, body);
|
||||
|
||||
// ─── Routines ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RoutineListParams {
|
||||
from_date?: string;
|
||||
to_date?: string;
|
||||
part_of_day?: string;
|
||||
}
|
||||
|
||||
export function getRoutines(params: RoutineListParams = {}): Promise<Routine[]> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.from_date) q.set('from_date', params.from_date);
|
||||
if (params.to_date) q.set('to_date', params.to_date);
|
||||
if (params.part_of_day) q.set('part_of_day', params.part_of_day);
|
||||
const qs = q.toString();
|
||||
return api.get(`/routines${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export const getRoutine = (id: string): Promise<Routine> => api.get(`/routines/${id}`);
|
||||
export const createRoutine = (body: Record<string, unknown>): Promise<Routine> =>
|
||||
api.post('/routines', body);
|
||||
export const updateRoutine = (id: string, body: Record<string, unknown>): Promise<Routine> =>
|
||||
api.patch(`/routines/${id}`, body);
|
||||
export const deleteRoutine = (id: string): Promise<void> => api.del(`/routines/${id}`);
|
||||
|
||||
export const addRoutineStep = (routineId: string, body: Record<string, unknown>): Promise<RoutineStep> =>
|
||||
api.post(`/routines/${routineId}/steps`, body);
|
||||
export const updateRoutineStep = (stepId: string, body: Record<string, unknown>): Promise<RoutineStep> =>
|
||||
api.patch(`/routines/steps/${stepId}`, body);
|
||||
export const deleteRoutineStep = (stepId: string): Promise<void> =>
|
||||
api.del(`/routines/steps/${stepId}`);
|
||||
|
||||
// ─── Health – Medications ────────────────────────────────────────────────────
|
||||
|
||||
export interface MedicationListParams {
|
||||
kind?: string;
|
||||
product_name?: string;
|
||||
}
|
||||
|
||||
export function getMedications(params: MedicationListParams = {}): Promise<MedicationEntry[]> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.kind) q.set('kind', params.kind);
|
||||
if (params.product_name) q.set('product_name', params.product_name);
|
||||
const qs = q.toString();
|
||||
return api.get(`/health/medications${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export const getMedication = (id: string): Promise<MedicationEntry> =>
|
||||
api.get(`/health/medications/${id}`);
|
||||
export const createMedication = (body: Record<string, unknown>): Promise<MedicationEntry> =>
|
||||
api.post('/health/medications', body);
|
||||
export const updateMedication = (
|
||||
id: string,
|
||||
body: Record<string, unknown>
|
||||
): Promise<MedicationEntry> => api.patch(`/health/medications/${id}`, body);
|
||||
export const deleteMedication = (id: string): Promise<void> =>
|
||||
api.del(`/health/medications/${id}`);
|
||||
|
||||
export const getMedicationUsages = (medicationId: string): Promise<MedicationUsage[]> =>
|
||||
api.get(`/health/medications/${medicationId}/usages`);
|
||||
export const createMedicationUsage = (
|
||||
medicationId: string,
|
||||
body: Record<string, unknown>
|
||||
): Promise<MedicationUsage> => api.post(`/health/medications/${medicationId}/usages`, body);
|
||||
|
||||
// ─── Health – Lab results ────────────────────────────────────────────────────
|
||||
|
||||
export interface LabResultListParams {
|
||||
test_code?: string;
|
||||
flag?: string;
|
||||
lab?: string;
|
||||
from_date?: string;
|
||||
to_date?: string;
|
||||
}
|
||||
|
||||
export function getLabResults(params: LabResultListParams = {}): Promise<LabResult[]> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.test_code) q.set('test_code', params.test_code);
|
||||
if (params.flag) q.set('flag', params.flag);
|
||||
if (params.lab) q.set('lab', params.lab);
|
||||
if (params.from_date) q.set('from_date', params.from_date);
|
||||
if (params.to_date) q.set('to_date', params.to_date);
|
||||
const qs = q.toString();
|
||||
return api.get(`/health/lab-results${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export const getLabResult = (id: string): Promise<LabResult> =>
|
||||
api.get(`/health/lab-results/${id}`);
|
||||
export const createLabResult = (body: Record<string, unknown>): Promise<LabResult> =>
|
||||
api.post('/health/lab-results', body);
|
||||
export const updateLabResult = (id: string, body: Record<string, unknown>): Promise<LabResult> =>
|
||||
api.patch(`/health/lab-results/${id}`, body);
|
||||
export const deleteLabResult = (id: string): Promise<void> =>
|
||||
api.del(`/health/lab-results/${id}`);
|
||||
|
||||
// ─── Skin ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SnapshotListParams {
|
||||
from_date?: string;
|
||||
to_date?: string;
|
||||
overall_state?: string;
|
||||
}
|
||||
|
||||
export function getSkinSnapshots(params: SnapshotListParams = {}): Promise<SkinConditionSnapshot[]> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.from_date) q.set('from_date', params.from_date);
|
||||
if (params.to_date) q.set('to_date', params.to_date);
|
||||
if (params.overall_state) q.set('overall_state', params.overall_state);
|
||||
const qs = q.toString();
|
||||
return api.get(`/skincare${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export const getSkinSnapshot = (id: string): Promise<SkinConditionSnapshot> =>
|
||||
api.get(`/skincare/${id}`);
|
||||
export const createSkinSnapshot = (body: Record<string, unknown>): Promise<SkinConditionSnapshot> =>
|
||||
api.post('/skincare', body);
|
||||
export const updateSkinSnapshot = (
|
||||
id: string,
|
||||
body: Record<string, unknown>
|
||||
): Promise<SkinConditionSnapshot> => api.patch(`/skincare/${id}`, body);
|
||||
export const deleteSkinSnapshot = (id: string): Promise<void> => api.del(`/skincare/${id}`);
|
||||
50
frontend/src/lib/components/ui/badge/badge.svelte
Normal file
50
frontend/src/lib/components/ui/badge/badge.svelte
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<script lang="ts" module>
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const badgeVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
|
||||
destructive:
|
||||
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
|
||||
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
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>
|
||||
2
frontend/src/lib/components/ui/badge/index.ts
Normal file
2
frontend/src/lib/components/ui/badge/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as Badge } from "./badge.svelte";
|
||||
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
||||
82
frontend/src/lib/components/ui/button/button.svelte
Normal file
82
frontend/src/lib/components/ui/button/button.svelte
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<script lang="ts" module>
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const buttonVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs",
|
||||
destructive:
|
||||
"bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs",
|
||||
outline:
|
||||
"bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
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}
|
||||
17
frontend/src/lib/components/ui/button/index.ts
Normal file
17
frontend/src/lib/components/ui/button/index.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
20
frontend/src/lib/components/ui/card/card-action.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-action.svelte
Normal file
|
|
@ -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-action"
|
||||
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
15
frontend/src/lib/components/ui/card/card-content.svelte
Normal file
15
frontend/src/lib/components/ui/card/card-content.svelte
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
20
frontend/src/lib/components/ui/card/card-description.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-description.svelte
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
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-sm", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</p>
|
||||
20
frontend/src/lib/components/ui/card/card-footer.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-footer.svelte
Normal file
|
|
@ -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("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
23
frontend/src/lib/components/ui/card/card-header.svelte
Normal file
23
frontend/src/lib/components/ui/card/card-header.svelte
Normal file
|
|
@ -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(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
20
frontend/src/lib/components/ui/card/card-title.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-title.svelte
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-title"
|
||||
class={cn("leading-none font-semibold", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
23
frontend/src/lib/components/ui/card/card.svelte
Normal file
23
frontend/src/lib/components/ui/card/card.svelte
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card"
|
||||
class={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
25
frontend/src/lib/components/ui/card/index.ts
Normal file
25
frontend/src/lib/components/ui/card/index.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import Root from "./card.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 Action from "./card-action.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,
|
||||
};
|
||||
7
frontend/src/lib/components/ui/input/index.ts
Normal file
7
frontend/src/lib/components/ui/input/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import Root from "./input.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Input,
|
||||
};
|
||||
52
frontend/src/lib/components/ui/input/input.svelte
Normal file
52
frontend/src/lib/components/ui/input/input.svelte
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
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(
|
||||
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
type="file"
|
||||
bind:files
|
||||
bind:value
|
||||
{...restProps}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
bind:this={ref}
|
||||
data-slot={dataSlot}
|
||||
class={cn(
|
||||
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{type}
|
||||
bind:value
|
||||
{...restProps}
|
||||
/>
|
||||
{/if}
|
||||
7
frontend/src/lib/components/ui/label/index.ts
Normal file
7
frontend/src/lib/components/ui/label/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import Root from "./label.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Label,
|
||||
};
|
||||
20
frontend/src/lib/components/ui/label/label.svelte
Normal file
20
frontend/src/lib/components/ui/label/label.svelte
Normal file
|
|
@ -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(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
37
frontend/src/lib/components/ui/select/index.ts
Normal file
37
frontend/src/lib/components/ui/select/index.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import Root from "./select.svelte";
|
||||
import Group from "./select-group.svelte";
|
||||
import Label from "./select-label.svelte";
|
||||
import Item from "./select-item.svelte";
|
||||
import Content from "./select-content.svelte";
|
||||
import Trigger from "./select-trigger.svelte";
|
||||
import Separator from "./select-separator.svelte";
|
||||
import ScrollDownButton from "./select-scroll-down-button.svelte";
|
||||
import ScrollUpButton from "./select-scroll-up-button.svelte";
|
||||
import GroupHeading from "./select-group-heading.svelte";
|
||||
import Portal from "./select-portal.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Group,
|
||||
Label,
|
||||
Item,
|
||||
Content,
|
||||
Trigger,
|
||||
Separator,
|
||||
ScrollDownButton,
|
||||
ScrollUpButton,
|
||||
GroupHeading,
|
||||
Portal,
|
||||
//
|
||||
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,
|
||||
};
|
||||
45
frontend/src/lib/components/ui/select/select-content.svelte
Normal file
45
frontend/src/lib/components/ui/select/select-content.svelte
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import SelectPortal from "./select-portal.svelte";
|
||||
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
|
||||
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
import type { WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
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-[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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
class={cn(
|
||||
"h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1"
|
||||
)}
|
||||
>
|
||||
{@render children?.()}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPortal>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Group bind:ref data-slot="select-group" {...restProps} />
|
||||
38
frontend/src/lib/components/ui/select/select-item.svelte
Normal file
38
frontend/src/lib/components/ui/select/select-item.svelte
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<script lang="ts">
|
||||
import CheckIcon from "@lucide/svelte/icons/check";
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
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}
|
||||
data-slot="select-item"
|
||||
class={cn(
|
||||
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 ps-2 pe-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ selected, highlighted })}
|
||||
<span class="absolute end-2 flex size-3.5 items-center justify-center">
|
||||
{#if selected}
|
||||
<CheckIcon class="size-4" />
|
||||
{/if}
|
||||
</span>
|
||||
{#if childrenProp}
|
||||
{@render childrenProp({ selected, highlighted })}
|
||||
{:else}
|
||||
{label || value}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SelectPrimitive.Item>
|
||||
20
frontend/src/lib/components/ui/select/select-label.svelte
Normal file
20
frontend/src/lib/components/ui/select/select-label.svelte
Normal file
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
|
||||
let { ...restProps }: SelectPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Portal {...restProps} />
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
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("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...restProps}
|
||||
>
|
||||
<ChevronDownIcon class="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
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("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...restProps}
|
||||
>
|
||||
<ChevronUpIcon class="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<script lang="ts">
|
||||
import type { Separator as SeparatorPrimitive } from "bits-ui";
|
||||
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: SeparatorPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<Separator
|
||||
bind:ref
|
||||
data-slot="select-separator"
|
||||
class={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
29
frontend/src/lib/components/ui/select/select-trigger.svelte
Normal file
29
frontend/src/lib/components/ui/select/select-trigger.svelte
Normal file
|
|
@ -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 [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<ChevronDownIcon class="size-4 opacity-50" />
|
||||
</SelectPrimitive.Trigger>
|
||||
11
frontend/src/lib/components/ui/select/select.svelte
Normal file
11
frontend/src/lib/components/ui/select/select.svelte
Normal file
|
|
@ -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} />
|
||||
7
frontend/src/lib/components/ui/separator/index.ts
Normal file
7
frontend/src/lib/components/ui/separator/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import Root from "./separator.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Separator,
|
||||
};
|
||||
21
frontend/src/lib/components/ui/separator/separator.svelte
Normal file
21
frontend/src/lib/components/ui/separator/separator.svelte
Normal file
|
|
@ -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(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:min-h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
28
frontend/src/lib/components/ui/table/index.ts
Normal file
28
frontend/src/lib/components/ui/table/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import Root from "./table.svelte";
|
||||
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";
|
||||
|
||||
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,
|
||||
};
|
||||
20
frontend/src/lib/components/ui/table/table-body.svelte
Normal file
20
frontend/src/lib/components/ui/table/table-body.svelte
Normal file
|
|
@ -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>
|
||||
|
||||
<tbody
|
||||
bind:this={ref}
|
||||
data-slot="table-body"
|
||||
class={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</tbody>
|
||||
20
frontend/src/lib/components/ui/table/table-caption.svelte
Normal file
20
frontend/src/lib/components/ui/table/table-caption.svelte
Normal file
|
|
@ -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-sm", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</caption>
|
||||
23
frontend/src/lib/components/ui/table/table-cell.svelte
Normal file
23
frontend/src/lib/components/ui/table/table-cell.svelte
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<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(
|
||||
"bg-clip-padding p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pe-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</td>
|
||||
20
frontend/src/lib/components/ui/table/table-footer.svelte
Normal file
20
frontend/src/lib/components/ui/table/table-footer.svelte
Normal file
|
|
@ -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>
|
||||
23
frontend/src/lib/components/ui/table/table-head.svelte
Normal file
23
frontend/src/lib/components/ui/table/table-head.svelte
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<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 bg-clip-padding px-2 text-start align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pe-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</th>
|
||||
20
frontend/src/lib/components/ui/table/table-header.svelte
Normal file
20
frontend/src/lib/components/ui/table/table-header.svelte
Normal file
|
|
@ -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>
|
||||
23
frontend/src/lib/components/ui/table/table-row.svelte
Normal file
23
frontend/src/lib/components/ui/table/table-row.svelte
Normal file
|
|
@ -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<HTMLTableRowElement>> = $props();
|
||||
</script>
|
||||
|
||||
<tr
|
||||
bind:this={ref}
|
||||
data-slot="table-row"
|
||||
class={cn(
|
||||
"hover:[&,&>svelte-css-wrapper]:[&>th,td]:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</tr>
|
||||
22
frontend/src/lib/components/ui/table/table.svelte
Normal file
22
frontend/src/lib/components/ui/table/table.svelte
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLTableAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
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-sm", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</table>
|
||||
</div>
|
||||
16
frontend/src/lib/components/ui/tabs/index.ts
Normal file
16
frontend/src/lib/components/ui/tabs/index.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import Root from "./tabs.svelte";
|
||||
import Content from "./tabs-content.svelte";
|
||||
import List from "./tabs-list.svelte";
|
||||
import Trigger from "./tabs-trigger.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
List,
|
||||
Trigger,
|
||||
//
|
||||
Root as Tabs,
|
||||
Content as TabsContent,
|
||||
List as TabsList,
|
||||
Trigger as TabsTrigger,
|
||||
};
|
||||
17
frontend/src/lib/components/ui/tabs/tabs-content.svelte
Normal file
17
frontend/src/lib/components/ui/tabs/tabs-content.svelte
Normal file
|
|
@ -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("flex-1 outline-none", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
20
frontend/src/lib/components/ui/tabs/tabs-list.svelte
Normal file
20
frontend/src/lib/components/ui/tabs/tabs-list.svelte
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: TabsPrimitive.ListProps = $props();
|
||||
</script>
|
||||
|
||||
<TabsPrimitive.List
|
||||
bind:ref
|
||||
data-slot="tabs-list"
|
||||
class={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
20
frontend/src/lib/components/ui/tabs/tabs-trigger.svelte
Normal file
20
frontend/src/lib/components/ui/tabs/tabs-trigger.svelte
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<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(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
19
frontend/src/lib/components/ui/tabs/tabs.svelte
Normal file
19
frontend/src/lib/components/ui/tabs/tabs.svelte
Normal file
|
|
@ -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("flex flex-col gap-2", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
286
frontend/src/lib/types.ts
Normal file
286
frontend/src/lib/types.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
// ─── Enums ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type AbsorptionSpeed = 'very_fast' | 'fast' | 'moderate' | 'slow' | 'very_slow';
|
||||
export type BarrierState = 'intact' | 'mildly_compromised' | 'compromised';
|
||||
export type DayTime = 'am' | 'pm' | 'both';
|
||||
export type EvidenceLevel = 'low' | 'mixed' | 'moderate' | 'high';
|
||||
export type GroomingAction = 'shaving_razor' | 'shaving_oneblade' | 'dermarolling';
|
||||
export type IngredientFunction =
|
||||
| 'humectant'
|
||||
| 'emollient'
|
||||
| 'occlusive'
|
||||
| 'exfoliant_aha'
|
||||
| 'exfoliant_bha'
|
||||
| 'exfoliant_pha'
|
||||
| 'retinoid'
|
||||
| 'antioxidant'
|
||||
| 'soothing'
|
||||
| 'barrier_support'
|
||||
| 'brightening'
|
||||
| 'anti_acne'
|
||||
| 'ceramide'
|
||||
| 'niacinamide'
|
||||
| 'sunscreen'
|
||||
| 'peptide'
|
||||
| 'hair_growth_stimulant'
|
||||
| 'prebiotic'
|
||||
| 'vitamin_c';
|
||||
export type InteractionScope = 'same_step' | 'same_day' | 'same_period';
|
||||
export type MedicationKind = 'prescription' | 'otc' | 'supplement' | 'herbal' | 'other';
|
||||
export type OverallSkinState = 'excellent' | 'good' | 'fair' | 'poor';
|
||||
export type PartOfDay = 'am' | 'pm';
|
||||
export type PriceTier = 'budget' | 'mid' | 'premium' | 'luxury';
|
||||
export type ProductCategory =
|
||||
| 'cleanser'
|
||||
| 'toner'
|
||||
| 'essence'
|
||||
| 'serum'
|
||||
| 'moisturizer'
|
||||
| 'spf'
|
||||
| 'mask'
|
||||
| 'exfoliant'
|
||||
| 'hair_treatment'
|
||||
| 'tool'
|
||||
| 'spot_treatment'
|
||||
| 'oil';
|
||||
export type ResultFlag = 'N' | 'ABN' | 'POS' | 'NEG' | 'L' | 'H';
|
||||
export type RoutineRole =
|
||||
| 'cleanse'
|
||||
| 'prepare'
|
||||
| 'treatment_active'
|
||||
| 'treatment_support'
|
||||
| 'seal'
|
||||
| 'protect'
|
||||
| 'hair_treatment';
|
||||
export type SkinConcern =
|
||||
| 'acne'
|
||||
| 'rosacea'
|
||||
| 'hyperpigmentation'
|
||||
| 'aging'
|
||||
| 'dehydration'
|
||||
| 'redness'
|
||||
| 'damaged_barrier'
|
||||
| 'pore_visibility'
|
||||
| 'uneven_texture'
|
||||
| 'hair_growth'
|
||||
| 'sebum_excess';
|
||||
export type SkinTrend = 'improving' | 'stable' | 'worsening' | 'fluctuating';
|
||||
export type SkinType = 'dry' | 'oily' | 'combination' | 'sensitive' | 'normal' | 'acne_prone';
|
||||
export type StrengthLevel = 1 | 2 | 3;
|
||||
export type TextureType = 'watery' | 'gel' | 'emulsion' | 'cream' | 'oil' | 'balm' | 'foam' | 'fluid';
|
||||
export type UsageFrequency =
|
||||
| 'daily'
|
||||
| 'twice_daily'
|
||||
| 'every_other_day'
|
||||
| 'twice_weekly'
|
||||
| 'three_times_weekly'
|
||||
| 'weekly'
|
||||
| 'as_needed';
|
||||
|
||||
// ─── Product types ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface ActiveIngredient {
|
||||
name: string;
|
||||
percent?: number;
|
||||
functions: IngredientFunction[];
|
||||
strength_level?: StrengthLevel;
|
||||
irritation_potential?: StrengthLevel;
|
||||
cumulative_with?: IngredientFunction[];
|
||||
}
|
||||
|
||||
export interface ProductEffectProfile {
|
||||
hydration_immediate: number;
|
||||
hydration_long_term: number;
|
||||
barrier_repair_strength: number;
|
||||
soothing_strength: number;
|
||||
exfoliation_strength: number;
|
||||
retinoid_strength: number;
|
||||
irritation_risk: number;
|
||||
comedogenic_risk: number;
|
||||
barrier_disruption_risk: number;
|
||||
dryness_risk: number;
|
||||
brightening_strength: number;
|
||||
anti_acne_strength: number;
|
||||
anti_aging_strength: number;
|
||||
}
|
||||
|
||||
export interface ProductInteraction {
|
||||
target: string;
|
||||
scope: InteractionScope;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ProductContext {
|
||||
safe_after_shaving?: boolean;
|
||||
safe_after_acids?: boolean;
|
||||
safe_after_retinoids?: boolean;
|
||||
safe_with_compromised_barrier?: boolean;
|
||||
low_uv_only?: boolean;
|
||||
}
|
||||
|
||||
export interface ProductInventory {
|
||||
id: string;
|
||||
product_id: string;
|
||||
is_opened: boolean;
|
||||
opened_at?: string;
|
||||
finished_at?: string;
|
||||
expiry_date?: string;
|
||||
current_weight_g?: number;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
product?: Product;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string;
|
||||
line_name?: string;
|
||||
sku?: string;
|
||||
url?: string;
|
||||
barcode?: string;
|
||||
category: ProductCategory;
|
||||
routine_role: RoutineRole;
|
||||
recommended_time: DayTime;
|
||||
texture?: TextureType;
|
||||
absorption_speed?: AbsorptionSpeed;
|
||||
leave_on: boolean;
|
||||
price_tier?: PriceTier;
|
||||
size_ml?: number;
|
||||
pao_months?: number;
|
||||
inci: string[];
|
||||
actives?: ActiveIngredient[];
|
||||
recommended_for: SkinType[];
|
||||
recommended_frequency?: UsageFrequency;
|
||||
targets: SkinConcern[];
|
||||
contraindications: string[];
|
||||
usage_notes?: string;
|
||||
evidence_level?: EvidenceLevel;
|
||||
claims: string[];
|
||||
fragrance_free?: boolean;
|
||||
essential_oils_free?: boolean;
|
||||
alcohol_denat_free?: boolean;
|
||||
pregnancy_safe?: boolean;
|
||||
product_effect_profile: ProductEffectProfile;
|
||||
ph_min?: number;
|
||||
ph_max?: number;
|
||||
incompatible_with?: ProductInteraction[];
|
||||
synergizes_with?: string[];
|
||||
context_rules?: ProductContext;
|
||||
min_interval_hours?: number;
|
||||
max_frequency_per_week?: number;
|
||||
is_medication: boolean;
|
||||
is_tool: boolean;
|
||||
needle_length_mm?: number;
|
||||
personal_rating?: number;
|
||||
personal_tolerance_notes?: string;
|
||||
personal_repurchase_intent?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
inventory: ProductInventory[];
|
||||
}
|
||||
|
||||
// ─── Routine types ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface RoutineStep {
|
||||
id: string;
|
||||
routine_id: string;
|
||||
product_id?: string;
|
||||
order_index: number;
|
||||
action_type?: GroomingAction;
|
||||
action_notes?: string;
|
||||
dose?: string;
|
||||
region?: string;
|
||||
product?: Product;
|
||||
}
|
||||
|
||||
export interface Routine {
|
||||
id: string;
|
||||
routine_date: string;
|
||||
part_of_day: PartOfDay;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
steps: RoutineStep[];
|
||||
}
|
||||
|
||||
export interface GroomingSchedule {
|
||||
id: string;
|
||||
day_of_week: number;
|
||||
action: GroomingAction;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// ─── Health types ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MedicationUsage {
|
||||
record_id: string;
|
||||
medication_record_id: string;
|
||||
dose_value?: number;
|
||||
dose_unit?: string;
|
||||
frequency?: string;
|
||||
schedule_text?: string;
|
||||
as_needed: boolean;
|
||||
valid_from: string;
|
||||
valid_to?: string;
|
||||
source_file?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MedicationEntry {
|
||||
record_id: string;
|
||||
kind: MedicationKind;
|
||||
product_name: string;
|
||||
active_substance?: string;
|
||||
formulation?: string;
|
||||
route?: string;
|
||||
source_file?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
usage_history: MedicationUsage[];
|
||||
}
|
||||
|
||||
export interface LabResult {
|
||||
record_id: string;
|
||||
collected_at: string;
|
||||
test_code: string;
|
||||
test_name_original?: string;
|
||||
test_name_loinc?: string;
|
||||
value_num?: number;
|
||||
value_text?: string;
|
||||
value_bool?: boolean;
|
||||
unit_original?: string;
|
||||
unit_ucum?: string;
|
||||
ref_low?: number;
|
||||
ref_high?: number;
|
||||
ref_text?: string;
|
||||
flag?: ResultFlag;
|
||||
lab?: string;
|
||||
source_file?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ─── Skin types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SkinConditionSnapshot {
|
||||
id: string;
|
||||
snapshot_date: string;
|
||||
overall_state?: OverallSkinState;
|
||||
trend?: SkinTrend;
|
||||
skin_type?: SkinType;
|
||||
hydration_level?: number;
|
||||
sebum_tzone?: number;
|
||||
sebum_cheeks?: number;
|
||||
sensitivity_level?: number;
|
||||
barrier_state?: BarrierState;
|
||||
active_concerns: SkinConcern[];
|
||||
risks: string[];
|
||||
priorities: string[];
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue