refactor(frontend): route protected API access through server session

This commit is contained in:
Piotr Oleszczyk 2026-03-12 16:27:24 +01:00
parent 1d5630ed8c
commit b11f64d5a1
31 changed files with 727 additions and 249 deletions

View file

@ -0,0 +1,18 @@
# Task T8 Protected Navigation
- QA app: `http://127.0.0.1:4175`
- Backend: `http://127.0.0.1:8002`
- Mock OIDC issuer: `http://127.0.0.1:9100`
- Backend DB: `.sisyphus/evidence/task-T8-qa.sqlite`
Authenticated shell and protected route checks executed with Playwright:
- `/` -> title `Dashboard - innercontext`, heading `Dashboard`, shell user `Playwright User`, role `Użytkownik`, logout visible `true`
- `/products` -> title `Produkty — innercontext`, heading `Produkty`, shell user `Playwright User`, role `Użytkownik`, logout visible `true`
- `/profile` -> title `Profil — innercontext`, heading `Profil`, shell user `Playwright User`, role `Użytkownik`, logout visible `true`
- `/routines` -> title `Rutyny — innercontext`, heading `Rutyny`, shell user `Playwright User`, role `Użytkownik`, logout visible `true`
Logout endpoint check executed with Playwright request API:
- `GET /auth/logout` -> `303`
- Location -> `http://127.0.0.1:9100/logout?client_id=innercontext-web&post_logout_redirect_uri=http%3A%2F%2F127.0.0.1%3A4175%2F`

View file

@ -0,0 +1,10 @@
Playwright unauthenticated request check
request: GET http://127.0.0.1:4175/products
cookies: none
maxRedirects: 0
status: 303
location: /auth/login?returnTo=%2Fproducts
result: protected page redirects to the login flow before returning page content.

View file

@ -10,6 +10,11 @@
"nav_appName": "innercontext", "nav_appName": "innercontext",
"nav_appSubtitle": "personal health & skincare", "nav_appSubtitle": "personal health & skincare",
"auth_signedInAs": "Signed in as",
"auth_roleAdmin": "Admin",
"auth_roleMember": "Member",
"auth_logout": "Log out",
"common_save": "Save", "common_save": "Save",
"common_cancel": "Cancel", "common_cancel": "Cancel",
"common_add": "Add", "common_add": "Add",

View file

@ -10,6 +10,11 @@
"nav_appName": "innercontext", "nav_appName": "innercontext",
"nav_appSubtitle": "zdrowie & pielęgnacja", "nav_appSubtitle": "zdrowie & pielęgnacja",
"auth_signedInAs": "Zalogowano jako",
"auth_roleAdmin": "Administrator",
"auth_roleMember": "Użytkownik",
"auth_logout": "Wyloguj",
"common_save": "Zapisz", "common_save": "Zapisz",
"common_cancel": "Anuluj", "common_cancel": "Anuluj",
"common_add": "Dodaj", "common_add": "Dodaj",

View file

@ -20,43 +20,87 @@ import type {
UserProfile, UserProfile,
} from "./types"; } from "./types";
// ─── Core fetch helpers ────────────────────────────────────────────────────── export interface ApiClientOptions {
fetch?: typeof globalThis.fetch;
accessToken?: string;
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> { function resolveBase(options: ApiClientOptions): string {
// Server-side uses PUBLIC_API_BASE (e.g. http://localhost:8000). if (browser && !options.accessToken) {
// Browser-side uses /api so nginx proxies the request on the correct host. return "/api";
const base = browser ? "/api" : PUBLIC_API_BASE; }
const url = `${base}${path}`;
const res = await fetch(url, { return PUBLIC_API_BASE;
headers: { "Content-Type": "application/json", ...init.headers }, }
function buildHeaders(
initHeaders: HeadersInit | undefined,
body: BodyInit | null | undefined,
accessToken?: string,
): Headers {
const headers = new Headers(initHeaders);
if (!(body instanceof FormData) && body !== undefined && body !== null) {
headers.set("Content-Type", headers.get("Content-Type") ?? "application/json");
}
if (accessToken && !headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${accessToken}`);
}
return headers;
}
async function request<T>(
path: string,
init: RequestInit = {},
options: ApiClientOptions = {},
): Promise<T> {
const url = `${resolveBase(options)}${path}`;
const requestFn = options.fetch ?? fetch;
const res = await requestFn(url, {
headers: buildHeaders(init.headers, init.body, options.accessToken),
...init, ...init,
}); });
if (!res.ok) { if (!res.ok) {
const detail = await res.json().catch(() => ({ detail: res.statusText })); const detail = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(detail?.detail ?? res.statusText); throw new Error(detail?.detail ?? res.statusText);
} }
if (res.status === 204) return undefined as T; if (res.status === 204) return undefined as T;
return res.json(); return res.json();
} }
export const api = { export function createApiClient(options: ApiClientOptions = {}) {
get: <T>(path: string) => request<T>(path), return {
get: <T>(path: string) => request<T>(path, {}, options),
post: <T>(path: string, body: unknown) => post: <T>(path: string, body: unknown) =>
request<T>(path, { method: "POST", body: JSON.stringify(body) }), request<T>(path, { method: "POST", body: JSON.stringify(body) }, options),
postForm: <T>(path: string, body: FormData) =>
request<T>(path, { method: "POST", body }, options),
patch: <T>(path: string, body: unknown) => patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }), request<T>(path, { method: "PATCH", body: JSON.stringify(body) }, options),
del: (path: string) => request<void>(path, { method: "DELETE" }), del: (path: string) => request<void>(path, { method: "DELETE" }, options),
}; };
}
// ─── Profile ───────────────────────────────────────────────────────────────── export type ApiClient = ReturnType<typeof createApiClient>;
export const getProfile = (): Promise<UserProfile | null> => api.get("/profile"); export const api = createApiClient();
function resolveClient(options?: ApiClientOptions): ApiClient {
return options ? createApiClient(options) : api;
}
export const getProfile = (
options?: ApiClientOptions,
): Promise<UserProfile | null> => resolveClient(options).get("/profile");
export const updateProfile = ( export const updateProfile = (
body: { birth_date?: string; sex_at_birth?: "male" | "female" | "intersex" }, body: { birth_date?: string; sex_at_birth?: "male" | "female" | "intersex" },
): Promise<UserProfile> => api.patch("/profile", body); options?: ApiClientOptions,
): Promise<UserProfile> => resolveClient(options).patch("/profile", body);
// ─── Products ────────────────────────────────────────────────────────────────
export interface ProductListParams { export interface ProductListParams {
category?: string; category?: string;
@ -68,62 +112,87 @@ export interface ProductListParams {
export function getProducts( export function getProducts(
params: ProductListParams = {}, params: ProductListParams = {},
options?: ApiClientOptions,
): Promise<Product[]> { ): Promise<Product[]> {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (params.category) q.set("category", params.category); if (params.category) q.set("category", params.category);
if (params.brand) q.set("brand", params.brand); if (params.brand) q.set("brand", params.brand);
if (params.targets) params.targets.forEach((t) => q.append("targets", t)); if (params.targets) params.targets.forEach((t) => q.append("targets", t));
if (params.is_medication != null) if (params.is_medication != null) {
q.set("is_medication", String(params.is_medication)); q.set("is_medication", String(params.is_medication));
}
if (params.is_tool != null) q.set("is_tool", String(params.is_tool)); if (params.is_tool != null) q.set("is_tool", String(params.is_tool));
const qs = q.toString(); const qs = q.toString();
return api.get(`/products${qs ? `?${qs}` : ""}`); return resolveClient(options).get(`/products${qs ? `?${qs}` : ""}`);
} }
export function getProductSummaries( export function getProductSummaries(
params: ProductListParams = {}, params: ProductListParams = {},
options?: ApiClientOptions,
): Promise<ProductSummary[]> { ): Promise<ProductSummary[]> {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (params.category) q.set("category", params.category); if (params.category) q.set("category", params.category);
if (params.brand) q.set("brand", params.brand); if (params.brand) q.set("brand", params.brand);
if (params.targets) params.targets.forEach((t) => q.append("targets", t)); if (params.targets) params.targets.forEach((t) => q.append("targets", t));
if (params.is_medication != null) if (params.is_medication != null) {
q.set("is_medication", String(params.is_medication)); q.set("is_medication", String(params.is_medication));
}
if (params.is_tool != null) q.set("is_tool", String(params.is_tool)); if (params.is_tool != null) q.set("is_tool", String(params.is_tool));
const qs = q.toString(); const qs = q.toString();
return api.get(`/products/summary${qs ? `?${qs}` : ""}`); return resolveClient(options).get(`/products/summary${qs ? `?${qs}` : ""}`);
} }
export const getProduct = (id: string): Promise<Product> => export const getProduct = (
api.get(`/products/${id}`); id: string,
options?: ApiClientOptions,
): Promise<Product> => resolveClient(options).get(`/products/${id}`);
export const createProduct = ( export const createProduct = (
body: Record<string, unknown>, body: Record<string, unknown>,
): Promise<Product> => api.post("/products", body); options?: ApiClientOptions,
): Promise<Product> => resolveClient(options).post("/products", body);
export const updateProduct = ( export const updateProduct = (
id: string, id: string,
body: Record<string, unknown>, body: Record<string, unknown>,
): Promise<Product> => api.patch(`/products/${id}`, body); options?: ApiClientOptions,
export const deleteProduct = (id: string): Promise<void> => ): Promise<Product> => resolveClient(options).patch(`/products/${id}`, body);
api.del(`/products/${id}`);
export const deleteProduct = (
id: string,
options?: ApiClientOptions,
): Promise<void> => resolveClient(options).del(`/products/${id}`);
export const getInventory = (
productId: string,
options?: ApiClientOptions,
): Promise<ProductInventory[]> =>
resolveClient(options).get(`/products/${productId}/inventory`);
export const getInventory = (productId: string): Promise<ProductInventory[]> =>
api.get(`/products/${productId}/inventory`);
export const createInventory = ( export const createInventory = (
productId: string, productId: string,
body: Record<string, unknown>, body: Record<string, unknown>,
options?: ApiClientOptions,
): Promise<ProductInventory> => ): Promise<ProductInventory> =>
api.post(`/products/${productId}/inventory`, body); resolveClient(options).post(`/products/${productId}/inventory`, body);
export const updateInventory = ( export const updateInventory = (
id: string, id: string,
body: Record<string, unknown>, body: Record<string, unknown>,
): Promise<ProductInventory> => api.patch(`/inventory/${id}`, body); options?: ApiClientOptions,
export const deleteInventory = (id: string): Promise<void> => ): Promise<ProductInventory> =>
api.del(`/inventory/${id}`); resolveClient(options).patch(`/inventory/${id}`, body);
export const parseProductText = (text: string): Promise<ProductParseResponse> => export const deleteInventory = (
api.post("/products/parse-text", { text }); id: string,
options?: ApiClientOptions,
): Promise<void> => resolveClient(options).del(`/inventory/${id}`);
// ─── Routines ──────────────────────────────────────────────────────────────── export const parseProductText = (
text: string,
options?: ApiClientOptions,
): Promise<ProductParseResponse> =>
resolveClient(options).post("/products/parse-text", { text });
export interface RoutineListParams { export interface RoutineListParams {
from_date?: string; from_date?: string;
@ -133,68 +202,103 @@ export interface RoutineListParams {
export function getRoutines( export function getRoutines(
params: RoutineListParams = {}, params: RoutineListParams = {},
options?: ApiClientOptions,
): Promise<Routine[]> { ): Promise<Routine[]> {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (params.from_date) q.set("from_date", params.from_date); if (params.from_date) q.set("from_date", params.from_date);
if (params.to_date) q.set("to_date", params.to_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); if (params.part_of_day) q.set("part_of_day", params.part_of_day);
const qs = q.toString(); const qs = q.toString();
return api.get(`/routines${qs ? `?${qs}` : ""}`); return resolveClient(options).get(`/routines${qs ? `?${qs}` : ""}`);
} }
export const getRoutine = (id: string): Promise<Routine> => export const getRoutine = (
api.get(`/routines/${id}`); id: string,
options?: ApiClientOptions,
): Promise<Routine> => resolveClient(options).get(`/routines/${id}`);
export const createRoutine = ( export const createRoutine = (
body: Record<string, unknown>, body: Record<string, unknown>,
): Promise<Routine> => api.post("/routines", body); options?: ApiClientOptions,
): Promise<Routine> => resolveClient(options).post("/routines", body);
export const updateRoutine = ( export const updateRoutine = (
id: string, id: string,
body: Record<string, unknown>, body: Record<string, unknown>,
): Promise<Routine> => api.patch(`/routines/${id}`, body); options?: ApiClientOptions,
export const deleteRoutine = (id: string): Promise<void> => ): Promise<Routine> => resolveClient(options).patch(`/routines/${id}`, body);
api.del(`/routines/${id}`);
export const deleteRoutine = (
id: string,
options?: ApiClientOptions,
): Promise<void> => resolveClient(options).del(`/routines/${id}`);
export const addRoutineStep = ( export const addRoutineStep = (
routineId: string, routineId: string,
body: Record<string, unknown>, body: Record<string, unknown>,
): Promise<RoutineStep> => api.post(`/routines/${routineId}/steps`, body); options?: ApiClientOptions,
): Promise<RoutineStep> =>
resolveClient(options).post(`/routines/${routineId}/steps`, body);
export const updateRoutineStep = ( export const updateRoutineStep = (
stepId: string, stepId: string,
body: Record<string, unknown>, body: Record<string, unknown>,
): Promise<RoutineStep> => api.patch(`/routines/steps/${stepId}`, body); options?: ApiClientOptions,
export const deleteRoutineStep = (stepId: string): Promise<void> => ): Promise<RoutineStep> =>
api.del(`/routines/steps/${stepId}`); resolveClient(options).patch(`/routines/steps/${stepId}`, body);
export const suggestRoutine = (body: { export const deleteRoutineStep = (
stepId: string,
options?: ApiClientOptions,
): Promise<void> => resolveClient(options).del(`/routines/steps/${stepId}`);
export const suggestRoutine = (
body: {
routine_date: string; routine_date: string;
part_of_day: PartOfDay; part_of_day: PartOfDay;
notes?: string; notes?: string;
include_minoxidil_beard?: boolean; include_minoxidil_beard?: boolean;
leaving_home?: boolean; leaving_home?: boolean;
}): Promise<RoutineSuggestion> => api.post("/routines/suggest", body); },
options?: ApiClientOptions,
): Promise<RoutineSuggestion> =>
resolveClient(options).post("/routines/suggest", body);
export const suggestBatch = (body: { export const suggestBatch = (
body: {
from_date: string; from_date: string;
to_date: string; to_date: string;
notes?: string; notes?: string;
include_minoxidil_beard?: boolean; include_minoxidil_beard?: boolean;
minimize_products?: boolean; minimize_products?: boolean;
}): Promise<BatchSuggestion> => api.post("/routines/suggest-batch", body); },
options?: ApiClientOptions,
): Promise<BatchSuggestion> =>
resolveClient(options).post("/routines/suggest-batch", body);
export const getGroomingSchedule = (
options?: ApiClientOptions,
): Promise<GroomingSchedule[]> =>
resolveClient(options).get("/routines/grooming-schedule");
export const getGroomingSchedule = (): Promise<GroomingSchedule[]> =>
api.get("/routines/grooming-schedule");
export const createGroomingScheduleEntry = ( export const createGroomingScheduleEntry = (
body: Record<string, unknown>, body: Record<string, unknown>,
): Promise<GroomingSchedule> => api.post("/routines/grooming-schedule", body); options?: ApiClientOptions,
): Promise<GroomingSchedule> =>
resolveClient(options).post("/routines/grooming-schedule", body);
export const updateGroomingScheduleEntry = ( export const updateGroomingScheduleEntry = (
id: string, id: string,
body: Record<string, unknown>, body: Record<string, unknown>,
options?: ApiClientOptions,
): Promise<GroomingSchedule> => ): Promise<GroomingSchedule> =>
api.patch(`/routines/grooming-schedule/${id}`, body); resolveClient(options).patch(`/routines/grooming-schedule/${id}`, body);
export const deleteGroomingScheduleEntry = (id: string): Promise<void> =>
api.del(`/routines/grooming-schedule/${id}`);
// ─── Health Medications ──────────────────────────────────────────────────── export const deleteGroomingScheduleEntry = (
id: string,
options?: ApiClientOptions,
): Promise<void> =>
resolveClient(options).del(`/routines/grooming-schedule/${id}`);
export interface MedicationListParams { export interface MedicationListParams {
kind?: string; kind?: string;
@ -203,37 +307,51 @@ export interface MedicationListParams {
export function getMedications( export function getMedications(
params: MedicationListParams = {}, params: MedicationListParams = {},
options?: ApiClientOptions,
): Promise<MedicationEntry[]> { ): Promise<MedicationEntry[]> {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (params.kind) q.set("kind", params.kind); if (params.kind) q.set("kind", params.kind);
if (params.product_name) q.set("product_name", params.product_name); if (params.product_name) q.set("product_name", params.product_name);
const qs = q.toString(); const qs = q.toString();
return api.get(`/health/medications${qs ? `?${qs}` : ""}`); return resolveClient(options).get(`/health/medications${qs ? `?${qs}` : ""}`);
} }
export const getMedication = (id: string): Promise<MedicationEntry> => export const getMedication = (
api.get(`/health/medications/${id}`); id: string,
options?: ApiClientOptions,
): Promise<MedicationEntry> =>
resolveClient(options).get(`/health/medications/${id}`);
export const createMedication = ( export const createMedication = (
body: Record<string, unknown>, body: Record<string, unknown>,
): Promise<MedicationEntry> => api.post("/health/medications", body); options?: ApiClientOptions,
): Promise<MedicationEntry> =>
resolveClient(options).post("/health/medications", body);
export const updateMedication = ( export const updateMedication = (
id: string, id: string,
body: Record<string, unknown>, body: Record<string, unknown>,
): Promise<MedicationEntry> => api.patch(`/health/medications/${id}`, body); options?: ApiClientOptions,
export const deleteMedication = (id: string): Promise<void> => ): Promise<MedicationEntry> =>
api.del(`/health/medications/${id}`); resolveClient(options).patch(`/health/medications/${id}`, body);
export const deleteMedication = (
id: string,
options?: ApiClientOptions,
): Promise<void> => resolveClient(options).del(`/health/medications/${id}`);
export const getMedicationUsages = ( export const getMedicationUsages = (
medicationId: string, medicationId: string,
options?: ApiClientOptions,
): Promise<MedicationUsage[]> => ): Promise<MedicationUsage[]> =>
api.get(`/health/medications/${medicationId}/usages`); resolveClient(options).get(`/health/medications/${medicationId}/usages`);
export const createMedicationUsage = ( export const createMedicationUsage = (
medicationId: string, medicationId: string,
body: Record<string, unknown>, body: Record<string, unknown>,
options?: ApiClientOptions,
): Promise<MedicationUsage> => ): Promise<MedicationUsage> =>
api.post(`/health/medications/${medicationId}/usages`, body); resolveClient(options).post(`/health/medications/${medicationId}/usages`, body);
// ─── Health Lab results ────────────────────────────────────────────────────
export interface LabResultListParams { export interface LabResultListParams {
q?: string; q?: string;
@ -250,6 +368,7 @@ export interface LabResultListParams {
export function getLabResults( export function getLabResults(
params: LabResultListParams = {}, params: LabResultListParams = {},
options?: ApiClientOptions,
): Promise<LabResultListResponse> { ): Promise<LabResultListResponse> {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (params.q) q.set("q", params.q); if (params.q) q.set("q", params.q);
@ -258,29 +377,43 @@ export function getLabResults(
if (params.flags?.length) { if (params.flags?.length) {
for (const flag of params.flags) q.append("flags", flag); for (const flag of params.flags) q.append("flags", flag);
} }
if (params.without_flag != null) q.set("without_flag", String(params.without_flag)); if (params.without_flag != null) {
q.set("without_flag", String(params.without_flag));
}
if (params.from_date) q.set("from_date", params.from_date); if (params.from_date) q.set("from_date", params.from_date);
if (params.to_date) q.set("to_date", params.to_date); if (params.to_date) q.set("to_date", params.to_date);
if (params.latest_only != null) q.set("latest_only", String(params.latest_only)); if (params.latest_only != null) {
q.set("latest_only", String(params.latest_only));
}
if (params.limit != null) q.set("limit", String(params.limit)); if (params.limit != null) q.set("limit", String(params.limit));
if (params.offset != null) q.set("offset", String(params.offset)); if (params.offset != null) q.set("offset", String(params.offset));
const qs = q.toString(); const qs = q.toString();
return api.get(`/health/lab-results${qs ? `?${qs}` : ""}`); return resolveClient(options).get(`/health/lab-results${qs ? `?${qs}` : ""}`);
} }
export const getLabResult = (id: string): Promise<LabResult> => export const getLabResult = (
api.get(`/health/lab-results/${id}`); id: string,
options?: ApiClientOptions,
): Promise<LabResult> =>
resolveClient(options).get(`/health/lab-results/${id}`);
export const createLabResult = ( export const createLabResult = (
body: Record<string, unknown>, body: Record<string, unknown>,
): Promise<LabResult> => api.post("/health/lab-results", body); options?: ApiClientOptions,
): Promise<LabResult> =>
resolveClient(options).post("/health/lab-results", body);
export const updateLabResult = ( export const updateLabResult = (
id: string, id: string,
body: Record<string, unknown>, body: Record<string, unknown>,
): Promise<LabResult> => api.patch(`/health/lab-results/${id}`, body); options?: ApiClientOptions,
export const deleteLabResult = (id: string): Promise<void> => ): Promise<LabResult> =>
api.del(`/health/lab-results/${id}`); resolveClient(options).patch(`/health/lab-results/${id}`, body);
// ─── Skin ──────────────────────────────────────────────────────────────────── export const deleteLabResult = (
id: string,
options?: ApiClientOptions,
): Promise<void> => resolveClient(options).del(`/health/lab-results/${id}`);
export interface SnapshotListParams { export interface SnapshotListParams {
from_date?: string; from_date?: string;
@ -290,40 +423,47 @@ export interface SnapshotListParams {
export function getSkinSnapshots( export function getSkinSnapshots(
params: SnapshotListParams = {}, params: SnapshotListParams = {},
options?: ApiClientOptions,
): Promise<SkinConditionSnapshot[]> { ): Promise<SkinConditionSnapshot[]> {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (params.from_date) q.set("from_date", params.from_date); if (params.from_date) q.set("from_date", params.from_date);
if (params.to_date) q.set("to_date", params.to_date); if (params.to_date) q.set("to_date", params.to_date);
if (params.overall_state) q.set("overall_state", params.overall_state); if (params.overall_state) q.set("overall_state", params.overall_state);
const qs = q.toString(); const qs = q.toString();
return api.get(`/skincare${qs ? `?${qs}` : ""}`); return resolveClient(options).get(`/skincare${qs ? `?${qs}` : ""}`);
} }
export const getSkinSnapshot = (id: string): Promise<SkinConditionSnapshot> => export const getSkinSnapshot = (
api.get(`/skincare/${id}`); id: string,
options?: ApiClientOptions,
): Promise<SkinConditionSnapshot> => resolveClient(options).get(`/skincare/${id}`);
export const createSkinSnapshot = ( export const createSkinSnapshot = (
body: Record<string, unknown>, body: Record<string, unknown>,
): Promise<SkinConditionSnapshot> => api.post("/skincare", body); options?: ApiClientOptions,
): Promise<SkinConditionSnapshot> => resolveClient(options).post("/skincare", body);
export const updateSkinSnapshot = ( export const updateSkinSnapshot = (
id: string, id: string,
body: Record<string, unknown>, body: Record<string, unknown>,
): Promise<SkinConditionSnapshot> => api.patch(`/skincare/${id}`, body); options?: ApiClientOptions,
export const deleteSkinSnapshot = (id: string): Promise<void> => ): Promise<SkinConditionSnapshot> =>
api.del(`/skincare/${id}`); resolveClient(options).patch(`/skincare/${id}`, body);
export const deleteSkinSnapshot = (
id: string,
options?: ApiClientOptions,
): Promise<void> => resolveClient(options).del(`/skincare/${id}`);
export async function analyzeSkinPhotos( export async function analyzeSkinPhotos(
files: File[], files: File[] | FormData,
options?: ApiClientOptions,
): Promise<SkinPhotoAnalysisResponse> { ): Promise<SkinPhotoAnalysisResponse> {
const body = new FormData(); const body = files instanceof FormData ? files : new FormData();
if (!(files instanceof FormData)) {
for (const file of files) body.append("photos", file); for (const file of files) body.append("photos", file);
const base = browser ? "/api" : PUBLIC_API_BASE;
const res = await fetch(`${base}/skincare/analyze-photos`, {
method: "POST",
body,
});
if (!res.ok) {
const detail = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(detail?.detail ?? res.statusText);
} }
return res.json();
return resolveClient(options).postForm("/skincare/analyze-photos", body);
} }

View file

@ -186,13 +186,27 @@
let editSection = $state<'basic' | 'ingredients' | 'assessment' | 'details' | 'notes'>('basic'); let editSection = $state<'basic' | 'ingredients' | 'assessment' | 'details' | 'notes'>('basic');
let activesPanelOpen = $state(true); let activesPanelOpen = $state(true);
async function requestProductParse(text: string): Promise<ProductParseResponse> {
const response = await fetch('/api/products/parse-text', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
});
if (!response.ok) {
const detail = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(detail?.detail ?? response.statusText);
}
return response.json();
}
async function parseWithAi() { async function parseWithAi() {
if (!aiText.trim()) return; if (!aiText.trim()) return;
aiLoading = true; aiLoading = true;
aiError = ''; aiError = '';
try { try {
const { parseProductText } = await import('$lib/api'); const r = await requestProductParse(aiText);
const r = await parseProductText(aiText);
applyAiResult(r); applyAiResult(r);
aiModalOpen = false; aiModalOpen = false;
} catch (e) { } catch (e) {

View file

@ -0,0 +1,18 @@
import type { ApiClientOptions } from "$lib/api";
import { error } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
type SessionEvent = Pick<RequestEvent, "fetch" | "locals">;
export function getSessionApiOptions(event: SessionEvent): ApiClientOptions {
const accessToken = event.locals.session?.accessToken;
if (!accessToken) {
throw error(401, "Authentication required");
}
return {
fetch: event.fetch,
accessToken,
};
}

View file

@ -0,0 +1,36 @@
import type { LayoutServerLoad } from './$types';
function getDisplayName(session: App.Locals['session']): string | null {
if (!session) return null;
const candidates = [
session.identity.name,
session.identity.preferred_username,
session.identity.email
];
for (const candidate of candidates) {
const value = candidate?.trim();
if (value) return value;
}
return null;
}
export const load: LayoutServerLoad = async ({ locals }) => {
if (!locals.session) {
return {
session: null
};
}
return {
session: {
expiresAt: locals.session.expiresAt,
user: locals.session.user,
identity: locals.session.identity,
profile: locals.session.profile,
displayName: getDisplayName(locals.session)
}
};
};

View file

@ -4,6 +4,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import * as m from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import { Button } from '$lib/components/ui/button';
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte'; import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
import { import {
House, House,
@ -17,34 +18,81 @@
Menu, Menu,
X X
} from 'lucide-svelte'; } from 'lucide-svelte';
import type { AuthIdentityPublic, AuthProfilePublic, AuthUserPublic } from '$lib/api/generated/types.gen';
import type { Snippet } from 'svelte';
let { children } = $props(); type ShellSession = {
expiresAt: number;
user: AuthUserPublic;
identity: AuthIdentityPublic;
profile: AuthProfilePublic | null;
displayName: string | null;
};
type NavRoute =
| '/'
| '/routines'
| '/routines/grooming-schedule'
| '/products'
| '/skin'
| '/profile'
| '/health/medications'
| '/health/lab-results';
type NavItem = {
route: NavRoute;
label: string;
icon: typeof House;
};
let {
data,
children
}: {
data: { session: ShellSession | null };
children: Snippet;
} = $props();
let mobileMenuOpen = $state(false); let mobileMenuOpen = $state(false);
const domainClass = $derived(getDomainClass(page.url.pathname)); const domainClass = $derived(getDomainClass(page.url.pathname));
const authMessages = m as typeof m &
Record<'auth_roleAdmin' | 'auth_roleMember' | 'auth_signedInAs' | 'auth_logout', () => string>;
const session = $derived(data.session);
const logoutPath = '/auth/logout';
const roleLabel = $derived(
session?.user.role === 'admin'
? authMessages['auth_roleAdmin']()
: session
? authMessages['auth_roleMember']()
: ''
);
const secondaryIdentity = $derived(
session?.identity.email ?? session?.identity.preferred_username ?? null
);
const displayName = $derived(session?.displayName ?? secondaryIdentity ?? m.common_unknown());
afterNavigate(() => { afterNavigate(() => {
mobileMenuOpen = false; mobileMenuOpen = false;
}); });
const navItems = $derived([ const navItems = $derived([
{ href: resolve('/'), label: m.nav_dashboard(), icon: House }, { route: '/', label: m.nav_dashboard(), icon: House },
{ href: resolve('/routines'), label: m.nav_routines(), icon: ClipboardList }, { route: '/routines', label: m.nav_routines(), icon: ClipboardList },
{ href: resolve('/routines/grooming-schedule'), label: m.nav_grooming(), icon: Scissors }, { route: '/routines/grooming-schedule', label: m.nav_grooming(), icon: Scissors },
{ href: resolve('/products'), label: m.nav_products(), icon: Package }, { route: '/products', label: m.nav_products(), icon: Package },
{ href: resolve('/skin'), label: m.nav_skin(), icon: Sparkles }, { route: '/skin', label: m.nav_skin(), icon: Sparkles },
{ href: resolve('/profile'), label: m.nav_profile(), icon: UserRound }, { route: '/profile', label: m.nav_profile(), icon: UserRound },
{ href: resolve('/health/medications'), label: m.nav_medications(), icon: Pill }, { route: '/health/medications', label: m.nav_medications(), icon: Pill },
{ href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: FlaskConical } { route: '/health/lab-results', label: m["nav_labResults"](), icon: FlaskConical }
]); ] satisfies NavItem[]);
function isActive(href: string) { function isActive(route: string) {
if (href === '/') return page.url.pathname === '/'; if (route === '/') return page.url.pathname === '/';
const pathname = page.url.pathname; const pathname = page.url.pathname;
if (!pathname.startsWith(href)) return false; if (!pathname.startsWith(route)) return false;
// Don't mark parent as active if a more-specific nav item also matches
const moreSpecific = navItems.some( const moreSpecific = navItems.some(
(item) => item.href !== href && item.href.startsWith(href) && pathname.startsWith(item.href) (item) =>
item.route !== route && item.route.startsWith(route) && pathname.startsWith(item.route)
); );
return !moreSpecific; return !moreSpecific;
} }
@ -65,6 +113,12 @@
<div class="app-mobile-titleblock"> <div class="app-mobile-titleblock">
<p class="app-mobile-overline">{m["nav_appSubtitle"]()}</p> <p class="app-mobile-overline">{m["nav_appSubtitle"]()}</p>
<span class="app-mobile-title">{m["nav_appName"]()}</span> <span class="app-mobile-title">{m["nav_appName"]()}</span>
{#if session}
<div class="mt-2 flex items-center gap-2 text-[0.68rem] uppercase tracking-[0.22em] text-muted-foreground">
<span class="truncate text-foreground">{displayName}</span>
<span class="rounded-full border border-border/80 bg-background/80 px-2 py-0.5 text-[0.62rem] text-foreground">{roleLabel}</span>
</div>
{/if}
</div> </div>
<button <button
type="button" type="button"
@ -104,12 +158,12 @@
</button> </button>
</div> </div>
<ul class="app-nav-list"> <ul class="app-nav-list">
{#each navItems as item (item.href)} {#each navItems as item (item.route)}
<li> <li>
<a <a
href={item.href} href={resolve(item.route)}
onclick={() => (mobileMenuOpen = false)} onclick={() => (mobileMenuOpen = false)}
class={`app-nav-link ${isActive(item.href) ? 'app-nav-link--active' : ''}`} class={`app-nav-link ${isActive(item.route) ? 'app-nav-link--active' : ''}`}
> >
<item.icon class="size-4 shrink-0" /> <item.icon class="size-4 shrink-0" />
<span>{item.label}</span> <span>{item.label}</span>
@ -118,6 +172,23 @@
{/each} {/each}
</ul> </ul>
<div class="app-sidebar-footer"> <div class="app-sidebar-footer">
{#if session}
<div class="mb-3 rounded-[1.35rem] border border-border/70 bg-background/88 px-4 py-3 shadow-sm">
<p class="text-[0.65rem] font-semibold uppercase tracking-[0.24em] text-muted-foreground">
{authMessages['auth_signedInAs']()}
</p>
<p class="mt-2 text-sm font-semibold text-foreground">{displayName}</p>
{#if secondaryIdentity}
<p class="mt-1 break-all text-xs text-muted-foreground">{secondaryIdentity}</p>
{/if}
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="rounded-full border border-border/80 bg-muted/40 px-2.5 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.16em] text-foreground">
{roleLabel}
</span>
<Button href={logoutPath} data-sveltekit-reload variant="outline" size="sm">{authMessages['auth_logout']()}</Button>
</div>
</div>
{/if}
<LanguageSwitcher /> <LanguageSwitcher />
</div> </div>
</aside> </aside>
@ -131,11 +202,11 @@
</div> </div>
</div> </div>
<ul class="app-nav-list"> <ul class="app-nav-list">
{#each navItems as item (item.href)} {#each navItems as item (item.route)}
<li> <li>
<a <a
href={item.href} href={resolve(item.route)}
class={`app-nav-link ${isActive(item.href) ? 'app-nav-link--active' : ''}`} class={`app-nav-link ${isActive(item.route) ? 'app-nav-link--active' : ''}`}
> >
<item.icon class="size-4 shrink-0" /> <item.icon class="size-4 shrink-0" />
<span>{item.label}</span> <span>{item.label}</span>
@ -144,6 +215,23 @@
{/each} {/each}
</ul> </ul>
<div class="app-sidebar-footer"> <div class="app-sidebar-footer">
{#if session}
<div class="mb-3 rounded-[1.35rem] border border-border/70 bg-background/88 px-4 py-3 shadow-sm">
<p class="text-[0.65rem] font-semibold uppercase tracking-[0.24em] text-muted-foreground">
{authMessages['auth_signedInAs']()}
</p>
<p class="mt-2 text-sm font-semibold text-foreground">{displayName}</p>
{#if secondaryIdentity}
<p class="mt-1 break-all text-xs text-muted-foreground">{secondaryIdentity}</p>
{/if}
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="rounded-full border border-border/80 bg-muted/40 px-2.5 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.16em] text-foreground">
{roleLabel}
</span>
<Button href={logoutPath} data-sveltekit-reload variant="outline" size="sm">{authMessages['auth_logout']()}</Button>
</div>
</div>
{/if}
<LanguageSwitcher /> <LanguageSwitcher />
</div> </div>
</nav> </nav>

View file

@ -1,12 +1,15 @@
import { getLabResults, getRoutines, getSkinSnapshots } from '$lib/api'; import { getLabResults, getRoutines, getSkinSnapshots } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import type { Routine, SkinConditionSnapshot } from '$lib/types'; import type { Routine, SkinConditionSnapshot } from '$lib/types';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
export const load: PageServerLoad = async (event) => {
const apiOptions = getSessionApiOptions(event);
const [routines, snapshots, labResults] = await Promise.all([ const [routines, snapshots, labResults] = await Promise.all([
getRoutines({ from_date: recentDate(14) }), getRoutines({ from_date: recentDate(14) }, apiOptions),
getSkinSnapshots({ from_date: recentDate(60) }), getSkinSnapshots({ from_date: recentDate(60) }, apiOptions),
getLabResults({ latest_only: true, limit: 8 }) getLabResults({ latest_only: true, limit: 8 }, apiOptions)
]); ]);
return { return {
recentRoutines: getFreshestRoutines(routines).slice(0, 10), recentRoutines: getFreshestRoutines(routines).slice(0, 10),

View file

@ -0,0 +1,23 @@
import { parseProductText } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const payload = await event.request.json().catch(() => null);
const text =
typeof payload === 'object' && payload !== null && 'text' in payload && typeof payload.text === 'string'
? payload.text.trim()
: '';
if (!text) {
throw error(400, 'Missing product text');
}
try {
const result = await parseProductText(text, getSessionApiOptions(event));
return json(result);
} catch (cause) {
throw error(502, cause instanceof Error ? cause.message : 'Failed to parse product text');
}
};

View file

@ -0,0 +1,23 @@
import { updateRoutineStep } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const PATCH: RequestHandler = async (event) => {
const payload = await event.request.json().catch(() => null);
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw error(400, 'Invalid routine step payload');
}
try {
const result = await updateRoutineStep(
event.params.id,
payload as Record<string, unknown>,
getSessionApiOptions(event)
);
return json(result);
} catch (cause) {
throw error(502, cause instanceof Error ? cause.message : 'Failed to update routine step');
}
};

View file

@ -0,0 +1,19 @@
import { analyzeSkinPhotos } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const formData = await event.request.formData();
if (formData.getAll('photos').length === 0) {
throw error(400, 'Missing skin photos');
}
try {
const result = await analyzeSkinPhotos(formData, getSessionApiOptions(event));
return json(result);
} catch (cause) {
throw error(502, cause instanceof Error ? cause.message : 'Failed to analyze skin photos');
}
};

View file

@ -1,4 +1,5 @@
import { deleteLabResult, getLabResults, updateLabResult } from '$lib/api'; import { deleteLabResult, getLabResults, updateLabResult } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
@ -9,7 +10,9 @@ const STATUS_GROUP_FLAGS = {
type StatusGroup = 'all' | 'abnormal' | 'normal' | 'uninterpreted'; type StatusGroup = 'all' | 'abnormal' | 'normal' | 'uninterpreted';
export const load: PageServerLoad = async ({ url }) => {
export const load: PageServerLoad = async (event) => {
const { url } = event;
const q = url.searchParams.get('q') ?? undefined; const q = url.searchParams.get('q') ?? undefined;
const test_code = url.searchParams.get('test_code') ?? undefined; const test_code = url.searchParams.get('test_code') ?? undefined;
const flag = url.searchParams.get('flag') ?? undefined; const flag = url.searchParams.get('flag') ?? undefined;
@ -41,7 +44,7 @@ export const load: PageServerLoad = async ({ url }) => {
latest_only: latestOnly, latest_only: latestOnly,
limit, limit,
offset offset
}); }, getSessionApiOptions(event));
const totalPages = Math.max(1, Math.ceil(resultPage.total / limit)); const totalPages = Math.max(1, Math.ceil(resultPage.total / limit));
return { return {
@ -64,8 +67,8 @@ function normalizeStatusGroup(value: string | null): StatusGroup {
} }
export const actions: Actions = { export const actions: Actions = {
update: async ({ request }) => { update: async (event) => {
const form = await request.formData(); const form = await event.request.formData();
const id = form.get('id') as string; const id = form.get('id') as string;
const collected_at = form.get('collected_at') as string; const collected_at = form.get('collected_at') as string;
const test_code = form.get('test_code') as string; const test_code = form.get('test_code') as string;
@ -136,20 +139,20 @@ export const actions: Actions = {
} }
try { try {
await updateLabResult(id, body); await updateLabResult(id, body, getSessionApiOptions(event));
return { updated: true }; return { updated: true };
} catch (e) { } catch (e) {
return fail(500, { error: (e as Error).message }); return fail(500, { error: (e as Error).message });
} }
}, },
delete: async ({ request }) => { delete: async (event) => {
const form = await request.formData(); const form = await event.request.formData();
const id = form.get('id') as string; const id = form.get('id') as string;
if (!id) return fail(400, { error: 'Missing id' }); if (!id) return fail(400, { error: 'Missing id' });
try { try {
await deleteLabResult(id); await deleteLabResult(id, getSessionApiOptions(event));
return { deleted: true }; return { deleted: true };
} catch (e) { } catch (e) {
return fail(500, { error: (e as Error).message }); return fail(500, { error: (e as Error).message });

View file

@ -1,4 +1,5 @@
import { createLabResult } from '$lib/api'; import { createLabResult } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
@ -7,8 +8,8 @@ export const load: PageServerLoad = async () => {
}; };
export const actions: Actions = { export const actions: Actions = {
default: async ({ request }) => { default: async (event) => {
const form = await request.formData(); const form = await event.request.formData();
const collected_at = form.get('collected_at') as string; const collected_at = form.get('collected_at') as string;
const test_code = form.get('test_code') as string; const test_code = form.get('test_code') as string;
const test_name_original = form.get('test_name_original') as string; const test_name_original = form.get('test_name_original') as string;
@ -32,7 +33,7 @@ export const actions: Actions = {
if (lab) body.lab = lab; if (lab) body.lab = lab;
try { try {
await createLabResult(body); await createLabResult(body, getSessionApiOptions(event));
} catch (error) { } catch (error) {
return fail(500, { error: (error as Error).message }); return fail(500, { error: (error as Error).message });
} }

View file

@ -1,8 +1,11 @@
import { getMedications } from '$lib/api'; import { getMedications } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
export const load: PageServerLoad = async (event) => {
const { url } = event;
const kind = url.searchParams.get('kind') ?? undefined; const kind = url.searchParams.get('kind') ?? undefined;
const medications = await getMedications({ kind }); const medications = await getMedications({ kind }, getSessionApiOptions(event));
return { medications, kind }; return { medications, kind };
}; };

View file

@ -1,4 +1,5 @@
import { createMedication } from '$lib/api'; import { createMedication } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
@ -7,8 +8,8 @@ export const load: PageServerLoad = async () => {
}; };
export const actions: Actions = { export const actions: Actions = {
default: async ({ request }) => { default: async (event) => {
const form = await request.formData(); const form = await event.request.formData();
const kind = form.get('kind') as string; const kind = form.get('kind') as string;
const product_name = form.get('product_name') as string; const product_name = form.get('product_name') as string;
const active_substance = form.get('active_substance') as string; const active_substance = form.get('active_substance') as string;
@ -24,7 +25,7 @@ export const actions: Actions = {
product_name, product_name,
active_substance: active_substance || undefined, active_substance: active_substance || undefined,
notes: notes || undefined notes: notes || undefined
}); }, getSessionApiOptions(event));
} catch (error) { } catch (error) {
return fail(500, { error: (error as Error).message }); return fail(500, { error: (error as Error).message });
} }

View file

@ -1,9 +1,11 @@
import { getProductSummaries } from '$lib/api'; import { getProductSummaries } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
export const load: PageServerLoad = async (event) => {
try { try {
const products = await getProductSummaries(); const products = await getProductSummaries({}, getSessionApiOptions(event));
return { products, loadError: null }; return { products, loadError: null };
} catch (error) { } catch (error) {
return { return {

View file

@ -6,12 +6,14 @@ import {
updateInventory as apiUpdateInventory, updateInventory as apiUpdateInventory,
deleteInventory as apiDeleteInventory deleteInventory as apiDeleteInventory
} from '$lib/api'; } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { error, fail, redirect } from '@sveltejs/kit'; import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => { export const load: PageServerLoad = async (event) => {
const { params } = event;
try { try {
const product = await getProduct(params.id); const product = await getProduct(params.id, getSessionApiOptions(event));
return { product }; return { product };
} catch { } catch {
error(404, 'Product not found'); error(404, 'Product not found');
@ -78,8 +80,9 @@ function parseContextRules(
} }
export const actions: Actions = { export const actions: Actions = {
update: async ({ params, request }) => { update: async (event) => {
const form = await request.formData(); const { params } = event;
const form = await event.request.formData();
const name = form.get('name') as string; const name = form.get('name') as string;
const brand = form.get('brand') as string; const brand = form.get('brand') as string;
@ -163,24 +166,25 @@ export const actions: Actions = {
body.context_rules = parseContextRules(form) ?? null; body.context_rules = parseContextRules(form) ?? null;
try { try {
const product = await updateProduct(params.id, body); const product = await updateProduct(params.id, body, getSessionApiOptions(event));
return { success: true, product }; return { success: true, product };
} catch (e) { } catch (e) {
return fail(500, { error: (e as Error).message }); return fail(500, { error: (e as Error).message });
} }
}, },
delete: async ({ params }) => { delete: async (event) => {
try { try {
await deleteProduct(params.id); await deleteProduct(event.params.id, getSessionApiOptions(event));
} catch (e) { } catch (e) {
return fail(500, { error: (e as Error).message }); return fail(500, { error: (e as Error).message });
} }
redirect(303, '/products'); redirect(303, '/products');
}, },
addInventory: async ({ params, request }) => { addInventory: async (event) => {
const form = await request.formData(); const { params } = event;
const form = await event.request.formData();
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
is_opened: form.get('is_opened') === 'true' is_opened: form.get('is_opened') === 'true'
}; };
@ -195,15 +199,15 @@ export const actions: Actions = {
const notes = form.get('notes'); const notes = form.get('notes');
if (notes) body.notes = notes; if (notes) body.notes = notes;
try { try {
await createInventory(params.id, body); await createInventory(params.id, body, getSessionApiOptions(event));
return { inventoryAdded: true }; return { inventoryAdded: true };
} catch (e) { } catch (e) {
return fail(500, { error: (e as Error).message }); return fail(500, { error: (e as Error).message });
} }
}, },
updateInventory: async ({ request }) => { updateInventory: async (event) => {
const form = await request.formData(); const form = await event.request.formData();
const inventoryId = form.get('inventory_id') as string; const inventoryId = form.get('inventory_id') as string;
if (!inventoryId) return fail(400, { error: 'Missing inventory_id' }); if (!inventoryId) return fail(400, { error: 'Missing inventory_id' });
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
@ -220,19 +224,19 @@ export const actions: Actions = {
const notes = form.get('notes'); const notes = form.get('notes');
body.notes = notes || null; body.notes = notes || null;
try { try {
await apiUpdateInventory(inventoryId, body); await apiUpdateInventory(inventoryId, body, getSessionApiOptions(event));
return { inventoryUpdated: true }; return { inventoryUpdated: true };
} catch (e) { } catch (e) {
return fail(500, { error: (e as Error).message }); return fail(500, { error: (e as Error).message });
} }
}, },
deleteInventory: async ({ request }) => { deleteInventory: async (event) => {
const form = await request.formData(); const form = await event.request.formData();
const inventoryId = form.get('inventory_id') as string; const inventoryId = form.get('inventory_id') as string;
if (!inventoryId) return fail(400, { error: 'Missing inventory_id' }); if (!inventoryId) return fail(400, { error: 'Missing inventory_id' });
try { try {
await apiDeleteInventory(inventoryId); await apiDeleteInventory(inventoryId, getSessionApiOptions(event));
return { inventoryDeleted: true }; return { inventoryDeleted: true };
} catch (e) { } catch (e) {
return fail(500, { error: (e as Error).message }); return fail(500, { error: (e as Error).message });

View file

@ -1,4 +1,5 @@
import { createProduct } from '$lib/api'; import { createProduct } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
@ -66,8 +67,8 @@ function parseContextRules(
} }
export const actions: Actions = { export const actions: Actions = {
default: async ({ request }) => { default: async (event) => {
const form = await request.formData(); const form = await event.request.formData();
const name = form.get('name') as string; const name = form.get('name') as string;
const brand = form.get('brand') as string; const brand = form.get('brand') as string;
@ -162,7 +163,7 @@ export const actions: Actions = {
if (contextRules) payload.context_rules = contextRules; if (contextRules) payload.context_rules = contextRules;
try { try {
await createProduct(payload); await createProduct(payload, getSessionApiOptions(event));
} catch (e) { } catch (e) {
return fail(500, { error: (e as Error).message }); return fail(500, { error: (e as Error).message });
} }

View file

@ -1,15 +1,17 @@
import { getProfile, updateProfile } from '$lib/api'; import { getProfile, updateProfile } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const profile = await getProfile(); export const load: PageServerLoad = async (event) => {
const profile = await getProfile(getSessionApiOptions(event));
return { profile }; return { profile };
}; };
export const actions: Actions = { export const actions: Actions = {
save: async ({ request }) => { save: async (event) => {
const form = await request.formData(); const form = await event.request.formData();
const birth_date_raw = String(form.get('birth_date') ?? '').trim(); const birth_date_raw = String(form.get('birth_date') ?? '').trim();
const sex_at_birth_raw = String(form.get('sex_at_birth') ?? '').trim(); const sex_at_birth_raw = String(form.get('sex_at_birth') ?? '').trim();
@ -20,7 +22,7 @@ export const actions: Actions = {
} }
try { try {
const profile = await updateProfile(payload); const profile = await updateProfile(payload, getSessionApiOptions(event));
return { saved: true, profile }; return { saved: true, profile };
} catch (e) { } catch (e) {
return fail(502, { error: (e as Error).message }); return fail(502, { error: (e as Error).message });

View file

@ -1,9 +1,12 @@
import { getRoutines } from '$lib/api'; import { getRoutines } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
export const load: PageServerLoad = async (event) => {
const { url } = event;
const from_date = url.searchParams.get('from_date') ?? recentDate(30); const from_date = url.searchParams.get('from_date') ?? recentDate(30);
const routines = await getRoutines({ from_date }); const routines = await getRoutines({ from_date }, getSessionApiOptions(event));
return { routines }; return { routines };
}; };

View file

@ -1,10 +1,16 @@
import { addRoutineStep, deleteRoutine, deleteRoutineStep, getProductSummaries, getRoutine } from '$lib/api'; import { addRoutineStep, deleteRoutine, deleteRoutineStep, getProductSummaries, getRoutine } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { error, fail, redirect } from '@sveltejs/kit'; import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => { export const load: PageServerLoad = async (event) => {
const { params } = event;
const apiOptions = getSessionApiOptions(event);
try { try {
const [routine, products] = await Promise.all([getRoutine(params.id), getProductSummaries()]); const [routine, products] = await Promise.all([
getRoutine(params.id, apiOptions),
getProductSummaries({}, apiOptions)
]);
return { routine, products }; return { routine, products };
} catch { } catch {
error(404, 'Routine not found'); error(404, 'Routine not found');
@ -12,8 +18,9 @@ export const load: PageServerLoad = async ({ params }) => {
}; };
export const actions: Actions = { export const actions: Actions = {
addStep: async ({ params, request }) => { addStep: async (event) => {
const form = await request.formData(); const { params } = event;
const form = await event.request.formData();
const product_id = form.get('product_id') as string; const product_id = form.get('product_id') as string;
const order_index = Number(form.get('order_index') ?? 0); const order_index = Number(form.get('order_index') ?? 0);
const dose = form.get('dose') as string; const dose = form.get('dose') as string;
@ -25,27 +32,27 @@ export const actions: Actions = {
if (region) body.region = region; if (region) body.region = region;
try { try {
await addRoutineStep(params.id, body); await addRoutineStep(params.id, body, getSessionApiOptions(event));
return { stepAdded: true }; return { stepAdded: true };
} catch (e) { } catch (e) {
return fail(500, { error: (e as Error).message }); return fail(500, { error: (e as Error).message });
} }
}, },
removeStep: async ({ request }) => { removeStep: async (event) => {
const form = await request.formData(); const form = await event.request.formData();
const step_id = form.get('step_id') as string; const step_id = form.get('step_id') as string;
try { try {
await deleteRoutineStep(step_id); await deleteRoutineStep(step_id, getSessionApiOptions(event));
return { stepRemoved: true }; return { stepRemoved: true };
} catch (e) { } catch (e) {
return fail(500, { error: (e as Error).message }); return fail(500, { error: (e as Error).message });
} }
}, },
delete: async ({ params }) => { delete: async (event) => {
try { try {
await deleteRoutine(params.id); await deleteRoutine(event.params.id, getSessionApiOptions(event));
} catch (e) { } catch (e) {
return fail(500, { error: (e as Error).message }); return fail(500, { error: (e as Error).message });
} }

View file

@ -2,7 +2,6 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action'; import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
import { updateRoutineStep } from '$lib/api';
import type { GroomingAction, RoutineStep } from '$lib/types'; import type { GroomingAction, RoutineStep } from '$lib/types';
import type { ActionData, PageData } from './$types'; import type { ActionData, PageData } from './$types';
import * as m from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
@ -32,6 +31,21 @@
// ── Drag & drop reordering ──────────────────────────────────── // ── Drag & drop reordering ────────────────────────────────────
let dndSaving = $state(false); let dndSaving = $state(false);
async function updateStep(stepId: string, payload: Record<string, unknown>): Promise<RoutineStep> {
const response = await fetch(`/api/routines/steps/${stepId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const detail = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(detail?.detail ?? response.statusText);
}
return response.json();
}
function handleConsider(e: CustomEvent<DndEvent<RoutineStep>>) { function handleConsider(e: CustomEvent<DndEvent<RoutineStep>>) {
steps = e.detail.items; steps = e.detail.items;
} }
@ -45,9 +59,7 @@
if (changed.length) { if (changed.length) {
dndSaving = true; dndSaving = true;
try { try {
await Promise.all( await Promise.all(changed.map((s) => updateStep(s.id, { order_index: s.order_index })));
changed.map((s) => updateRoutineStep(s.id, { order_index: s.order_index }))
);
} finally { } finally {
dndSaving = false; dndSaving = false;
} }
@ -85,7 +97,7 @@
payload.action_type = editDraft.action_type; payload.action_type = editDraft.action_type;
payload.action_notes = editDraft.action_notes || null; payload.action_notes = editDraft.action_notes || null;
} }
const updatedStep = await updateRoutineStep(step.id, payload); const updatedStep = await updateStep(step.id, payload);
steps = steps.map((s) => (s.id === step.id ? { ...s, ...updatedStep } : s)); steps = steps.map((s) => (s.id === step.id ? { ...s, ...updatedStep } : s));
editingStepId = null; editingStepId = null;
} catch (err) { } catch (err) {

View file

@ -3,17 +3,19 @@ import {
getGroomingSchedule, getGroomingSchedule,
updateGroomingScheduleEntry updateGroomingScheduleEntry
} from '$lib/api'; } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const schedule = await getGroomingSchedule(); export const load: PageServerLoad = async (event) => {
const schedule = await getGroomingSchedule(getSessionApiOptions(event));
return { schedule }; return { schedule };
}; };
export const actions: Actions = { export const actions: Actions = {
update: async ({ request }) => { update: async (event) => {
const form = await request.formData(); const form = await event.request.formData();
const id = form.get('id') as string; const id = form.get('id') as string;
if (!id) return fail(400, { error: 'Missing id' }); if (!id) return fail(400, { error: 'Missing id' });
const body: Record<string, unknown> = {}; const body: Record<string, unknown> = {};
@ -24,19 +26,19 @@ export const actions: Actions = {
const notes = (form.get('notes') as string)?.trim(); const notes = (form.get('notes') as string)?.trim();
body.notes = notes || null; body.notes = notes || null;
try { try {
await updateGroomingScheduleEntry(id, body); await updateGroomingScheduleEntry(id, body, getSessionApiOptions(event));
return { updated: true }; return { updated: true };
} catch (e) { } catch (e) {
return fail(500, { error: (e as Error).message }); return fail(500, { error: (e as Error).message });
} }
}, },
delete: async ({ request }) => { delete: async (event) => {
const form = await request.formData(); const form = await event.request.formData();
const id = form.get('id') as string; const id = form.get('id') as string;
if (!id) return fail(400, { error: 'Missing id' }); if (!id) return fail(400, { error: 'Missing id' });
try { try {
await deleteGroomingScheduleEntry(id); await deleteGroomingScheduleEntry(id, getSessionApiOptions(event));
return { deleted: true }; return { deleted: true };
} catch (e) { } catch (e) {
return fail(500, { error: (e as Error).message }); return fail(500, { error: (e as Error).message });

View file

@ -1,4 +1,5 @@
import { createGroomingScheduleEntry } from '$lib/api'; import { createGroomingScheduleEntry } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
@ -7,8 +8,8 @@ export const load: PageServerLoad = async () => {
}; };
export const actions: Actions = { export const actions: Actions = {
default: async ({ request }) => { default: async (event) => {
const form = await request.formData(); const form = await event.request.formData();
const day_of_week = form.get('day_of_week'); const day_of_week = form.get('day_of_week');
const action = form.get('action') as string; const action = form.get('action') as string;
@ -24,7 +25,7 @@ export const actions: Actions = {
if (notes) body.notes = notes; if (notes) body.notes = notes;
try { try {
await createGroomingScheduleEntry(body); await createGroomingScheduleEntry(body, getSessionApiOptions(event));
} catch (error) { } catch (error) {
return fail(500, { error: (error as Error).message }); return fail(500, { error: (error as Error).message });
} }

View file

@ -1,4 +1,5 @@
import { createRoutine } from '$lib/api'; import { createRoutine } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
@ -7,8 +8,8 @@ export const load: PageServerLoad = async () => {
}; };
export const actions: Actions = { export const actions: Actions = {
default: async ({ request }) => { default: async (event) => {
const form = await request.formData(); const form = await event.request.formData();
const routine_date = form.get('routine_date') as string; const routine_date = form.get('routine_date') as string;
const part_of_day = form.get('part_of_day') as string; const part_of_day = form.get('part_of_day') as string;
const notes = form.get('notes') as string; const notes = form.get('notes') as string;
@ -19,7 +20,10 @@ export const actions: Actions = {
let routine; let routine;
try { try {
routine = await createRoutine({ routine_date, part_of_day, notes: notes || undefined }); routine = await createRoutine(
{ routine_date, part_of_day, notes: notes || undefined },
getSessionApiOptions(event)
);
} catch (e) { } catch (e) {
return fail(500, { error: (e as Error).message }); return fail(500, { error: (e as Error).message });
} }

View file

@ -1,16 +1,19 @@
import { addRoutineStep, createRoutine, getProductSummaries, suggestBatch, suggestRoutine } from '$lib/api'; import { addRoutineStep, createRoutine, getProductSummaries, suggestBatch, suggestRoutine } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const products = await getProductSummaries(); export const load: PageServerLoad = async (event) => {
const apiOptions = getSessionApiOptions(event);
const products = await getProductSummaries({}, apiOptions);
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
return { products, today }; return { products, today };
}; };
export const actions: Actions = { export const actions: Actions = {
suggest: async ({ request }) => { suggest: async (event) => {
const form = await request.formData(); const form = await event.request.formData();
const routine_date = form.get('routine_date') as string; const routine_date = form.get('routine_date') as string;
const part_of_day = form.get('part_of_day') as 'am' | 'pm'; const part_of_day = form.get('part_of_day') as 'am' | 'pm';
const notes = (form.get('notes') as string) || undefined; const notes = (form.get('notes') as string) || undefined;
@ -29,15 +32,15 @@ export const actions: Actions = {
notes, notes,
include_minoxidil_beard, include_minoxidil_beard,
leaving_home leaving_home
}); }, getSessionApiOptions(event));
return { suggestion, routine_date, part_of_day }; return { suggestion, routine_date, part_of_day };
} catch (e) { } catch (e) {
return fail(502, { error: (e as Error).message }); return fail(502, { error: (e as Error).message });
} }
}, },
suggestBatch: async ({ request }) => { suggestBatch: async (event) => {
const form = await request.formData(); const form = await event.request.formData();
const from_date = form.get('from_date') as string; const from_date = form.get('from_date') as string;
const to_date = form.get('to_date') as string; const to_date = form.get('to_date') as string;
const notes = (form.get('notes') as string) || undefined; const notes = (form.get('notes') as string) || undefined;
@ -55,15 +58,18 @@ export const actions: Actions = {
} }
try { try {
const batch = await suggestBatch({ from_date, to_date, notes, include_minoxidil_beard, minimize_products }); const batch = await suggestBatch(
{ from_date, to_date, notes, include_minoxidil_beard, minimize_products },
getSessionApiOptions(event)
);
return { batch, from_date, to_date }; return { batch, from_date, to_date };
} catch (e) { } catch (e) {
return fail(502, { error: (e as Error).message }); return fail(502, { error: (e as Error).message });
} }
}, },
save: async ({ request }) => { save: async (event) => {
const form = await request.formData(); const form = await event.request.formData();
const routine_date = form.get('routine_date') as string; const routine_date = form.get('routine_date') as string;
const part_of_day = form.get('part_of_day') as string; const part_of_day = form.get('part_of_day') as string;
const steps_json = form.get('steps') as string; const steps_json = form.get('steps') as string;
@ -86,7 +92,8 @@ export const actions: Actions = {
let routineId: string; let routineId: string;
try { try {
const routine = await createRoutine({ routine_date, part_of_day }); const apiOptions = getSessionApiOptions(event);
const routine = await createRoutine({ routine_date, part_of_day }, apiOptions);
routineId = routine.id; routineId = routine.id;
for (let i = 0; i < steps.length; i++) { for (let i = 0; i < steps.length; i++) {
const s = steps[i]; const s = steps[i];
@ -96,7 +103,7 @@ export const actions: Actions = {
action_type: s.action_type || undefined, action_type: s.action_type || undefined,
action_notes: s.action_notes || undefined, action_notes: s.action_notes || undefined,
region: s.region || undefined region: s.region || undefined
}); }, apiOptions);
} }
} catch (e) { } catch (e) {
return fail(500, { error: (e as Error).message }); return fail(500, { error: (e as Error).message });
@ -104,8 +111,8 @@ export const actions: Actions = {
redirect(303, `/routines/${routineId}`); redirect(303, `/routines/${routineId}`);
}, },
saveBatch: async ({ request }) => { saveBatch: async (event) => {
const form = await request.formData(); const form = await event.request.formData();
const days_json = form.get('days') as string; const days_json = form.get('days') as string;
if (!days_json) { if (!days_json) {
@ -134,11 +141,12 @@ export const actions: Actions = {
} }
try { try {
const apiOptions = getSessionApiOptions(event);
for (const day of days) { for (const day of days) {
for (const part_of_day of ['am', 'pm'] as const) { for (const part_of_day of ['am', 'pm'] as const) {
const steps = part_of_day === 'am' ? day.am_steps : day.pm_steps; const steps = part_of_day === 'am' ? day.am_steps : day.pm_steps;
if (steps.length === 0) continue; if (steps.length === 0) continue;
const routine = await createRoutine({ routine_date: day.date, part_of_day }); const routine = await createRoutine({ routine_date: day.date, part_of_day }, apiOptions);
for (let i = 0; i < steps.length; i++) { for (let i = 0; i < steps.length; i++) {
const s = steps[i]; const s = steps[i];
await addRoutineStep(routine.id, { await addRoutineStep(routine.id, {
@ -147,7 +155,7 @@ export const actions: Actions = {
action_type: s.action_type || undefined, action_type: s.action_type || undefined,
action_notes: s.action_notes || undefined, action_notes: s.action_notes || undefined,
region: s.region || undefined region: s.region || undefined
}); }, apiOptions);
} }
} }
} }

View file

@ -1,16 +1,19 @@
import { deleteSkinSnapshot, getSkinSnapshots, updateSkinSnapshot } from '$lib/api'; import { deleteSkinSnapshot, getSkinSnapshots, updateSkinSnapshot } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
export const load: PageServerLoad = async (event) => {
const { url } = event;
const overall_state = url.searchParams.get('overall_state') ?? undefined; const overall_state = url.searchParams.get('overall_state') ?? undefined;
const snapshots = await getSkinSnapshots({ overall_state }); const snapshots = await getSkinSnapshots({ overall_state }, getSessionApiOptions(event));
return { snapshots, overall_state }; return { snapshots, overall_state };
}; };
export const actions: Actions = { export const actions: Actions = {
update: async ({ request }) => { update: async (event) => {
const form = await request.formData(); const form = await event.request.formData();
const id = form.get('id') as string; const id = form.get('id') as string;
const snapshot_date = form.get('snapshot_date') as string; const snapshot_date = form.get('snapshot_date') as string;
const overall_state = form.get('overall_state') as string; const overall_state = form.get('overall_state') as string;
@ -50,19 +53,19 @@ export const actions: Actions = {
if (sebum_cheeks) body.sebum_cheeks = Number(sebum_cheeks); if (sebum_cheeks) body.sebum_cheeks = Number(sebum_cheeks);
try { try {
await updateSkinSnapshot(id, body); await updateSkinSnapshot(id, body, getSessionApiOptions(event));
return { updated: true }; return { updated: true };
} catch (e) { } catch (e) {
return fail(500, { error: (e as Error).message }); return fail(500, { error: (e as Error).message });
} }
}, },
delete: async ({ request }) => { delete: async (event) => {
const form = await request.formData(); const form = await event.request.formData();
const id = form.get('id') as string; const id = form.get('id') as string;
if (!id) return fail(400, { error: 'Missing id' }); if (!id) return fail(400, { error: 'Missing id' });
try { try {
await deleteSkinSnapshot(id); await deleteSkinSnapshot(id, getSessionApiOptions(event));
return { deleted: true }; return { deleted: true };
} catch (e) { } catch (e) {
return fail(500, { error: (e as Error).message }); return fail(500, { error: (e as Error).message });

View file

@ -1,4 +1,5 @@
import { createSkinSnapshot } from '$lib/api'; import { createSkinSnapshot } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
@ -7,8 +8,8 @@ export const load: PageServerLoad = async () => {
}; };
export const actions: Actions = { export const actions: Actions = {
default: async ({ request }) => { default: async (event) => {
const form = await request.formData(); const form = await event.request.formData();
const snapshot_date = form.get('snapshot_date') as string; const snapshot_date = form.get('snapshot_date') as string;
const overall_state = form.get('overall_state') as string; const overall_state = form.get('overall_state') as string;
const texture = form.get('texture') as string; const texture = form.get('texture') as string;
@ -52,7 +53,7 @@ export const actions: Actions = {
if (sebum_cheeks) body.sebum_cheeks = Number(sebum_cheeks); if (sebum_cheeks) body.sebum_cheeks = Number(sebum_cheeks);
try { try {
await createSkinSnapshot(body); await createSkinSnapshot(body, getSessionApiOptions(event));
} catch (error) { } catch (error) {
return fail(500, { error: (error as Error).message }); return fail(500, { error: (error as Error).message });
} }

View file

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { analyzeSkinPhotos } from '$lib/api';
import FlashMessages from '$lib/components/FlashMessages.svelte'; import FlashMessages from '$lib/components/FlashMessages.svelte';
import LabeledInputField from '$lib/components/forms/LabeledInputField.svelte'; import LabeledInputField from '$lib/components/forms/LabeledInputField.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@ -73,6 +72,20 @@
let aiLoading = $state(false); let aiLoading = $state(false);
let aiError = $state(''); let aiError = $state('');
async function requestSkinAnalysis(formData: FormData) {
const response = await fetch('/api/skin/analyze-photos', {
method: 'POST',
body: formData
});
if (!response.ok) {
const detail = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(detail?.detail ?? response.statusText);
}
return response.json();
}
const flashMessages = $derived(form?.error ? [{ kind: 'error' as const, text: form.error }] : []); const flashMessages = $derived(form?.error ? [{ kind: 'error' as const, text: form.error }] : []);
$effect(() => { $effect(() => {
@ -103,7 +116,12 @@
aiLoading = true; aiLoading = true;
aiError = ''; aiError = '';
try { try {
const result = await analyzeSkinPhotos(selectedFiles); const formData = new FormData();
for (const file of selectedFiles) {
formData.append('photos', file);
}
const result = await requestSkinAnalysis(formData);
if (result.overall_state) overallState = result.overall_state; if (result.overall_state) overallState = result.overall_state;
if (result.texture) texture = result.texture; if (result.texture) texture = result.texture;
if (result.skin_type) skinType = result.skin_type; if (result.skin_type) skinType = result.skin_type;