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
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
PUBLIC_API_BASE=http://localhost:8000
|
||||||
|
|
@ -12,12 +12,15 @@
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@internationalized/date": "^3.11.0",
|
||||||
|
"@lucide/svelte": "^0.561.0",
|
||||||
"@sveltejs/adapter-auto": "^7.0.0",
|
"@sveltejs/adapter-auto": "^7.0.0",
|
||||||
"@sveltejs/kit": "^2.50.2",
|
"@sveltejs/kit": "^2.50.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"svelte": "^5.51.0",
|
"svelte": "^5.51.0",
|
||||||
"svelte-check": "^4.3.6",
|
"svelte-check": "^4.3.6",
|
||||||
|
"tailwind-variants": "^3.2.2",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
|
|
|
||||||
34
frontend/pnpm-lock.yaml
generated
34
frontend/pnpm-lock.yaml
generated
|
|
@ -24,6 +24,12 @@ importers:
|
||||||
specifier: ^3.5.0
|
specifier: ^3.5.0
|
||||||
version: 3.5.0
|
version: 3.5.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@internationalized/date':
|
||||||
|
specifier: ^3.11.0
|
||||||
|
version: 3.11.0
|
||||||
|
'@lucide/svelte':
|
||||||
|
specifier: ^0.561.0
|
||||||
|
version: 0.561.0(svelte@5.53.5)
|
||||||
'@sveltejs/adapter-auto':
|
'@sveltejs/adapter-auto':
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.0.1(@sveltejs/kit@2.53.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))
|
version: 7.0.1(@sveltejs/kit@2.53.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))
|
||||||
|
|
@ -42,6 +48,9 @@ importers:
|
||||||
svelte-check:
|
svelte-check:
|
||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 4.4.4(picomatch@4.0.3)(svelte@5.53.5)(typescript@5.9.3)
|
version: 4.4.4(picomatch@4.0.3)(svelte@5.53.5)(typescript@5.9.3)
|
||||||
|
tailwind-variants:
|
||||||
|
specifier: ^3.2.2
|
||||||
|
version: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1)
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.2.1
|
specifier: ^4.2.1
|
||||||
version: 4.2.1
|
version: 4.2.1
|
||||||
|
|
@ -238,6 +247,11 @@ packages:
|
||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
|
'@lucide/svelte@0.561.0':
|
||||||
|
resolution: {integrity: sha512-vofKV2UFVrKE6I4ewKJ3dfCXSV6iP6nWVmiM83MLjsU91EeJcEg7LoWUABLp/aOTxj1HQNbJD1f3g3L0JQgH9A==}
|
||||||
|
peerDependencies:
|
||||||
|
svelte: ^5
|
||||||
|
|
||||||
'@polka/url@1.0.0-next.29':
|
'@polka/url@1.0.0-next.29':
|
||||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||||
|
|
||||||
|
|
@ -819,6 +833,16 @@ packages:
|
||||||
tailwind-merge@3.5.0:
|
tailwind-merge@3.5.0:
|
||||||
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
|
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
|
||||||
|
|
||||||
|
tailwind-variants@3.2.2:
|
||||||
|
resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==}
|
||||||
|
engines: {node: '>=16.x', pnpm: '>=7.x'}
|
||||||
|
peerDependencies:
|
||||||
|
tailwind-merge: '>=3.0.0'
|
||||||
|
tailwindcss: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
tailwind-merge:
|
||||||
|
optional: true
|
||||||
|
|
||||||
tailwindcss@4.2.1:
|
tailwindcss@4.2.1:
|
||||||
resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==}
|
resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==}
|
||||||
|
|
||||||
|
|
@ -1007,6 +1031,10 @@ snapshots:
|
||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
'@lucide/svelte@0.561.0(svelte@5.53.5)':
|
||||||
|
dependencies:
|
||||||
|
svelte: 5.53.5
|
||||||
|
|
||||||
'@polka/url@1.0.0-next.29': {}
|
'@polka/url@1.0.0-next.29': {}
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.59.0':
|
'@rollup/rollup-android-arm-eabi@4.59.0':
|
||||||
|
|
@ -1509,6 +1537,12 @@ snapshots:
|
||||||
|
|
||||||
tailwind-merge@3.5.0: {}
|
tailwind-merge@3.5.0: {}
|
||||||
|
|
||||||
|
tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1):
|
||||||
|
dependencies:
|
||||||
|
tailwindcss: 4.2.1
|
||||||
|
optionalDependencies:
|
||||||
|
tailwind-merge: 3.5.0
|
||||||
|
|
||||||
tailwindcss@4.2.1: {}
|
tailwindcss@4.2.1: {}
|
||||||
|
|
||||||
tapable@2.3.0: {}
|
tapable@2.3.0: {}
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,89 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@layer base {
|
@custom-variant dark (&:is(.dark *));
|
||||||
:root {
|
|
||||||
--background: 0 0% 100%;
|
/* ── CSS variable definitions (light / dark) ─────────────────────────────── */
|
||||||
--foreground: 240 10% 3.9%;
|
|
||||||
--card: 0 0% 100%;
|
:root {
|
||||||
--card-foreground: 240 10% 3.9%;
|
--background: hsl(0 0% 100%);
|
||||||
--popover: 0 0% 100%;
|
--foreground: hsl(240 10% 3.9%);
|
||||||
--popover-foreground: 240 10% 3.9%;
|
--card: hsl(0 0% 100%);
|
||||||
--primary: 240 5.9% 10%;
|
--card-foreground: hsl(240 10% 3.9%);
|
||||||
--primary-foreground: 0 0% 98%;
|
--popover: hsl(0 0% 100%);
|
||||||
--secondary: 240 4.8% 95.9%;
|
--popover-foreground: hsl(240 10% 3.9%);
|
||||||
--secondary-foreground: 240 5.9% 10%;
|
--primary: hsl(240 5.9% 10%);
|
||||||
--muted: 240 4.8% 95.9%;
|
--primary-foreground: hsl(0 0% 98%);
|
||||||
--muted-foreground: 240 3.8% 46.1%;
|
--secondary: hsl(240 4.8% 95.9%);
|
||||||
--accent: 240 4.8% 95.9%;
|
--secondary-foreground: hsl(240 5.9% 10%);
|
||||||
--accent-foreground: 240 5.9% 10%;
|
--muted: hsl(240 4.8% 95.9%);
|
||||||
--destructive: 0 84.2% 60.2%;
|
--muted-foreground: hsl(240 3.8% 46.1%);
|
||||||
--destructive-foreground: 0 0% 98%;
|
--accent: hsl(240 4.8% 95.9%);
|
||||||
--border: 240 5.9% 90%;
|
--accent-foreground: hsl(240 5.9% 10%);
|
||||||
--input: 240 5.9% 90%;
|
--destructive: hsl(0 84.2% 60.2%);
|
||||||
--ring: 240 5.9% 10%;
|
--destructive-foreground: hsl(0 0% 98%);
|
||||||
--radius: 0.5rem;
|
--border: hsl(240 5.9% 90%);
|
||||||
}
|
--input: hsl(240 5.9% 90%);
|
||||||
.dark {
|
--ring: hsl(240 5.9% 10%);
|
||||||
--background: 240 10% 3.9%;
|
--radius: 0.5rem;
|
||||||
--foreground: 0 0% 98%;
|
|
||||||
--card: 240 10% 3.9%;
|
|
||||||
--card-foreground: 0 0% 98%;
|
|
||||||
--popover: 240 10% 3.9%;
|
|
||||||
--popover-foreground: 0 0% 98%;
|
|
||||||
--primary: 0 0% 98%;
|
|
||||||
--primary-foreground: 240 5.9% 10%;
|
|
||||||
--secondary: 240 3.7% 15.9%;
|
|
||||||
--secondary-foreground: 0 0% 98%;
|
|
||||||
--muted: 240 3.7% 15.9%;
|
|
||||||
--muted-foreground: 240 5% 64.9%;
|
|
||||||
--accent: 240 3.7% 15.9%;
|
|
||||||
--accent-foreground: 0 0% 98%;
|
|
||||||
--destructive: 0 62.8% 30.6%;
|
|
||||||
--destructive-foreground: 0 0% 98%;
|
|
||||||
--border: 240 3.7% 15.9%;
|
|
||||||
--input: 240 3.7% 15.9%;
|
|
||||||
--ring: 240 4.9% 83.9%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
.dark {
|
||||||
* {
|
--background: hsl(240 10% 3.9%);
|
||||||
@apply border-border;
|
--foreground: hsl(0 0% 98%);
|
||||||
}
|
--card: hsl(240 10% 3.9%);
|
||||||
body {
|
--card-foreground: hsl(0 0% 98%);
|
||||||
@apply bg-background text-foreground;
|
--popover: hsl(240 10% 3.9%);
|
||||||
}
|
--popover-foreground: hsl(0 0% 98%);
|
||||||
|
--primary: hsl(0 0% 98%);
|
||||||
|
--primary-foreground: hsl(240 5.9% 10%);
|
||||||
|
--secondary: hsl(240 3.7% 15.9%);
|
||||||
|
--secondary-foreground: hsl(0 0% 98%);
|
||||||
|
--muted: hsl(240 3.7% 15.9%);
|
||||||
|
--muted-foreground: hsl(240 5% 64.9%);
|
||||||
|
--accent: hsl(240 3.7% 15.9%);
|
||||||
|
--accent-foreground: hsl(0 0% 98%);
|
||||||
|
--destructive: hsl(0 62.8% 30.6%);
|
||||||
|
--destructive-foreground: hsl(0 0% 98%);
|
||||||
|
--border: hsl(240 3.7% 15.9%);
|
||||||
|
--input: hsl(240 3.7% 15.9%);
|
||||||
|
--ring: hsl(240 4.9% 83.9%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Map CSS vars → Tailwind v4 design tokens ────────────────────────────── */
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Base resets ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
* {
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,51 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import '../app.css';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/', label: 'Dashboard', icon: '⌂' },
|
||||||
|
{ href: '/products', label: 'Products', icon: '🧴' },
|
||||||
|
{ href: '/routines', label: 'Routines', icon: '📋' },
|
||||||
|
{ href: '/health/medications', label: 'Medications', icon: '💊' },
|
||||||
|
{ href: '/health/lab-results', label: 'Lab Results', icon: '🔬' },
|
||||||
|
{ href: '/skin', label: 'Skin', icon: '✨' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function isActive(href: string) {
|
||||||
|
if (href === '/') return page.url.pathname === '/';
|
||||||
|
return page.url.pathname.startsWith(href);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<div class="flex min-h-screen bg-background">
|
||||||
<link rel="icon" href={favicon} />
|
<!-- Sidebar -->
|
||||||
</svelte:head>
|
<nav class="w-56 shrink-0 border-r border-border bg-card px-3 py-6">
|
||||||
|
<div class="mb-8 px-3">
|
||||||
|
<h1 class="text-lg font-semibold tracking-tight">innercontext</h1>
|
||||||
|
<p class="text-xs text-muted-foreground">personal health & skincare</p>
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{#each navItems as item}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
class="flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors
|
||||||
|
{isActive(item.href)
|
||||||
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
|
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
|
||||||
|
>
|
||||||
|
<span class="text-base">{item.icon}</span>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
{@render children()}
|
<!-- Main content -->
|
||||||
|
<main class="flex-1 overflow-auto p-8">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
|
||||||
19
frontend/src/routes/+page.server.ts
Normal file
19
frontend/src/routes/+page.server.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { getRoutines, getSkinSnapshots } from '$lib/api';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
const [routines, snapshots] = await Promise.all([
|
||||||
|
getRoutines({ from_date: recentDate(14) }),
|
||||||
|
getSkinSnapshots({ from_date: recentDate(60) })
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
recentRoutines: routines.slice(0, 10),
|
||||||
|
latestSnapshot: snapshots.at(-1) ?? null
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function recentDate(daysAgo: number): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - daysAgo);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
@ -1,2 +1,87 @@
|
||||||
<h1>Welcome to SvelteKit</h1>
|
<script lang="ts">
|
||||||
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
import type { PageData } from './$types';
|
||||||
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
const stateColors: Record<string, string> = {
|
||||||
|
excellent: 'bg-green-100 text-green-800',
|
||||||
|
good: 'bg-blue-100 text-blue-800',
|
||||||
|
fair: 'bg-yellow-100 text-yellow-800',
|
||||||
|
poor: 'bg-red-100 text-red-800'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Dashboard — innercontext</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold tracking-tight">Dashboard</h2>
|
||||||
|
<p class="text-muted-foreground">Your recent health & skincare overview</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
|
<!-- Latest skin snapshot -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Latest Skin Snapshot</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{#if data.latestSnapshot}
|
||||||
|
{@const s = data.latestSnapshot}
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-muted-foreground">{s.snapshot_date}</span>
|
||||||
|
{#if s.overall_state}
|
||||||
|
<span class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[s.overall_state] ?? ''}">
|
||||||
|
{s.overall_state}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if s.trend}
|
||||||
|
<p class="text-sm">Trend: <span class="font-medium">{s.trend}</span></p>
|
||||||
|
{/if}
|
||||||
|
{#if s.active_concerns.length}
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#each s.active_concerns as concern}
|
||||||
|
<Badge variant="secondary">{concern.replace(/_/g, ' ')}</Badge>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if s.notes}
|
||||||
|
<p class="text-sm text-muted-foreground">{s.notes}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted-foreground">No skin snapshots yet.</p>
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Recent routines -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recent Routines</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{#if data.recentRoutines.length}
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{#each data.recentRoutines as routine}
|
||||||
|
<li class="flex items-center justify-between">
|
||||||
|
<a href="/routines/{routine.id}" class="text-sm hover:underline">
|
||||||
|
{routine.routine_date}
|
||||||
|
</a>
|
||||||
|
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
|
||||||
|
{routine.part_of_day.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted-foreground">No routines in the past 2 weeks.</p>
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
|
||||||
44
frontend/src/routes/health/lab-results/+page.server.ts
Normal file
44
frontend/src/routes/health/lab-results/+page.server.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { createLabResult, getLabResults } from '$lib/api';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
|
const flag = url.searchParams.get('flag') ?? undefined;
|
||||||
|
const from_date = url.searchParams.get('from_date') ?? undefined;
|
||||||
|
const results = await getLabResults({ flag, from_date });
|
||||||
|
return { results, flag };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
create: async ({ request }) => {
|
||||||
|
const form = await request.formData();
|
||||||
|
const collected_at = form.get('collected_at') as string;
|
||||||
|
const test_code = form.get('test_code') as string;
|
||||||
|
const test_name_original = form.get('test_name_original') as string;
|
||||||
|
const value_num = form.get('value_num') as string;
|
||||||
|
const unit_original = form.get('unit_original') as string;
|
||||||
|
const flag = form.get('flag') as string;
|
||||||
|
const lab = form.get('lab') as string;
|
||||||
|
|
||||||
|
if (!collected_at || !test_code) {
|
||||||
|
return fail(400, { error: 'Date and test code are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
collected_at,
|
||||||
|
test_code
|
||||||
|
};
|
||||||
|
if (test_name_original) body.test_name_original = test_name_original;
|
||||||
|
if (value_num) body.value_num = Number(value_num);
|
||||||
|
if (unit_original) body.unit_original = unit_original;
|
||||||
|
if (flag) body.flag = flag;
|
||||||
|
if (lab) body.lab = lab;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createLabResult(body);
|
||||||
|
return { created: true };
|
||||||
|
} catch (e) {
|
||||||
|
return fail(500, { error: (e as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
176
frontend/src/routes/health/lab-results/+page.svelte
Normal file
176
frontend/src/routes/health/lab-results/+page.svelte
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { ActionData, PageData } from './$types';
|
||||||
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow
|
||||||
|
} from '$lib/components/ui/table';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
|
const flags = ['N', 'ABN', 'POS', 'NEG', 'L', 'H'];
|
||||||
|
const flagColors: Record<string, string> = {
|
||||||
|
N: 'bg-green-100 text-green-800',
|
||||||
|
ABN: 'bg-red-100 text-red-800',
|
||||||
|
POS: 'bg-orange-100 text-orange-800',
|
||||||
|
NEG: 'bg-blue-100 text-blue-800',
|
||||||
|
L: 'bg-yellow-100 text-yellow-800',
|
||||||
|
H: 'bg-red-100 text-red-800'
|
||||||
|
};
|
||||||
|
|
||||||
|
let showForm = $state(false);
|
||||||
|
let selectedFlag = $state('');
|
||||||
|
let filterFlag = $derived(data.flag ?? '');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Lab Results — innercontext</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold tracking-tight">Lab Results</h2>
|
||||||
|
<p class="text-muted-foreground">{data.results.length} results</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onclick={() => (showForm = !showForm)}>
|
||||||
|
{showForm ? 'Cancel' : '+ Add result'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
|
||||||
|
{/if}
|
||||||
|
{#if form?.created}
|
||||||
|
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Result added.</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Filter -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm text-muted-foreground">Flag:</span>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={filterFlag}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
filterFlag = v;
|
||||||
|
goto(v ? `/health/lab-results?flag=${v}` : '/health/lab-results');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger class="w-32">{filterFlag || 'All'}</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">All</SelectItem>
|
||||||
|
{#each flags as f}
|
||||||
|
<SelectItem value={f}>{f}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showForm}
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>New lab result</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form method="POST" action="?/create" use:enhance class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="collected_at">Date *</Label>
|
||||||
|
<Input id="collected_at" name="collected_at" type="date" required />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="test_code">LOINC code * <span class="text-xs text-muted-foreground">(e.g. 718-7)</span></Label>
|
||||||
|
<Input id="test_code" name="test_code" required placeholder="718-7" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="test_name_original">Test name</Label>
|
||||||
|
<Input id="test_name_original" name="test_name_original" placeholder="e.g. Hemoglobin" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="lab">Lab</Label>
|
||||||
|
<Input id="lab" name="lab" placeholder="e.g. LabCorp" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="value_num">Value</Label>
|
||||||
|
<Input id="value_num" name="value_num" type="number" step="any" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="unit_original">Unit</Label>
|
||||||
|
<Input id="unit_original" name="unit_original" placeholder="e.g. g/dL" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label>Flag</Label>
|
||||||
|
<input type="hidden" name="flag" value={selectedFlag} />
|
||||||
|
<Select type="single" value={selectedFlag} onValueChange={(v) => (selectedFlag = v)}>
|
||||||
|
<SelectTrigger>{selectedFlag || 'None'}</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">None</SelectItem>
|
||||||
|
{#each flags as f}
|
||||||
|
<SelectItem value={f}>{f}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<Button type="submit">Add</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="rounded-md border border-border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Test</TableHead>
|
||||||
|
<TableHead>LOINC</TableHead>
|
||||||
|
<TableHead>Value</TableHead>
|
||||||
|
<TableHead>Flag</TableHead>
|
||||||
|
<TableHead>Lab</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{#each data.results as r}
|
||||||
|
<TableRow>
|
||||||
|
<TableCell class="text-sm">{r.collected_at.slice(0, 10)}</TableCell>
|
||||||
|
<TableCell class="font-medium">{r.test_name_original ?? r.test_code}</TableCell>
|
||||||
|
<TableCell class="text-xs text-muted-foreground font-mono">{r.test_code}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{#if r.value_num != null}
|
||||||
|
{r.value_num} {r.unit_original ?? ''}
|
||||||
|
{:else if r.value_text}
|
||||||
|
{r.value_text}
|
||||||
|
{:else}
|
||||||
|
—
|
||||||
|
{/if}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{#if r.flag}
|
||||||
|
<span class="rounded-full px-2 py-0.5 text-xs font-medium {flagColors[r.flag] ?? ''}">
|
||||||
|
{r.flag}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
—
|
||||||
|
{/if}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="text-sm text-muted-foreground">{r.lab ?? '—'}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{:else}
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colspan={6} class="text-center text-muted-foreground py-8">
|
||||||
|
No lab results found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{/each}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
35
frontend/src/routes/health/medications/+page.server.ts
Normal file
35
frontend/src/routes/health/medications/+page.server.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { createMedication, getMedications } from '$lib/api';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
|
const kind = url.searchParams.get('kind') ?? undefined;
|
||||||
|
const medications = await getMedications({ kind });
|
||||||
|
return { medications, kind };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
create: async ({ request }) => {
|
||||||
|
const form = await request.formData();
|
||||||
|
const kind = form.get('kind') as string;
|
||||||
|
const product_name = form.get('product_name') as string;
|
||||||
|
const active_substance = form.get('active_substance') as string;
|
||||||
|
const notes = form.get('notes') as string;
|
||||||
|
|
||||||
|
if (!kind || !product_name) {
|
||||||
|
return fail(400, { error: 'Kind and product name are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createMedication({
|
||||||
|
kind,
|
||||||
|
product_name,
|
||||||
|
active_substance: active_substance || undefined,
|
||||||
|
notes: notes || undefined
|
||||||
|
});
|
||||||
|
return { created: true };
|
||||||
|
} catch (e) {
|
||||||
|
return fail(500, { error: (e as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
106
frontend/src/routes/health/medications/+page.svelte
Normal file
106
frontend/src/routes/health/medications/+page.svelte
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { ActionData, PageData } from './$types';
|
||||||
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
||||||
|
|
||||||
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
|
const kinds = ['prescription', 'otc', 'supplement', 'herbal', 'other'];
|
||||||
|
let showForm = $state(false);
|
||||||
|
let kind = $state('supplement');
|
||||||
|
|
||||||
|
const kindColors: Record<string, string> = {
|
||||||
|
prescription: 'bg-purple-100 text-purple-800',
|
||||||
|
otc: 'bg-blue-100 text-blue-800',
|
||||||
|
supplement: 'bg-green-100 text-green-800',
|
||||||
|
herbal: 'bg-emerald-100 text-emerald-800',
|
||||||
|
other: 'bg-gray-100 text-gray-700'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Medications — innercontext</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold tracking-tight">Medications</h2>
|
||||||
|
<p class="text-muted-foreground">{data.medications.length} entries</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onclick={() => (showForm = !showForm)}>
|
||||||
|
{showForm ? 'Cancel' : '+ Add medication'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
|
||||||
|
{/if}
|
||||||
|
{#if form?.created}
|
||||||
|
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Medication added.</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showForm}
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>New medication</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form method="POST" action="?/create" use:enhance class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1 col-span-2">
|
||||||
|
<Label>Kind</Label>
|
||||||
|
<input type="hidden" name="kind" value={kind} />
|
||||||
|
<Select type="single" value={kind} onValueChange={(v) => (kind = v)}>
|
||||||
|
<SelectTrigger>{kind}</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each kinds as k}
|
||||||
|
<SelectItem value={k}>{k}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="product_name">Product name *</Label>
|
||||||
|
<Input id="product_name" name="product_name" required placeholder="e.g. Vitamin D3" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="active_substance">Active substance</Label>
|
||||||
|
<Input id="active_substance" name="active_substance" placeholder="e.g. cholecalciferol" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 col-span-2">
|
||||||
|
<Label for="notes">Notes</Label>
|
||||||
|
<Input id="notes" name="notes" />
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<Button type="submit">Add</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each data.medications as med}
|
||||||
|
<div class="rounded-md border border-border px-4 py-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="rounded-full px-2 py-0.5 text-xs font-medium {kindColors[med.kind] ?? ''}">
|
||||||
|
{med.kind}
|
||||||
|
</span>
|
||||||
|
<span class="font-medium">{med.product_name}</span>
|
||||||
|
{#if med.active_substance}
|
||||||
|
<span class="text-sm text-muted-foreground">{med.active_substance}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary">{med.usage_history.length} usages</Badge>
|
||||||
|
</div>
|
||||||
|
{#if med.notes}
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">{med.notes}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted-foreground">No medications recorded.</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
8
frontend/src/routes/products/+page.server.ts
Normal file
8
frontend/src/routes/products/+page.server.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { getProducts } from '$lib/api';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
|
const category = url.searchParams.get('category') ?? undefined;
|
||||||
|
const products = await getProducts({ category });
|
||||||
|
return { products, category };
|
||||||
|
};
|
||||||
114
frontend/src/routes/products/+page.svelte
Normal file
114
frontend/src/routes/products/+page.svelte
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow
|
||||||
|
} from '$lib/components/ui/table';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
'cleanser', 'toner', 'essence', 'serum', 'moisturizer',
|
||||||
|
'spf', 'mask', 'exfoliant', 'hair_treatment', 'tool', 'spot_treatment', 'oil'
|
||||||
|
];
|
||||||
|
|
||||||
|
let selectedCategory = $derived(data.category ?? '');
|
||||||
|
|
||||||
|
function filterByCategory(cat: string) {
|
||||||
|
selectedCategory = cat;
|
||||||
|
goto(cat ? `/products?category=${cat}` : '/products');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Products — innercontext</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold tracking-tight">Products</h2>
|
||||||
|
<p class="text-muted-foreground">{data.products.length} products</p>
|
||||||
|
</div>
|
||||||
|
<Button href="/products/new">+ Add product</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm text-muted-foreground">Filter by category:</span>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={selectedCategory}
|
||||||
|
onValueChange={filterByCategory}
|
||||||
|
>
|
||||||
|
<SelectTrigger class="w-48">
|
||||||
|
{selectedCategory ? selectedCategory.replace(/_/g, ' ') : 'All categories'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">All categories</SelectItem>
|
||||||
|
{#each categories as cat}
|
||||||
|
<SelectItem value={cat}>{cat.replace(/_/g, ' ')}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-md border border-border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Brand</TableHead>
|
||||||
|
<TableHead>Category</TableHead>
|
||||||
|
<TableHead>Targets</TableHead>
|
||||||
|
<TableHead>Time</TableHead>
|
||||||
|
<TableHead>Rating</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{#each data.products as product}
|
||||||
|
<TableRow class="cursor-pointer hover:bg-muted/50">
|
||||||
|
<TableCell>
|
||||||
|
<a href="/products/{product.id}" class="font-medium hover:underline">
|
||||||
|
{product.name}
|
||||||
|
</a>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="text-muted-foreground">{product.brand}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{product.category.replace(/_/g, ' ')}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#each product.targets.slice(0, 3) as t}
|
||||||
|
<Badge variant="secondary" class="text-xs">{t.replace(/_/g, ' ')}</Badge>
|
||||||
|
{/each}
|
||||||
|
{#if product.targets.length > 3}
|
||||||
|
<span class="text-xs text-muted-foreground">+{product.targets.length - 3}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="uppercase text-sm">{product.recommended_time}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{#if product.personal_rating}
|
||||||
|
{product.personal_rating}/10
|
||||||
|
{:else}
|
||||||
|
<span class="text-muted-foreground">—</span>
|
||||||
|
{/if}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{:else}
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colspan={6} class="text-center text-muted-foreground py-8">
|
||||||
|
No products found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{/each}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
58
frontend/src/routes/products/[id]/+page.server.ts
Normal file
58
frontend/src/routes/products/[id]/+page.server.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { createInventory, deleteProduct, getProduct, updateProduct } from '$lib/api';
|
||||||
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
|
try {
|
||||||
|
const product = await getProduct(params.id);
|
||||||
|
return { product };
|
||||||
|
} catch {
|
||||||
|
error(404, 'Product not found');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
update: async ({ params, request }) => {
|
||||||
|
const form = await request.formData();
|
||||||
|
const body: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of form.entries()) {
|
||||||
|
if (value !== '') body[key] = value;
|
||||||
|
}
|
||||||
|
if ('leave_on' in body) body.leave_on = body.leave_on === 'true';
|
||||||
|
if ('personal_rating' in body) body.personal_rating = Number(body.personal_rating);
|
||||||
|
try {
|
||||||
|
const product = await updateProduct(params.id, body);
|
||||||
|
return { success: true, product };
|
||||||
|
} catch (e) {
|
||||||
|
return fail(500, { error: (e as Error).message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async ({ params }) => {
|
||||||
|
try {
|
||||||
|
await deleteProduct(params.id);
|
||||||
|
} catch (e) {
|
||||||
|
return fail(500, { error: (e as Error).message });
|
||||||
|
}
|
||||||
|
redirect(303, '/products');
|
||||||
|
},
|
||||||
|
|
||||||
|
addInventory: async ({ params, request }) => {
|
||||||
|
const form = await request.formData();
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
is_opened: form.get('is_opened') === 'true'
|
||||||
|
};
|
||||||
|
const expiry = form.get('expiry_date');
|
||||||
|
if (expiry) body.expiry_date = expiry;
|
||||||
|
const weight = form.get('current_weight_g');
|
||||||
|
if (weight) body.current_weight_g = Number(weight);
|
||||||
|
const notes = form.get('notes');
|
||||||
|
if (notes) body.notes = notes;
|
||||||
|
try {
|
||||||
|
await createInventory(params.id, body);
|
||||||
|
return { inventoryAdded: true };
|
||||||
|
} catch (e) {
|
||||||
|
return fail(500, { error: (e as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
185
frontend/src/routes/products/[id]/+page.svelte
Normal file
185
frontend/src/routes/products/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { ActionData, PageData } from './$types';
|
||||||
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
|
|
||||||
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
let { product } = $derived(data);
|
||||||
|
|
||||||
|
let showInventoryForm = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>{product.name} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="max-w-2xl space-y-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<a href="/products" class="text-sm text-muted-foreground hover:underline">← Products</a>
|
||||||
|
<h2 class="text-2xl font-bold tracking-tight">{product.name}</h2>
|
||||||
|
<Badge variant="outline">{product.category.replace(/_/g, ' ')}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
|
||||||
|
{/if}
|
||||||
|
{#if form?.success}
|
||||||
|
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Saved.</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Product info -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Details</CardTitle></CardHeader>
|
||||||
|
<CardContent class="space-y-3 text-sm">
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<span class="text-muted-foreground">Brand</span><span>{product.brand}</span>
|
||||||
|
<span class="text-muted-foreground">Line</span><span>{product.line_name ?? '—'}</span>
|
||||||
|
<span class="text-muted-foreground">Routine role</span><span>{product.routine_role.replace(/_/g, ' ')}</span>
|
||||||
|
<span class="text-muted-foreground">Time</span><span class="uppercase">{product.recommended_time}</span>
|
||||||
|
<span class="text-muted-foreground">Leave-on</span><span>{product.leave_on ? 'Yes' : 'No'}</span>
|
||||||
|
<span class="text-muted-foreground">Texture</span><span>{product.texture ?? '—'}</span>
|
||||||
|
<span class="text-muted-foreground">pH</span>
|
||||||
|
<span>
|
||||||
|
{#if product.ph_min != null && product.ph_max != null}
|
||||||
|
{product.ph_min}–{product.ph_max}
|
||||||
|
{:else}
|
||||||
|
—
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="text-muted-foreground">Rating</span>
|
||||||
|
<span>{product.personal_rating != null ? `${product.personal_rating}/10` : '—'}</span>
|
||||||
|
</div>
|
||||||
|
{#if product.targets.length}
|
||||||
|
<div>
|
||||||
|
<span class="text-muted-foreground">Targets: </span>
|
||||||
|
{#each product.targets as t}
|
||||||
|
<Badge variant="secondary" class="mr-1">{t.replace(/_/g, ' ')}</Badge>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if product.actives?.length}
|
||||||
|
<div>
|
||||||
|
<p class="text-muted-foreground mb-1">Actives:</p>
|
||||||
|
<ul class="list-disc pl-4 space-y-0.5">
|
||||||
|
{#each product.actives as a}
|
||||||
|
<li>{a.name}{a.percent != null ? ` ${a.percent}%` : ''}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if product.usage_notes}
|
||||||
|
<p class="text-muted-foreground">{product.usage_notes}</p>
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Quick edit -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Quick edit</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form method="POST" action="?/update" use:enhance class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="personal_rating">Personal rating (1-10)</Label>
|
||||||
|
<Input
|
||||||
|
id="personal_rating"
|
||||||
|
name="personal_rating"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
value={product.personal_rating ?? ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="personal_tolerance_notes">Tolerance notes</Label>
|
||||||
|
<Input
|
||||||
|
id="personal_tolerance_notes"
|
||||||
|
name="personal_tolerance_notes"
|
||||||
|
value={product.personal_tolerance_notes ?? ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<Button type="submit" size="sm">Save</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<!-- Inventory -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold">Inventory packages ({product.inventory.length})</h3>
|
||||||
|
<Button variant="outline" size="sm" onclick={() => (showInventoryForm = !showInventoryForm)}>
|
||||||
|
{showInventoryForm ? 'Cancel' : '+ Add package'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.inventoryAdded}
|
||||||
|
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Package added.</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showInventoryForm}
|
||||||
|
<Card>
|
||||||
|
<CardContent class="pt-4">
|
||||||
|
<form method="POST" action="?/addInventory" use:enhance class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="expiry_date">Expiry date</Label>
|
||||||
|
<Input id="expiry_date" name="expiry_date" type="date" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="current_weight_g">Current weight (g)</Label>
|
||||||
|
<Input id="current_weight_g" name="current_weight_g" type="number" min="0" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="notes">Notes</Label>
|
||||||
|
<Input id="notes" name="notes" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<Button type="submit" size="sm">Add</Button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="is_opened" value="false" />
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if product.inventory.length}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each product.inventory as pkg}
|
||||||
|
<div class="rounded-md border border-border px-4 py-3 text-sm flex items-center justify-between">
|
||||||
|
<div class="space-x-3">
|
||||||
|
<Badge variant={pkg.is_opened ? 'default' : 'secondary'}>
|
||||||
|
{pkg.is_opened ? 'Open' : 'Sealed'}
|
||||||
|
</Badge>
|
||||||
|
{#if pkg.expiry_date}
|
||||||
|
<span class="text-muted-foreground">Exp: {pkg.expiry_date}</span>
|
||||||
|
{/if}
|
||||||
|
{#if pkg.current_weight_g}
|
||||||
|
<span class="text-muted-foreground">{pkg.current_weight_g}g remaining</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if pkg.notes}
|
||||||
|
<span class="text-muted-foreground">{pkg.notes}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted-foreground">No inventory packages.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<!-- Danger zone -->
|
||||||
|
<div>
|
||||||
|
<form method="POST" action="?/delete" use:enhance
|
||||||
|
onsubmit={(e) => { if (!confirm('Delete this product?')) e.preventDefault(); }}>
|
||||||
|
<Button type="submit" variant="destructive" size="sm">Delete product</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
44
frontend/src/routes/products/new/+page.server.ts
Normal file
44
frontend/src/routes/products/new/+page.server.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { createProduct } from '$lib/api';
|
||||||
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async ({ request }) => {
|
||||||
|
const form = await request.formData();
|
||||||
|
|
||||||
|
const name = form.get('name') as string;
|
||||||
|
const brand = form.get('brand') as string;
|
||||||
|
const category = form.get('category') as string;
|
||||||
|
const recommended_time = form.get('recommended_time') as string;
|
||||||
|
const routine_role = form.get('routine_role') as string;
|
||||||
|
const leave_on = form.get('leave_on') === 'true';
|
||||||
|
|
||||||
|
if (!name || !brand || !category || !recommended_time || !routine_role) {
|
||||||
|
return fail(400, { error: 'Required fields missing' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = (form.get('targets') as string)
|
||||||
|
?.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean) ?? [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const product = await createProduct({
|
||||||
|
name,
|
||||||
|
brand,
|
||||||
|
category,
|
||||||
|
recommended_time,
|
||||||
|
routine_role,
|
||||||
|
leave_on,
|
||||||
|
targets
|
||||||
|
});
|
||||||
|
redirect(303, `/products/${product.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
return fail(500, { error: (e as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
127
frontend/src/routes/products/new/+page.svelte
Normal file
127
frontend/src/routes/products/new/+page.svelte
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { ActionData } from './$types';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
|
|
||||||
|
let { form }: { form: ActionData } = $props();
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
'cleanser', 'toner', 'essence', 'serum', 'moisturizer',
|
||||||
|
'spf', 'mask', 'exfoliant', 'hair_treatment', 'tool', 'spot_treatment', 'oil'
|
||||||
|
];
|
||||||
|
const routineRoles = [
|
||||||
|
'cleanse', 'prepare', 'treatment_active', 'treatment_support',
|
||||||
|
'seal', 'protect', 'hair_treatment'
|
||||||
|
];
|
||||||
|
const skinConcerns = [
|
||||||
|
'acne', 'rosacea', 'hyperpigmentation', 'aging', 'dehydration',
|
||||||
|
'redness', 'damaged_barrier', 'pore_visibility', 'uneven_texture',
|
||||||
|
'hair_growth', 'sebum_excess'
|
||||||
|
];
|
||||||
|
|
||||||
|
let category = $state('');
|
||||||
|
let routineRole = $state('');
|
||||||
|
let recommendedTime = $state('');
|
||||||
|
let leaveOn = $state('true');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>New Product — innercontext</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="max-w-xl space-y-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<a href="/products" class="text-sm text-muted-foreground hover:underline">← Products</a>
|
||||||
|
<h2 class="text-2xl font-bold tracking-tight">New Product</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||||
|
{form.error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Product details</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form method="POST" use:enhance class="space-y-5">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="name">Name *</Label>
|
||||||
|
<Input id="name" name="name" required placeholder="e.g. Hydro Boost Water Gel" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="brand">Brand *</Label>
|
||||||
|
<Input id="brand" name="brand" required placeholder="e.g. Neutrogena" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Category *</Label>
|
||||||
|
<input type="hidden" name="category" value={category} />
|
||||||
|
<Select type="single" value={category} onValueChange={(v) => (category = v)}>
|
||||||
|
<SelectTrigger>{category ? category.replace(/_/g, ' ') : 'Select category'}</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each categories as cat}
|
||||||
|
<SelectItem value={cat}>{cat.replace(/_/g, ' ')}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Routine role *</Label>
|
||||||
|
<input type="hidden" name="routine_role" value={routineRole} />
|
||||||
|
<Select type="single" value={routineRole} onValueChange={(v) => (routineRole = v)}>
|
||||||
|
<SelectTrigger>{routineRole ? routineRole.replace(/_/g, ' ') : 'Select role'}</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each routineRoles as role}
|
||||||
|
<SelectItem value={role}>{role.replace(/_/g, ' ')}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Recommended time *</Label>
|
||||||
|
<input type="hidden" name="recommended_time" value={recommendedTime} />
|
||||||
|
<Select type="single" value={recommendedTime} onValueChange={(v) => (recommendedTime = v)}>
|
||||||
|
<SelectTrigger>{recommendedTime ? recommendedTime.toUpperCase() : 'AM / PM / Both'}</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="am">AM</SelectItem>
|
||||||
|
<SelectItem value="pm">PM</SelectItem>
|
||||||
|
<SelectItem value="both">Both</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Leave-on?</Label>
|
||||||
|
<input type="hidden" name="leave_on" value={leaveOn} />
|
||||||
|
<Select type="single" value={leaveOn} onValueChange={(v) => (leaveOn = v)}>
|
||||||
|
<SelectTrigger>{leaveOn === 'true' ? 'Yes (leave-on)' : 'No (rinse-off)'}</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="true">Yes (leave-on)</SelectItem>
|
||||||
|
<SelectItem value="false">No (rinse-off)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="targets">Target concerns (comma-separated)</Label>
|
||||||
|
<Input
|
||||||
|
id="targets"
|
||||||
|
name="targets"
|
||||||
|
placeholder={skinConcerns.slice(0, 3).join(', ')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
<Button type="submit">Create product</Button>
|
||||||
|
<Button variant="outline" href="/products">Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
15
frontend/src/routes/routines/+page.server.ts
Normal file
15
frontend/src/routes/routines/+page.server.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { getRoutines } from '$lib/api';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
|
const part_of_day = url.searchParams.get('part_of_day') ?? undefined;
|
||||||
|
const from_date = url.searchParams.get('from_date') ?? recentDate(30);
|
||||||
|
const routines = await getRoutines({ from_date, part_of_day });
|
||||||
|
return { routines, part_of_day };
|
||||||
|
};
|
||||||
|
|
||||||
|
function recentDate(daysAgo: number): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - daysAgo);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
84
frontend/src/routes/routines/+page.svelte
Normal file
84
frontend/src/routes/routines/+page.svelte
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
function filterPod(pod: string) {
|
||||||
|
goto(pod ? `/routines?part_of_day=${pod}` : '/routines');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by date
|
||||||
|
const byDate = $derived(
|
||||||
|
data.routines.reduce(
|
||||||
|
(acc, r) => {
|
||||||
|
(acc[r.routine_date] ??= []).push(r);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, typeof data.routines>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const sortedDates = $derived(Object.keys(byDate).sort((a, b) => b.localeCompare(a)));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Routines — innercontext</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold tracking-tight">Routines</h2>
|
||||||
|
<p class="text-muted-foreground">{data.routines.length} routines (last 30 days)</p>
|
||||||
|
</div>
|
||||||
|
<Button href="/routines/new">+ New routine</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant={!data.part_of_day ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onclick={() => filterPod('')}
|
||||||
|
>All</Button>
|
||||||
|
<Button
|
||||||
|
variant={data.part_of_day === 'am' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onclick={() => filterPod('am')}
|
||||||
|
>AM</Button>
|
||||||
|
<Button
|
||||||
|
variant={data.part_of_day === 'pm' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onclick={() => filterPod('pm')}
|
||||||
|
>PM</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if sortedDates.length}
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each sortedDates as date}
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-muted-foreground mb-2">{date}</h3>
|
||||||
|
<div class="space-y-1">
|
||||||
|
{#each byDate[date] as routine}
|
||||||
|
<a
|
||||||
|
href="/routines/{routine.id}"
|
||||||
|
class="flex items-center justify-between rounded-md border border-border px-4 py-3 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
|
||||||
|
{routine.part_of_day.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
<span class="text-sm">{routine.steps.length} steps</span>
|
||||||
|
</div>
|
||||||
|
{#if routine.notes}
|
||||||
|
<span class="text-sm text-muted-foreground truncate max-w-xs">{routine.notes}</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted-foreground">No routines found.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
54
frontend/src/routes/routines/[id]/+page.server.ts
Normal file
54
frontend/src/routes/routines/[id]/+page.server.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { addRoutineStep, deleteRoutine, deleteRoutineStep, getProducts, getRoutine } from '$lib/api';
|
||||||
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
|
try {
|
||||||
|
const [routine, products] = await Promise.all([getRoutine(params.id), getProducts()]);
|
||||||
|
return { routine, products };
|
||||||
|
} catch {
|
||||||
|
error(404, 'Routine not found');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
addStep: async ({ params, request }) => {
|
||||||
|
const form = await request.formData();
|
||||||
|
const product_id = form.get('product_id') as string;
|
||||||
|
const order_index = Number(form.get('order_index') ?? 0);
|
||||||
|
const dose = form.get('dose') as string;
|
||||||
|
const region = form.get('region') as string;
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = { order_index };
|
||||||
|
if (product_id) body.product_id = product_id;
|
||||||
|
if (dose) body.dose = dose;
|
||||||
|
if (region) body.region = region;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addRoutineStep(params.id, body);
|
||||||
|
return { stepAdded: true };
|
||||||
|
} catch (e) {
|
||||||
|
return fail(500, { error: (e as Error).message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeStep: async ({ request }) => {
|
||||||
|
const form = await request.formData();
|
||||||
|
const step_id = form.get('step_id') as string;
|
||||||
|
try {
|
||||||
|
await deleteRoutineStep(step_id);
|
||||||
|
return { stepRemoved: true };
|
||||||
|
} catch (e) {
|
||||||
|
return fail(500, { error: (e as Error).message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async ({ params }) => {
|
||||||
|
try {
|
||||||
|
await deleteRoutine(params.id);
|
||||||
|
} catch (e) {
|
||||||
|
return fail(500, { error: (e as Error).message });
|
||||||
|
}
|
||||||
|
redirect(303, '/routines');
|
||||||
|
}
|
||||||
|
};
|
||||||
131
frontend/src/routes/routines/[id]/+page.svelte
Normal file
131
frontend/src/routes/routines/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { ActionData, PageData } from './$types';
|
||||||
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
||||||
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
|
|
||||||
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
let { routine, products } = $derived(data);
|
||||||
|
|
||||||
|
let showStepForm = $state(false);
|
||||||
|
let selectedProductId = $state('');
|
||||||
|
|
||||||
|
const nextOrderIndex = $derived(
|
||||||
|
routine.steps.length ? Math.max(...routine.steps.map((s) => s.order_index)) + 1 : 0
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Routine {routine.routine_date} {routine.part_of_day.toUpperCase()} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="max-w-2xl space-y-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<a href="/routines" class="text-sm text-muted-foreground hover:underline">← Routines</a>
|
||||||
|
<h2 class="text-2xl font-bold tracking-tight">{routine.routine_date}</h2>
|
||||||
|
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
|
||||||
|
{routine.part_of_day.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if routine.notes}
|
||||||
|
<p class="text-sm text-muted-foreground">{routine.notes}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Steps -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold">Steps ({routine.steps.length})</h3>
|
||||||
|
<Button variant="outline" size="sm" onclick={() => (showStepForm = !showStepForm)}>
|
||||||
|
{showStepForm ? 'Cancel' : '+ Add step'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showStepForm}
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle class="text-base">Add step</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form method="POST" action="?/addStep" use:enhance class="space-y-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label>Product</Label>
|
||||||
|
<input type="hidden" name="product_id" value={selectedProductId} />
|
||||||
|
<Select type="single" value={selectedProductId} onValueChange={(v) => (selectedProductId = v)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
{#if selectedProductId}
|
||||||
|
{products.find((p) => p.id === selectedProductId)?.name ?? 'Select product'}
|
||||||
|
{:else}
|
||||||
|
Select product
|
||||||
|
{/if}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each products as p}
|
||||||
|
<SelectItem value={p.id}>{p.name} ({p.brand})</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="order_index" value={nextOrderIndex} />
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="dose">Dose</Label>
|
||||||
|
<Input id="dose" name="dose" placeholder="e.g. 2 pumps" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="region">Region</Label>
|
||||||
|
<Input id="region" name="region" placeholder="e.g. face" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" size="sm">Add step</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if routine.steps.length}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each routine.steps.toSorted((a, b) => a.order_index - b.order_index) as step}
|
||||||
|
<div class="flex items-center justify-between rounded-md border border-border px-4 py-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-xs text-muted-foreground w-4">{step.order_index + 1}</span>
|
||||||
|
<div>
|
||||||
|
{#if step.product}
|
||||||
|
<p class="text-sm font-medium">{step.product.name}</p>
|
||||||
|
<p class="text-xs text-muted-foreground">{step.product.brand}</p>
|
||||||
|
{:else if step.action_type}
|
||||||
|
<p class="text-sm font-medium">{step.action_type.replace(/_/g, ' ')}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted-foreground">Unknown step</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if step.dose}
|
||||||
|
<span class="text-xs text-muted-foreground">{step.dose}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="?/removeStep" use:enhance>
|
||||||
|
<input type="hidden" name="step_id" value={step.id} />
|
||||||
|
<Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive">
|
||||||
|
×
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted-foreground">No steps yet.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<form method="POST" action="?/delete" use:enhance
|
||||||
|
onsubmit={(e) => { if (!confirm('Delete this routine?')) e.preventDefault(); }}>
|
||||||
|
<Button type="submit" variant="destructive" size="sm">Delete routine</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
27
frontend/src/routes/routines/new/+page.server.ts
Normal file
27
frontend/src/routes/routines/new/+page.server.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { createRoutine } from '$lib/api';
|
||||||
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
return { today: new Date().toISOString().slice(0, 10) };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async ({ request }) => {
|
||||||
|
const form = await request.formData();
|
||||||
|
const routine_date = form.get('routine_date') as string;
|
||||||
|
const part_of_day = form.get('part_of_day') as string;
|
||||||
|
const notes = form.get('notes') as string;
|
||||||
|
|
||||||
|
if (!routine_date || !part_of_day) {
|
||||||
|
return fail(400, { error: 'Date and AM/PM are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const routine = await createRoutine({ routine_date, part_of_day, notes: notes || undefined });
|
||||||
|
redirect(303, `/routines/${routine.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
return fail(500, { error: (e as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
59
frontend/src/routes/routines/new/+page.svelte
Normal file
59
frontend/src/routes/routines/new/+page.svelte
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { ActionData, PageData } from './$types';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
|
|
||||||
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
let partOfDay = $state('am');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>New Routine — innercontext</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="max-w-md space-y-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<a href="/routines" class="text-sm text-muted-foreground hover:underline">← Routines</a>
|
||||||
|
<h2 class="text-2xl font-bold tracking-tight">New Routine</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Routine details</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form method="POST" use:enhance class="space-y-5">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="routine_date">Date *</Label>
|
||||||
|
<Input id="routine_date" name="routine_date" type="date" value={data.today} required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>AM or PM *</Label>
|
||||||
|
<input type="hidden" name="part_of_day" value={partOfDay} />
|
||||||
|
<Select type="single" value={partOfDay} onValueChange={(v) => (partOfDay = v)}>
|
||||||
|
<SelectTrigger>{partOfDay.toUpperCase()}</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="am">AM</SelectItem>
|
||||||
|
<SelectItem value="pm">PM</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="notes">Notes</Label>
|
||||||
|
<Input id="notes" name="notes" placeholder="Optional notes" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
<Button type="submit">Create routine</Button>
|
||||||
|
<Button variant="outline" href="/routines">Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
47
frontend/src/routes/skin/+page.server.ts
Normal file
47
frontend/src/routes/skin/+page.server.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { createSkinSnapshot, getSkinSnapshots } from '$lib/api';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
|
const overall_state = url.searchParams.get('overall_state') ?? undefined;
|
||||||
|
const snapshots = await getSkinSnapshots({ overall_state });
|
||||||
|
return { snapshots, overall_state };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
create: async ({ request }) => {
|
||||||
|
const form = await request.formData();
|
||||||
|
const snapshot_date = form.get('snapshot_date') as string;
|
||||||
|
const overall_state = form.get('overall_state') as string;
|
||||||
|
const trend = form.get('trend') as string;
|
||||||
|
const notes = form.get('notes') as string;
|
||||||
|
const hydration_level = form.get('hydration_level') as string;
|
||||||
|
const sensitivity_level = form.get('sensitivity_level') as string;
|
||||||
|
const barrier_state = form.get('barrier_state') as string;
|
||||||
|
const active_concerns_raw = form.get('active_concerns') as string;
|
||||||
|
|
||||||
|
if (!snapshot_date) {
|
||||||
|
return fail(400, { error: 'Date is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const active_concerns = active_concerns_raw
|
||||||
|
?.split(',')
|
||||||
|
.map((c) => c.trim())
|
||||||
|
.filter(Boolean) ?? [];
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = { snapshot_date, active_concerns };
|
||||||
|
if (overall_state) body.overall_state = overall_state;
|
||||||
|
if (trend) body.trend = trend;
|
||||||
|
if (notes) body.notes = notes;
|
||||||
|
if (hydration_level) body.hydration_level = Number(hydration_level);
|
||||||
|
if (sensitivity_level) body.sensitivity_level = Number(sensitivity_level);
|
||||||
|
if (barrier_state) body.barrier_state = barrier_state;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createSkinSnapshot(body);
|
||||||
|
return { created: true };
|
||||||
|
} catch (e) {
|
||||||
|
return fail(500, { error: (e as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
177
frontend/src/routes/skin/+page.svelte
Normal file
177
frontend/src/routes/skin/+page.svelte
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { ActionData, PageData } from './$types';
|
||||||
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
||||||
|
|
||||||
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
|
const states = ['excellent', 'good', 'fair', 'poor'];
|
||||||
|
const trends = ['improving', 'stable', 'worsening', 'fluctuating'];
|
||||||
|
const barrierStates = ['intact', 'mildly_compromised', 'compromised'];
|
||||||
|
|
||||||
|
const stateColors: Record<string, string> = {
|
||||||
|
excellent: 'bg-green-100 text-green-800',
|
||||||
|
good: 'bg-blue-100 text-blue-800',
|
||||||
|
fair: 'bg-yellow-100 text-yellow-800',
|
||||||
|
poor: 'bg-red-100 text-red-800'
|
||||||
|
};
|
||||||
|
|
||||||
|
let showForm = $state(false);
|
||||||
|
let overallState = $state('');
|
||||||
|
let trend = $state('');
|
||||||
|
let barrierState = $state('');
|
||||||
|
|
||||||
|
const sortedSnapshots = $derived(
|
||||||
|
[...data.snapshots].sort((a, b) => b.snapshot_date.localeCompare(a.snapshot_date))
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Skin — innercontext</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold tracking-tight">Skin Snapshots</h2>
|
||||||
|
<p class="text-muted-foreground">{data.snapshots.length} snapshots</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onclick={() => (showForm = !showForm)}>
|
||||||
|
{showForm ? 'Cancel' : '+ Add snapshot'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
|
||||||
|
{/if}
|
||||||
|
{#if form?.created}
|
||||||
|
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Snapshot added.</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showForm}
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>New skin snapshot</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form method="POST" action="?/create" use:enhance class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="snapshot_date">Date *</Label>
|
||||||
|
<Input id="snapshot_date" name="snapshot_date" type="date"
|
||||||
|
value={new Date().toISOString().slice(0, 10)} required />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label>Overall state</Label>
|
||||||
|
<input type="hidden" name="overall_state" value={overallState} />
|
||||||
|
<Select type="single" value={overallState} onValueChange={(v) => (overallState = v)}>
|
||||||
|
<SelectTrigger>{overallState || 'Select'}</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each states as s}
|
||||||
|
<SelectItem value={s}>{s}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label>Trend</Label>
|
||||||
|
<input type="hidden" name="trend" value={trend} />
|
||||||
|
<Select type="single" value={trend} onValueChange={(v) => (trend = v)}>
|
||||||
|
<SelectTrigger>{trend || 'Select'}</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each trends as t}
|
||||||
|
<SelectItem value={t}>{t}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label>Barrier state</Label>
|
||||||
|
<input type="hidden" name="barrier_state" value={barrierState} />
|
||||||
|
<Select type="single" value={barrierState} onValueChange={(v) => (barrierState = v)}>
|
||||||
|
<SelectTrigger>{barrierState ? barrierState.replace(/_/g, ' ') : 'Select'}</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each barrierStates as b}
|
||||||
|
<SelectItem value={b}>{b.replace(/_/g, ' ')}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="hydration_level">Hydration (1–5)</Label>
|
||||||
|
<Input id="hydration_level" name="hydration_level" type="number" min="1" max="5" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="sensitivity_level">Sensitivity (1–5)</Label>
|
||||||
|
<Input id="sensitivity_level" name="sensitivity_level" type="number" min="1" max="5" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 col-span-2">
|
||||||
|
<Label for="active_concerns">Active concerns (comma-separated)</Label>
|
||||||
|
<Input id="active_concerns" name="active_concerns" placeholder="acne, redness, dehydration" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 col-span-2">
|
||||||
|
<Label for="notes">Notes</Label>
|
||||||
|
<Input id="notes" name="notes" />
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<Button type="submit">Add snapshot</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each sortedSnapshots as snap}
|
||||||
|
<Card>
|
||||||
|
<CardContent class="pt-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<span class="font-medium">{snap.snapshot_date}</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#if snap.overall_state}
|
||||||
|
<span class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[snap.overall_state] ?? ''}">
|
||||||
|
{snap.overall_state}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if snap.trend}
|
||||||
|
<Badge variant="secondary">{snap.trend}</Badge>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-3 text-sm mb-3">
|
||||||
|
{#if snap.hydration_level != null}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-muted-foreground">Hydration</p>
|
||||||
|
<p class="font-medium">{snap.hydration_level}/5</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if snap.sensitivity_level != null}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-muted-foreground">Sensitivity</p>
|
||||||
|
<p class="font-medium">{snap.sensitivity_level}/5</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if snap.barrier_state}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-muted-foreground">Barrier</p>
|
||||||
|
<p class="font-medium">{snap.barrier_state.replace(/_/g, ' ')}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if snap.active_concerns.length}
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#each snap.active_concerns as c}
|
||||||
|
<Badge variant="secondary" class="text-xs">{c.replace(/_/g, ' ')}</Badge>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if snap.notes}
|
||||||
|
<p class="mt-2 text-sm text-muted-foreground">{snap.notes}</p>
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted-foreground">No skin snapshots yet.</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue