feat(frontend): streamline AI workflows and localize remaining UI copy

Move product and skin AI helpers into modal flows, simplify product edit/inventory navigation, and improve responsive actions so core forms are faster to use. Localize remaining frontend labels/placeholders and strip deprecated Rollup output options to remove deploy-time build warnings.
This commit is contained in:
Piotr Oleszczyk 2026-03-04 18:13:49 +01:00
parent 83ba4cc5c0
commit d4fbc1faf5
15 changed files with 921 additions and 532 deletions

View file

@ -22,6 +22,12 @@
"common_unknown_value": "Unknown",
"common_optional_notes": "optional",
"common_steps": "steps",
"common_am": "AM",
"common_pm": "PM",
"common_toggleMenu": "Toggle menu",
"common_dragToReorder": "drag to reorder",
"common_editStep": "edit step",
"common_pricePerUse": "PLN/use",
"dashboard_title": "Dashboard",
"dashboard_subtitle": "Your recent health & skincare overview",
@ -61,8 +67,9 @@
"products_colBrand": "Brand",
"products_colTargets": "Targets",
"products_colTime": "Time",
"products_colPricePerUse": "PLN/use",
"products_newTitle": "New Product",
"products_backToList": "Products",
"products_backToList": "Products",
"products_createProduct": "Create product",
"products_saveChanges": "Save changes",
"products_deleteProduct": "Delete product",
@ -106,7 +113,7 @@
"routines_addNew": "+ New routine",
"routines_noRoutines": "No routines found.",
"routines_newTitle": "New Routine",
"routines_backToList": "Routines",
"routines_backToList": "Routines",
"routines_detailsTitle": "Routine details",
"routines_date": "Date *",
"routines_amOrPm": "AM or PM *",
@ -124,12 +131,15 @@
"routines_dosePlaceholder": "e.g. 2 pumps",
"routines_region": "Region",
"routines_regionPlaceholder": "e.g. face",
"routines_action": "Action",
"routines_selectAction": "Select action",
"routines_actionNotesPlaceholder": "Optional notes",
"routines_addStepBtn": "Add step",
"routines_unknownStep": "Unknown step",
"routines_noSteps": "No steps yet.",
"grooming_title": "Grooming Schedule",
"grooming_backToRoutines": "Routines",
"grooming_backToRoutines": "Routines",
"grooming_addEntry": "+ Add entry",
"grooming_entryAdded": "Entry added.",
"grooming_entryUpdated": "Entry updated.",
@ -152,7 +162,7 @@
"grooming_daySunday": "Sunday",
"suggest_title": "AI Routine Suggestion",
"suggest_backToRoutines": "Routines",
"suggest_backToRoutines": "Routines",
"suggest_singleTab": "Single routine",
"suggest_batchTab": "Batch / Vacation",
"suggest_singleParams": "Parameters",
@ -165,6 +175,8 @@
"suggest_leavingHomeHint": "Affects SPF selection — checked: SPF50+, unchecked: SPF30.",
"suggest_minoxidilToggleLabel": "Prioritize beard/mustache density (minoxidil)",
"suggest_minoxidilToggleHint": "When enabled, AI will explicitly consider minoxidil for beard/mustache areas if available.",
"suggest_minimizeProductsLabel": "Minimize products",
"suggest_minimizeProductsHint": "Limit the number of different products",
"suggest_generateBtn": "Generate suggestion",
"suggest_generating": "Generating…",
"suggest_proposalTitle": "Suggestion",
@ -258,6 +270,7 @@
"labResults_flagNone": "None",
"labResults_date": "Date *",
"labResults_loincCode": "LOINC code *",
"labResults_loincExample": "e.g. 718-7",
"labResults_testName": "Test name",
"labResults_testNamePlaceholder": "e.g. Hemoglobin",
"labResults_lab": "Lab",
@ -336,7 +349,7 @@
"productForm_aiPrefill": "AI pre-fill",
"productForm_aiPrefillText": "Paste product description from a website, ingredient list, or other text. AI will fill in available fields — you can review and correct before saving.",
"productForm_pasteText": "Paste product description, INCI ingredients here...",
"productForm_parseWithAI": "Fill fields (AI)",
"productForm_parseWithAI": "Fill",
"productForm_parsing": "Processing…",
"productForm_basicInfo": "Basic info",
"productForm_name": "Name *",
@ -346,6 +359,7 @@
"productForm_lineName": "Line / series",
"productForm_lineNamePlaceholder": "e.g. Hydro Boost",
"productForm_url": "URL",
"productForm_urlPlaceholder": "https://...",
"productForm_sku": "SKU",
"productForm_skuPlaceholder": "e.g. NTR-HB-50",
"productForm_barcode": "Barcode / EAN",
@ -370,11 +384,14 @@
"productForm_contraindicationsPlaceholder": "e.g. active rosacea flares",
"productForm_ingredients": "Ingredients",
"productForm_inciList": "INCI list (one ingredient per line)",
"productForm_inciPlaceholder": "Aqua\nGlycerin\nNiacinamide",
"productForm_activeIngredients": "Active ingredients",
"productForm_addActive": "+ Add active",
"productForm_noActives": "No actives added yet.",
"productForm_activeName": "Name",
"productForm_activeNamePlaceholder": "e.g. Niacinamide",
"productForm_activePercent": "%",
"productForm_activePercentPlaceholder": "e.g. 5",
"productForm_activeStrength": "Strength",
"productForm_activeIrritation": "Irritation",
"productForm_activeFunctions": "Functions",
@ -387,6 +404,19 @@
"productForm_ctxLowUvOnly": "Low UV only (evening/covered)",
"productForm_productDetails": "Product details",
"productForm_priceTier": "Price tier",
"productForm_price": "Price",
"productForm_currency": "Currency",
"productForm_priceAmountPlaceholder": "e.g. 79.99",
"productForm_priceCurrencyPlaceholder": "PLN",
"productForm_sizePlaceholder": "e.g. 50",
"productForm_fullWeightPlaceholder": "e.g. 120",
"productForm_emptyWeightPlaceholder": "e.g. 30",
"productForm_paoPlaceholder": "e.g. 12",
"productForm_phMinPlaceholder": "e.g. 3.5",
"productForm_phMaxPlaceholder": "e.g. 4.5",
"productForm_minIntervalPlaceholder": "e.g. 24",
"productForm_maxFrequencyPlaceholder": "e.g. 3",
"productForm_needleLengthPlaceholder": "e.g. 0.25",
"productForm_selectTier": "Select tier",
"productForm_sizeMl": "Size (ml)",
"productForm_fullWeightG": "Full weight (g)",

View file

@ -22,6 +22,12 @@
"common_unknown_value": "Nieznane",
"common_optional_notes": "opcjonalnie",
"common_steps": "kroków",
"common_am": "AM",
"common_pm": "PM",
"common_toggleMenu": "Przełącz menu",
"common_dragToReorder": "przeciągnij, aby zmienić kolejność",
"common_editStep": "edytuj krok",
"common_pricePerUse": "PLN/use",
"dashboard_title": "Dashboard",
"dashboard_subtitle": "Przegląd zdrowia i pielęgnacji",
@ -63,8 +69,9 @@
"products_colBrand": "Marka",
"products_colTargets": "Cele",
"products_colTime": "Pora",
"products_colPricePerUse": "PLN/use",
"products_newTitle": "Nowy produkt",
"products_backToList": "Produkty",
"products_backToList": "Produkty",
"products_createProduct": "Utwórz produkt",
"products_saveChanges": "Zapisz zmiany",
"products_deleteProduct": "Usuń produkt",
@ -110,7 +117,7 @@
"routines_addNew": "+ Nowa rutyna",
"routines_noRoutines": "Nie znaleziono rutyn.",
"routines_newTitle": "Nowa rutyna",
"routines_backToList": "Rutyny",
"routines_backToList": "Rutyny",
"routines_detailsTitle": "Szczegóły rutyny",
"routines_date": "Data *",
"routines_amOrPm": "AM lub PM *",
@ -128,12 +135,15 @@
"routines_dosePlaceholder": "np. 2 pompki",
"routines_region": "Okolica",
"routines_regionPlaceholder": "np. twarz",
"routines_action": "Czynność",
"routines_selectAction": "Wybierz czynność",
"routines_actionNotesPlaceholder": "Opcjonalne notatki",
"routines_addStepBtn": "Dodaj krok",
"routines_unknownStep": "Nieznany krok",
"routines_noSteps": "Brak kroków.",
"grooming_title": "Harmonogram pielęgnacji",
"grooming_backToRoutines": "Rutyny",
"grooming_backToRoutines": "Rutyny",
"grooming_addEntry": "+ Dodaj wpis",
"grooming_entryAdded": "Wpis dodany.",
"grooming_entryUpdated": "Wpis zaktualizowany.",
@ -156,7 +166,7 @@
"grooming_daySunday": "Niedziela",
"suggest_title": "Propozycja rutyny AI",
"suggest_backToRoutines": "Rutyny",
"suggest_backToRoutines": "Rutyny",
"suggest_singleTab": "Jedna rutyna",
"suggest_batchTab": "Batch / Urlop",
"suggest_singleParams": "Parametry",
@ -169,6 +179,8 @@
"suggest_leavingHomeHint": "Wpływa na wybór SPF — zaznaczone: SPF50+, odznaczone: SPF30.",
"suggest_minoxidilToggleLabel": "Priorytet: gęstość brody/wąsów (minoksydyl)",
"suggest_minoxidilToggleHint": "Po włączeniu AI jawnie uwzględni minoksydyl dla obszaru brody/wąsów, jeśli jest dostępny.",
"suggest_minimizeProductsLabel": "Minimalizuj produkty",
"suggest_minimizeProductsHint": "Ogranicz liczbę różnych produktów",
"suggest_generateBtn": "Generuj propozycję",
"suggest_generating": "Generuję…",
"suggest_proposalTitle": "Propozycja",
@ -270,6 +282,7 @@
"labResults_flagNone": "Brak",
"labResults_date": "Data *",
"labResults_loincCode": "Kod LOINC *",
"labResults_loincExample": "np. 718-7",
"labResults_testName": "Nazwa badania",
"labResults_testNamePlaceholder": "np. Hemoglobina",
"labResults_lab": "Laboratorium",
@ -350,7 +363,7 @@
"productForm_aiPrefill": "Uzupełnienie AI",
"productForm_aiPrefillText": "Wklej opis produktu ze strony, listę składników lub inny tekst. AI uzupełni dostępne pola — możesz je przejrzeć i poprawić przed zapisem.",
"productForm_pasteText": "Wklej tutaj opis produktu, składniki INCI...",
"productForm_parseWithAI": "Uzupełnij pola (AI)",
"productForm_parseWithAI": "Uzupełnij",
"productForm_parsing": "Przetwarzam…",
"productForm_basicInfo": "Informacje podstawowe",
"productForm_name": "Nazwa *",
@ -360,6 +373,7 @@
"productForm_lineName": "Linia / seria",
"productForm_lineNamePlaceholder": "np. Hydro Boost",
"productForm_url": "URL",
"productForm_urlPlaceholder": "https://...",
"productForm_sku": "SKU",
"productForm_skuPlaceholder": "np. NTR-HB-50",
"productForm_barcode": "Kod kreskowy / EAN",
@ -384,11 +398,14 @@
"productForm_contraindicationsPlaceholder": "np. aktywna rosacea",
"productForm_ingredients": "Składniki",
"productForm_inciList": "Lista INCI (jeden składnik na linię)",
"productForm_inciPlaceholder": "Aqua\nGlycerin\nNiacinamide",
"productForm_activeIngredients": "Składniki aktywne",
"productForm_addActive": "+ Dodaj aktywny",
"productForm_noActives": "Brak składników aktywnych.",
"productForm_activeName": "Nazwa",
"productForm_activeNamePlaceholder": "np. Niacinamide",
"productForm_activePercent": "%",
"productForm_activePercentPlaceholder": "np. 5",
"productForm_activeStrength": "Siła",
"productForm_activeIrritation": "Podrażnienie",
"productForm_activeFunctions": "Funkcje",
@ -401,6 +418,19 @@
"productForm_ctxLowUvOnly": "Tylko przy niskim UV (wieczór/zakrycie)",
"productForm_productDetails": "Szczegóły produktu",
"productForm_priceTier": "Przedział cenowy",
"productForm_price": "Cena",
"productForm_currency": "Waluta",
"productForm_priceAmountPlaceholder": "np. 79.99",
"productForm_priceCurrencyPlaceholder": "PLN",
"productForm_sizePlaceholder": "np. 50",
"productForm_fullWeightPlaceholder": "np. 120",
"productForm_emptyWeightPlaceholder": "np. 30",
"productForm_paoPlaceholder": "np. 12",
"productForm_phMinPlaceholder": "np. 3.5",
"productForm_phMaxPlaceholder": "np. 4.5",
"productForm_minIntervalPlaceholder": "np. 24",
"productForm_maxFrequencyPlaceholder": "np. 3",
"productForm_needleLengthPlaceholder": "np. 0.25",
"productForm_selectTier": "Wybierz przedział",
"productForm_sizeMl": "Rozmiar (ml)",
"productForm_fullWeightG": "Waga pełna (g)",

View file

@ -6,11 +6,29 @@
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 { Tabs, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { parseProductText, type ProductParseResponse } from '$lib/api';
import { m } from '$lib/paraglide/messages.js';
import { Sparkles, X } from 'lucide-svelte';
let { product }: { product?: Product } = $props();
let {
product,
dirty = $bindable(false),
saveVersion = 0,
showAiTrigger = true,
computedPriceLabel,
computedPricePerUseLabel,
computedPriceTierLabel
}: {
product?: Product;
dirty?: boolean;
saveVersion?: number;
showAiTrigger?: boolean;
computedPriceLabel?: string;
computedPricePerUseLabel?: string;
computedPriceTierLabel?: string;
} = $props();
// ── Enum option lists ─────────────────────────────────────────────────────
@ -162,6 +180,7 @@
let usageNotes = $state(untrack(() => product?.usage_notes ?? ''));
let inciText = $state(untrack(() => product?.inci?.join('\n') ?? ''));
let contraindicationsText = $state(untrack(() => product?.contraindications?.join('\n') ?? ''));
let personalToleranceNotes = $state(untrack(() => product?.personal_tolerance_notes ?? ''));
let recommendedFor = $state<string[]>(untrack(() => [...(product?.recommended_for ?? [])]));
let targetConcerns = $state<string[]>(untrack(() => [...(product?.targets ?? [])]));
@ -170,10 +189,12 @@
// ── AI pre-fill state ─────────────────────────────────────────────────────
let aiPanelOpen = $state(false);
let aiModalOpen = $state(false);
let aiText = $state('');
let aiLoading = $state(false);
let aiError = $state('');
let editSection = $state<'basic' | 'ingredients' | 'assessment' | 'details' | 'notes'>('basic');
let activesPanelOpen = $state(true);
async function parseWithAi() {
if (!aiText.trim()) return;
@ -182,7 +203,7 @@
try {
const r = await parseProductText(aiText);
applyAiResult(r);
aiPanelOpen = false;
aiModalOpen = false;
} catch (e) {
aiError = (e as Error).message;
} finally {
@ -363,36 +384,157 @@
const selectClass =
'h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring';
export function openAiModal() {
aiError = '';
aiModalOpen = true;
}
function closeAiModal() {
if (aiLoading) return;
aiModalOpen = false;
}
function handleModalKeydown(event: KeyboardEvent) {
if (!aiModalOpen) return;
if (event.key === 'Escape') {
event.preventDefault();
closeAiModal();
}
}
const formFingerprint = $derived(
JSON.stringify({
name,
brand,
lineName,
url,
sku,
barcode,
sizeMl,
fullWeightG,
emptyWeightG,
paoMonths,
phMin,
phMax,
minIntervalHours,
maxFrequencyPerWeek,
needleLengthMm,
usageNotes,
inciText,
contraindicationsText,
personalToleranceNotes,
recommendedFor,
targetConcerns,
isMedication,
isTool,
category,
recommendedTime,
leaveOn,
texture,
absorptionSpeed,
priceAmount,
priceCurrency,
fragranceFree,
essentialOilsFree,
alcoholDenatFree,
pregnancySafe,
personalRepurchaseIntent,
ctxAfterShaving,
ctxAfterAcids,
ctxAfterRetinoids,
ctxCompromisedBarrier,
ctxLowUvOnly,
effectValues,
actives: activesJson
})
);
let baselineFingerprint = $state('');
let baselineProductId = $state('');
let baselineSaveVersion = $state(-1);
$effect(() => {
const currentProductId = product?.id ?? '';
const currentFingerprint = formFingerprint;
if (
!baselineFingerprint ||
currentProductId !== baselineProductId ||
saveVersion !== baselineSaveVersion
) {
baselineFingerprint = currentFingerprint;
baselineProductId = currentProductId;
baselineSaveVersion = saveVersion;
dirty = false;
return;
}
dirty = currentFingerprint !== baselineFingerprint;
});
</script>
<!-- ── AI pre-fill ──────────────────────────────────────────────────────────── -->
<Card>
<CardHeader>
<button type="button" class="flex w-full items-center justify-between text-left"
onclick={() => (aiPanelOpen = !aiPanelOpen)}>
<svelte:window onkeydown={handleModalKeydown} />
<Tabs bind:value={editSection} class="space-y-2">
<TabsList class="h-auto w-full justify-start gap-1 overflow-x-auto p-1 whitespace-nowrap">
<TabsTrigger value="basic" class="shrink-0 px-3">{m["productForm_basicInfo"]()}</TabsTrigger>
<TabsTrigger value="ingredients" class="shrink-0 px-3">{m["productForm_ingredients"]()}</TabsTrigger>
<TabsTrigger value="assessment" class="shrink-0 px-3">{m["productForm_effectProfile"]()}</TabsTrigger>
<TabsTrigger value="details" class="shrink-0 px-3">{m["productForm_productDetails"]()}</TabsTrigger>
<TabsTrigger value="notes" class="shrink-0 px-3">{m["productForm_personalNotes"]()}</TabsTrigger>
</TabsList>
</Tabs>
{#if showAiTrigger}
<div class="flex justify-end">
<Button type="button" variant="outline" size="sm" onclick={openAiModal}>
<Sparkles class="size-4" />
{m["productForm_aiPrefill"]()}
</Button>
</div>
{/if}
{#if aiModalOpen}
<button
type="button"
class="fixed inset-0 z-50 bg-black/50"
onclick={closeAiModal}
aria-label={m.common_cancel()}
></button>
<div class="fixed inset-x-3 bottom-3 top-3 z-50 mx-auto flex max-w-2xl items-center md:inset-x-6 md:inset-y-8">
<Card class="max-h-full w-full overflow-hidden">
<CardHeader class="border-b border-border">
<div class="flex items-center justify-between gap-3">
<CardTitle>{m["productForm_aiPrefill"]()}</CardTitle>
<span class="text-sm text-muted-foreground">{aiPanelOpen ? '▲' : '▼'}</span>
</button>
<Button type="button" variant="ghost" size="sm" class="h-8 w-8 p-0" onclick={closeAiModal} aria-label={m.common_cancel()}>
<X class="size-4" />
</Button>
</div>
</CardHeader>
{#if aiPanelOpen}
<CardContent class="space-y-3">
<CardContent class="space-y-3 overflow-y-auto p-4">
<p class="text-sm text-muted-foreground">{m["productForm_aiPrefillText"]()}</p>
<textarea bind:value={aiText} rows="6"
placeholder={m["productForm_pasteText"]()}
class={textareaClass}></textarea>
<textarea bind:value={aiText} rows="8" placeholder={m["productForm_pasteText"]()} class={textareaClass}></textarea>
{#if aiError}
<p class="text-sm text-destructive">{aiError}</p>
{/if}
<Button type="button" onclick={parseWithAi}
disabled={aiLoading || !aiText.trim()}>
{aiLoading ? m["productForm_parsing"]() : m["productForm_parseWithAI"]()}
</Button>
</CardContent>
<div class="flex justify-end gap-2">
<Button type="button" variant="outline" onclick={closeAiModal} disabled={aiLoading}>{m.common_cancel()}</Button>
<Button type="button" onclick={parseWithAi} disabled={aiLoading || !aiText.trim()}>
{#if aiLoading}
{m["productForm_parsing"]()}
{:else}
<Sparkles class="size-4" /> {m["productForm_parseWithAI"]()}
{/if}
</Button>
</div>
</CardContent>
</Card>
</div>
{/if}
<!-- ── Basic info ──────────────────────────────────────────────────────────── -->
<Card>
<Card class={editSection === 'basic' ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_basicInfo"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
@ -412,7 +554,7 @@
</div>
<div class="space-y-2">
<Label for="url">{m["productForm_url"]()}</Label>
<Input id="url" name="url" type="url" placeholder="https://…" bind:value={url} />
<Input id="url" name="url" type="url" placeholder={m["productForm_urlPlaceholder"]()} bind:value={url} />
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
@ -429,7 +571,7 @@
</Card>
<!-- ── Classification ────────────────────────────────────────────────────── -->
<Card>
<Card class={editSection === 'basic' ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_classification"]()}</CardTitle></CardHeader>
<CardContent>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
@ -454,8 +596,8 @@
{recommendedTime ? recommendedTime.toUpperCase() : m["productForm_timeOptions"]()}
</SelectTrigger>
<SelectContent>
<SelectItem value="am">AM</SelectItem>
<SelectItem value="pm">PM</SelectItem>
<SelectItem value="am">{m.common_am()}</SelectItem>
<SelectItem value="pm">{m.common_pm()}</SelectItem>
<SelectItem value="both">{m["productForm_timeBoth"]()}</SelectItem>
</SelectContent>
</Select>
@ -503,7 +645,7 @@
</Card>
<!-- ── Skin profile ───────────────────────────────────────────────────────── -->
<Card>
<Card class={editSection === 'ingredients' ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_skinProfile"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
@ -569,7 +711,7 @@
</Card>
<!-- ── Ingredients ────────────────────────────────────────────────────────── -->
<Card>
<Card class={editSection === 'ingredients' ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_ingredients"]()}</CardTitle></CardHeader>
<CardContent class="space-y-6">
<div class="space-y-2">
@ -578,7 +720,7 @@
id="inci"
name="inci"
rows="5"
placeholder="Aqua&#10;Glycerin&#10;Niacinamide"
placeholder={m["productForm_inciPlaceholder"]()}
class={textareaClass}
bind:value={inciText}
></textarea>
@ -586,19 +728,27 @@
<div class="space-y-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Label>{m["productForm_activeIngredients"]()}</Label>
<button
type="button"
class="flex w-full items-center justify-between rounded-md border border-border px-3 py-2 text-left"
onclick={() => (activesPanelOpen = !activesPanelOpen)}
>
<span class="text-sm font-medium">{m["productForm_activeIngredients"]()}</span>
<span class="text-xs text-muted-foreground">{activesPanelOpen ? 'Ukryj' : 'Pokaż'}</span>
</button>
<Button type="button" variant="outline" size="sm" onclick={addActive}>{m["productForm_addActive"]()}</Button>
</div>
<input type="hidden" name="actives_json" value={activesJson} />
{#if activesPanelOpen}
{#each actives as active, i}
<div class="rounded-md border border-border p-3 space-y-3">
<div class="flex items-end gap-2">
<div class="min-w-0 flex-1 space-y-1">
<Label class="text-xs">{m["productForm_activeName"]()}</Label>
<Input
placeholder="e.g. Niacinamide"
placeholder={m["productForm_activeNamePlaceholder"]()}
bind:value={active.name}
/>
</div>
@ -608,7 +758,7 @@
size="sm"
onclick={() => removeActive(i)}
class="h-7 w-7 shrink-0 p-0 text-destructive hover:text-destructive"
>✕</Button>
><X class="size-3.5" /></Button>
</div>
<div class="grid grid-cols-3 gap-2">
<div class="space-y-1">
@ -618,7 +768,7 @@
min="0"
max="100"
step="0.01"
placeholder="e.g. 5"
placeholder={m["productForm_activePercentPlaceholder"]()}
bind:value={active.percent}
/>
</div>
@ -664,12 +814,13 @@
{#if actives.length === 0}
<p class="text-sm text-muted-foreground">{m["productForm_noActives"]()}</p>
{/if}
{/if}
</div>
</CardContent>
</Card>
<!-- ── Effect profile ─────────────────────────────────────────────────────── -->
<Card>
<Card class={editSection === 'assessment' ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_effectProfile"]()}</CardTitle></CardHeader>
<CardContent>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
@ -694,7 +845,7 @@
</Card>
<!-- ── Context rules ──────────────────────────────────────────────────────── -->
<Card>
<Card class={editSection === 'assessment' ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_contextRules"]()}</CardTitle></CardHeader>
<CardContent>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
@ -765,49 +916,70 @@
</Card>
<!-- ── Product details ────────────────────────────────────────────────────── -->
<Card>
<Card class={editSection === 'details' ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_productDetails"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_16rem]">
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
<div class="space-y-2">
<Label for="price_amount">Price</Label>
<Input id="price_amount" name="price_amount" type="number" min="0" step="0.01" placeholder="e.g. 79.99" bind:value={priceAmount} />
<Label for="price_amount">{m["productForm_price"]()}</Label>
<Input id="price_amount" name="price_amount" type="number" min="0" step="0.01" placeholder={m["productForm_priceAmountPlaceholder"]()} bind:value={priceAmount} />
</div>
<div class="space-y-2">
<Label for="price_currency">Currency</Label>
<Input id="price_currency" name="price_currency" maxlength={3} placeholder="PLN" bind:value={priceCurrency} />
<Label for="price_currency">{m["productForm_currency"]()}</Label>
<Input id="price_currency" name="price_currency" maxlength={3} placeholder={m["productForm_priceCurrencyPlaceholder"]()} bind:value={priceCurrency} />
</div>
<div class="space-y-2">
<Label for="size_ml">{m["productForm_sizeMl"]()}</Label>
<Input id="size_ml" name="size_ml" type="number" min="0" step="0.1" placeholder="e.g. 50" bind:value={sizeMl} />
<Input id="size_ml" name="size_ml" type="number" min="0" step="0.1" placeholder={m["productForm_sizePlaceholder"]()} bind:value={sizeMl} />
</div>
<div class="space-y-2">
<Label for="full_weight_g">{m["productForm_fullWeightG"]()}</Label>
<Input id="full_weight_g" name="full_weight_g" type="number" min="0" step="0.1" placeholder="e.g. 120" bind:value={fullWeightG} />
<Input id="full_weight_g" name="full_weight_g" type="number" min="0" step="0.1" placeholder={m["productForm_fullWeightPlaceholder"]()} bind:value={fullWeightG} />
</div>
<div class="space-y-2">
<Label for="empty_weight_g">{m["productForm_emptyWeightG"]()}</Label>
<Input id="empty_weight_g" name="empty_weight_g" type="number" min="0" step="0.1" placeholder="e.g. 30" bind:value={emptyWeightG} />
<Input id="empty_weight_g" name="empty_weight_g" type="number" min="0" step="0.1" placeholder={m["productForm_emptyWeightPlaceholder"]()} bind:value={emptyWeightG} />
</div>
<div class="space-y-2">
<Label for="pao_months">{m["productForm_paoMonths"]()}</Label>
<Input id="pao_months" name="pao_months" type="number" min="1" max="60" placeholder="e.g. 12" bind:value={paoMonths} />
<Input id="pao_months" name="pao_months" type="number" min="1" max="60" placeholder={m["productForm_paoPlaceholder"]()} bind:value={paoMonths} />
</div>
</div>
{#if computedPriceLabel || computedPricePerUseLabel || computedPriceTierLabel}
<div class="rounded-md border border-border bg-muted/25 p-3 text-sm">
<div class="space-y-2">
<div>
<p class="text-muted-foreground">{m["productForm_price"]()}</p>
<p class="font-medium">{computedPriceLabel ?? '-'}</p>
</div>
<div>
<p class="text-muted-foreground">{m.common_pricePerUse()}</p>
<p class="font-medium">{computedPricePerUseLabel ?? '-'}</p>
</div>
<div>
<p class="text-muted-foreground">{m["productForm_priceTier"]()}</p>
<p class="font-medium">{computedPriceTierLabel ?? 'n/a'}</p>
</div>
</div>
</div>
{/if}
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="ph_min">{m["productForm_phMin"]()}</Label>
<Input id="ph_min" name="ph_min" type="number" min="0" max="14" step="0.1" placeholder="e.g. 3.5" bind:value={phMin} />
<Input id="ph_min" name="ph_min" type="number" min="0" max="14" step="0.1" placeholder={m["productForm_phMinPlaceholder"]()} bind:value={phMin} />
</div>
<div class="space-y-2">
<Label for="ph_max">{m["productForm_phMax"]()}</Label>
<Input id="ph_max" name="ph_max" type="number" min="0" max="14" step="0.1" placeholder="e.g. 4.5" bind:value={phMax} />
<Input id="ph_max" name="ph_max" type="number" min="0" max="14" step="0.1" placeholder={m["productForm_phMaxPlaceholder"]()} bind:value={phMax} />
</div>
</div>
@ -826,7 +998,7 @@
</Card>
<!-- ── Safety flags ───────────────────────────────────────────────────────── -->
<Card>
<Card class={editSection === 'assessment' ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_safetyFlags"]()}</CardTitle></CardHeader>
<CardContent>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
@ -886,17 +1058,17 @@
</Card>
<!-- ── Usage constraints ──────────────────────────────────────────────────── -->
<Card>
<Card class={editSection === 'details' ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_usageConstraints"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="min_interval_hours">{m["productForm_minIntervalHours"]()}</Label>
<Input id="min_interval_hours" name="min_interval_hours" type="number" min="0" placeholder="e.g. 24" bind:value={minIntervalHours} />
<Input id="min_interval_hours" name="min_interval_hours" type="number" min="0" placeholder={m["productForm_minIntervalPlaceholder"]()} bind:value={minIntervalHours} />
</div>
<div class="space-y-2">
<Label for="max_frequency_per_week">{m["productForm_maxFrequencyPerWeek"]()}</Label>
<Input id="max_frequency_per_week" name="max_frequency_per_week" type="number" min="1" max="14" placeholder="e.g. 3" bind:value={maxFrequencyPerWeek} />
<Input id="max_frequency_per_week" name="max_frequency_per_week" type="number" min="1" max="14" placeholder={m["productForm_maxFrequencyPlaceholder"]()} bind:value={maxFrequencyPerWeek} />
</div>
</div>
@ -925,13 +1097,13 @@
<div class="space-y-2">
<Label for="needle_length_mm">{m["productForm_needleLengthMm"]()}</Label>
<Input id="needle_length_mm" name="needle_length_mm" type="number" min="0" step="0.01" placeholder="e.g. 0.25" bind:value={needleLengthMm} />
<Input id="needle_length_mm" name="needle_length_mm" type="number" min="0" step="0.01" placeholder={m["productForm_needleLengthPlaceholder"]()} bind:value={needleLengthMm} />
</div>
</CardContent>
</Card>
<!-- ── Personal notes ─────────────────────────────────────────────────────── -->
<Card>
<Card class={editSection === 'notes' ? '' : 'hidden'}>
<CardHeader><CardTitle>{m["productForm_personalNotes"]()}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
@ -959,7 +1131,8 @@
rows="2"
placeholder={m["productForm_toleranceNotesPlaceholder"]()}
class={textareaClass}
>{product?.personal_tolerance_notes ?? ''}</textarea>
bind:value={personalToleranceNotes}
></textarea>
</div>
</CardContent>
</Card>

View file

@ -4,19 +4,30 @@
import { resolve } from '$app/paths';
import { m } from '$lib/paraglide/messages.js';
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
import {
House,
Package,
ClipboardList,
Scissors,
Pill,
FlaskConical,
Sparkles,
Menu,
X
} from 'lucide-svelte';
let { children } = $props();
let mobileMenuOpen = $state(false);
const navItems = $derived([
{ href: resolve('/'), label: m.nav_dashboard(), icon: '🏠' },
{ href: resolve('/products'), label: m.nav_products(), icon: '🧴' },
{ href: resolve('/routines'), label: m.nav_routines(), icon: '📋' },
{ href: resolve('/routines/grooming-schedule'), label: m.nav_grooming(), icon: '🪒' },
{ href: resolve('/health/medications'), label: m.nav_medications(), icon: '💊' },
{ href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: '🔬' },
{ href: resolve('/skin'), label: m.nav_skin(), icon: '✨' }
{ href: resolve('/'), label: m.nav_dashboard(), icon: House },
{ href: resolve('/products'), label: m.nav_products(), icon: Package },
{ href: resolve('/routines'), label: m.nav_routines(), icon: ClipboardList },
{ href: resolve('/routines/grooming-schedule'), label: m.nav_grooming(), icon: Scissors },
{ href: resolve('/health/medications'), label: m.nav_medications(), icon: Pill },
{ href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: FlaskConical },
{ href: resolve('/skin'), label: m.nav_skin(), icon: Sparkles }
]);
function isActive(href: string) {
@ -41,12 +52,12 @@
type="button"
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
aria-label="Toggle menu"
aria-label={m.common_toggleMenu()}
>
{#if mobileMenuOpen}
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<X class="size-[18px]" />
{:else}
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
<Menu class="size-[18px]" />
{/if}
</button>
</header>
@ -60,7 +71,7 @@
onclick={() => (mobileMenuOpen = false)}
aria-label={m.common_cancel()}
></button>
<!-- Drawer (same z-50 but later in DOM → on top of backdrop) -->
<!-- Drawer (same z-50 but later in DOM, on top) -->
<nav
class="fixed inset-y-0 left-0 z-50 w-64 overflow-y-auto bg-card px-3 py-6 md:hidden"
>
@ -79,7 +90,7 @@
? '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.icon class="size-4 shrink-0" />
{item.label}
</a>
</li>
@ -107,7 +118,7 @@
? '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.icon class="size-4 shrink-0" />
{item.label}
</a>
</li>

View file

@ -89,7 +89,7 @@
<Input id="collected_at" name="collected_at" type="date" required />
</div>
<div class="space-y-1">
<Label for="test_code">{m["labResults_loincCode"]()} <span class="text-xs text-muted-foreground">(e.g. 718-7)</span></Label>
<Label for="test_code">{m["labResults_loincCode"]()} <span class="text-xs text-muted-foreground">({m["labResults_loincExample"]()})</span></Label>
<Input id="test_code" name="test_code" required placeholder="718-7" />
</div>
<div class="space-y-1">

View file

@ -6,6 +6,8 @@
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Sparkles, ArrowUp, ArrowDown } from 'lucide-svelte';
import {
Table,
TableBody,
@ -18,7 +20,11 @@
let { data }: { data: PageData } = $props();
type OwnershipFilter = 'all' | 'owned' | 'unowned';
type SortKey = 'brand' | 'name' | 'time' | 'price';
let ownershipFilter = $state<OwnershipFilter>('all');
let sortKey = $state<SortKey>('brand');
let sortDirection = $state<'asc' | 'desc'>('asc');
let searchQuery = $state('');
const CATEGORY_ORDER = [
'cleanser', 'toner', 'essence', 'serum', 'moisturizer',
@ -29,14 +35,59 @@
return p.inventory?.some(inv => !inv.finished_at) ?? false;
}
function setSort(nextKey: SortKey): void {
if (sortKey === nextKey) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
return;
}
sortKey = nextKey;
sortDirection = 'asc';
}
function compareText(a: string, b: string): number {
return a.localeCompare(b, undefined, { sensitivity: 'base' });
}
function comparePrice(a?: number, b?: number): number {
if (a == null && b == null) return 0;
if (a == null) return 1;
if (b == null) return -1;
return a - b;
}
function sortState(key: SortKey): '' | 'asc' | 'desc' {
if (sortKey !== key) return '';
return sortDirection;
}
const groupedProducts = $derived((() => {
let items = data.products;
if (ownershipFilter === 'owned') items = items.filter(isOwned);
if (ownershipFilter === 'unowned') items = items.filter(p => !isOwned(p));
const q = searchQuery.trim().toLocaleLowerCase();
if (q) {
items = items.filter((p) => {
const inName = p.name.toLocaleLowerCase().includes(q);
const inBrand = p.brand.toLocaleLowerCase().includes(q);
const inTargets = p.targets.some((t) => t.replace(/_/g, ' ').toLocaleLowerCase().includes(q));
return inName || inBrand || inTargets;
});
}
items = [...items].sort((a, b) => {
const bc = a.brand.localeCompare(b.brand);
return bc !== 0 ? bc : a.name.localeCompare(b.name);
let cmp = 0;
if (sortKey === 'brand') cmp = compareText(a.brand, b.brand);
if (sortKey === 'name') cmp = compareText(a.name, b.name);
if (sortKey === 'time') cmp = compareText(a.recommended_time, b.recommended_time);
if (sortKey === 'price') cmp = comparePrice(getPricePerUse(a), getPricePerUse(b));
if (cmp === 0) {
const byBrand = compareText(a.brand, b.brand);
cmp = byBrand !== 0 ? byBrand : compareText(a.name, b.name);
}
return sortDirection === 'asc' ? cmp : -cmp;
});
const map = new SvelteMap<string, Product[]>();
@ -58,11 +109,15 @@
function formatPricePerUse(value?: number): string {
if (value == null) return '-';
return `${value.toFixed(2)} PLN/use`;
return `${value.toFixed(2)} ${m.common_pricePerUse()}`;
}
function formatTier(value?: string): string {
if (!value) return 'n/a';
if (value === 'budget') return m['productForm_priceBudget']();
if (value === 'mid') return m['productForm_priceMid']();
if (value === 'premium') return m['productForm_pricePremium']();
if (value === 'luxury') return m['productForm_priceLuxury']();
return value;
}
@ -70,16 +125,6 @@
return (product as Product & { price_per_use_pln?: number }).price_per_use_pln;
}
function getTierSource(product: Product): string | undefined {
return (product as Product & { price_tier_source?: string }).price_tier_source;
}
function sourceLabel(product: Product): string {
const source = getTierSource(product);
if (source === 'fallback') return 'fallback';
if (source === 'insufficient_data') return 'insufficient';
return 'category';
}
</script>
<svelte:head><title>{m.products_title()} — innercontext</title></svelte:head>
@ -91,7 +136,7 @@
<p class="text-muted-foreground">{m.products_count({ count: totalCount })}</p>
</div>
<div class="flex gap-2">
<Button href={resolve('/products/suggest')} variant="outline"> {m["products_suggest"]()}</Button>
<Button href={resolve('/products/suggest')} variant="outline"><Sparkles class="size-4" /> {m["products_suggest"]()}</Button>
<Button href={resolve('/products/new')}>{m["products_addNew"]()}</Button>
</div>
</div>
@ -108,6 +153,34 @@
{/each}
</div>
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div class="w-full lg:max-w-md">
<Input
type="search"
bind:value={searchQuery}
placeholder={`${m['products_colName']()} / ${m['products_colBrand']()}`}
/>
</div>
<div class="flex flex-wrap gap-1">
<Button variant={sortKey === 'brand' ? 'default' : 'outline'} size="sm" onclick={() => setSort('brand')}>
{m['products_colBrand']()}
{#if sortState('brand') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('brand') === 'desc'}<ArrowDown class="size-3" />{/if}
</Button>
<Button variant={sortKey === 'name' ? 'default' : 'outline'} size="sm" onclick={() => setSort('name')}>
{m['products_colName']()}
{#if sortState('name') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('name') === 'desc'}<ArrowDown class="size-3" />{/if}
</Button>
<Button variant={sortKey === 'time' ? 'default' : 'outline'} size="sm" onclick={() => setSort('time')}>
{m['products_colTime']()}
{#if sortState('time') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('time') === 'desc'}<ArrowDown class="size-3" />{/if}
</Button>
<Button variant={sortKey === 'price' ? 'default' : 'outline'} size="sm" onclick={() => setSort('price')}>
{m["products_colPricePerUse"]()}
{#if sortState('price') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('price') === 'desc'}<ArrowDown class="size-3" />{/if}
</Button>
</div>
</div>
<!-- Desktop: table -->
<div class="hidden rounded-md border border-border md:block">
<Table>
@ -117,7 +190,7 @@
<TableHead>{m["products_colBrand"]()}</TableHead>
<TableHead>{m["products_colTargets"]()}</TableHead>
<TableHead>{m["products_colTime"]()}</TableHead>
<TableHead>Pricing</TableHead>
<TableHead>{m["products_colPricePerUse"]()}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -135,9 +208,9 @@
</TableCell>
</TableRow>
{#each products as product (product.id)}
<TableRow class="cursor-pointer hover:bg-muted/50">
<TableCell>
<a href={resolve(`/products/${product.id}`)} class="font-medium hover:underline">
<TableRow class={`cursor-pointer ${isOwned(product) ? 'hover:bg-muted/50' : 'bg-muted/25 text-muted-foreground hover:bg-muted/35'}`}>
<TableCell class="max-w-[32rem] align-top whitespace-normal">
<a href={resolve(`/products/${product.id}`)} title={product.name} class="block break-words line-clamp-2 font-medium hover:underline">
{product.name}
</a>
</TableCell>
@ -156,8 +229,7 @@
<TableCell>
<div class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground">{formatPricePerUse(getPricePerUse(product))}</span>
<Badge variant="outline" class="uppercase text-[10px]">{formatTier(product.price_tier)}</Badge>
<Badge variant="secondary" class="text-[10px]">{sourceLabel(product)}</Badge>
<Badge variant="outline" class="text-[10px]">{formatTier(product.price_tier)}</Badge>
</div>
</TableCell>
</TableRow>
@ -180,16 +252,15 @@
{#each products as product (product.id)}
<a
href={resolve(`/products/${product.id}`)}
class="block rounded-lg border border-border p-4 hover:bg-muted/50"
class={`block rounded-lg border border-border p-4 ${isOwned(product) ? 'hover:bg-muted/50' : 'bg-muted/20 text-muted-foreground hover:bg-muted/30'}`}
>
<div class="flex items-start justify-between gap-2">
<div>
<p class="font-medium">{product.name}</p>
<div class="min-w-0">
<p class="break-words line-clamp-2 font-medium" title={product.name}>{product.name}</p>
<p class="text-sm text-muted-foreground">{product.brand}</p>
<div class="mt-1 flex items-center gap-2 text-xs">
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs">
<span class="text-muted-foreground">{formatPricePerUse(getPricePerUse(product))}</span>
<Badge variant="outline" class="uppercase text-[10px]">{formatTier(product.price_tier)}</Badge>
<Badge variant="secondary" class="text-[10px]">{sourceLabel(product)}</Badge>
<Badge variant="outline" class="text-[10px]">{formatTier(product.price_tier)}</Badge>
</div>
</div>
<span class="shrink-0 text-xs uppercase text-muted-foreground">{product.recommended_time}</span>

View file

@ -5,10 +5,11 @@
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent } from '$lib/components/ui/card';
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';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
import { Save, Trash2, Boxes, Pencil, X, ArrowLeft, Sparkles } from 'lucide-svelte';
import ProductForm from '$lib/components/ProductForm.svelte';
let { data, form }: { data: PageData; form: ActionData } = $props();
@ -16,6 +17,10 @@
let showInventoryForm = $state(false);
let editingInventoryId = $state<string | null>(null);
let activeTab = $state<'inventory' | 'edit'>('edit');
let isEditDirty = $state(false);
let editSaveVersion = $state(0);
let productFormRef: { openAiModal: () => void } | null = $state(null);
function formatAmount(amount?: number, currency?: string): string {
if (amount == null || !currency) return '-';
@ -32,7 +37,7 @@
function formatPricePerUse(value?: number): string {
if (value == null) return '-';
return `${value.toFixed(2)} PLN/use`;
return `${value.toFixed(2)} ${m.common_pricePerUse()}`;
}
function getPriceAmount(): number | undefined {
@ -47,63 +52,83 @@
return (product as { price_per_use_pln?: number }).price_per_use_pln;
}
function getTierSource(): string {
const source = (product as { price_tier_source?: string }).price_tier_source;
if (source === 'fallback') return 'fallback';
if (source === 'insufficient_data') return 'insufficient_data';
return 'category';
function formatTier(value?: string): string {
if (!value) return 'n/a';
if (value === 'budget') return m['productForm_priceBudget']();
if (value === 'mid') return m['productForm_priceMid']();
if (value === 'premium') return m['productForm_pricePremium']();
if (value === 'luxury') return m['productForm_priceLuxury']();
return value;
}
</script>
<svelte:head><title>{product.name} — innercontext</title></svelte:head>
<div class="max-w-2xl space-y-6">
<div>
<a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
<h2 class="mt-1 text-2xl font-bold tracking-tight">{product.name}</h2>
<div class="fixed inset-x-3 bottom-3 z-40 flex items-center justify-end gap-2 rounded-lg border border-border bg-card/95 p-2 shadow-sm backdrop-blur md:inset-x-auto md:bottom-auto md:right-6 md:top-4">
<Button
type="submit"
form="product-edit-form"
disabled={activeTab !== 'edit' || !isEditDirty}
size="sm"
>
<Save class="size-4" aria-hidden="true" />
<span class="sr-only md:not-sr-only">{m["products_saveChanges"]()}</span>
</Button>
<form
method="POST"
action="?/delete"
use:enhance
onsubmit={(e) => {
if (!confirm(m["products_confirmDelete"]())) e.preventDefault();
}}
>
<Button type="submit" variant="destructive" size="sm">
<Trash2 class="size-4" aria-hidden="true" />
<span class="sr-only md:not-sr-only">{m["products_deleteProduct"]()}</span>
</Button>
</form>
</div>
<div class="space-y-6 pb-20 md:pb-0">
<div class="space-y-2">
<a href={resolve('/products')} class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:underline"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
<div class="flex flex-wrap items-start gap-3">
<div class="min-w-0 space-y-2">
<h2 class="break-words text-2xl font-bold tracking-tight">{product.name}</h2>
<div class="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span class="font-medium text-foreground">{product.brand}</span>
<span></span>
<span class="uppercase">{product.recommended_time}</span>
<span></span>
<span>{product.category.replace(/_/g, ' ')}</span>
<Badge variant="outline" class="ml-1">ID: {product.id.slice(0, 8)}</Badge>
</div>
</div>
</div>
</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">{m.common_saved()}</div>
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{m.common_saved()}</div>
{/if}
<Card>
<CardContent class="pt-4">
<div class="grid grid-cols-1 gap-3 text-sm sm:grid-cols-3">
<div>
<p class="text-muted-foreground">Price</p>
<p class="font-medium">{formatAmount(getPriceAmount(), getPriceCurrency())}</p>
</div>
<div>
<p class="text-muted-foreground">Price per use</p>
<p class="font-medium">{formatPricePerUse(getPricePerUse())}</p>
</div>
<div>
<p class="text-muted-foreground">Price tier</p>
<p class="font-medium uppercase">{product.price_tier ?? 'n/a'}</p>
<p class="text-xs text-muted-foreground">source: {getTierSource()}</p>
</div>
</div>
</CardContent>
</Card>
<Tabs bind:value={activeTab} class="space-y-2">
<TabsList class="h-auto w-full justify-start gap-1 overflow-x-auto p-1 whitespace-nowrap">
<TabsTrigger value="inventory" class="shrink-0 px-3" title={m.inventory_title({ count: product.inventory.length })}>
<Boxes class="size-4" aria-hidden="true" />
<span class="sr-only md:not-sr-only">{m.inventory_title({ count: product.inventory.length })}</span>
</TabsTrigger>
<TabsTrigger value="edit" class="shrink-0 px-3" title={m.common_edit()}>
<Pencil class="size-4" aria-hidden="true" />
<span class="sr-only md:not-sr-only">{m.common_edit()}</span>
</TabsTrigger>
</TabsList>
<!-- Edit form -->
<form method="POST" action="?/update" use:enhance class="space-y-6">
<ProductForm {product} />
<div class="flex gap-3">
<Button type="submit">{m["products_saveChanges"]()}</Button>
</div>
</form>
<Separator />
<!-- Inventory -->
<TabsContent value="inventory" class="space-y-4 pt-2">
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex flex-wrap items-center justify-between gap-2">
<h3 class="text-lg font-semibold">{m.inventory_title({ count: product.inventory.length })}</h3>
<Button variant="outline" size="sm" onclick={() => (showInventoryForm = !showInventoryForm)}>
{showInventoryForm ? m.common_cancel() : m["inventory_addPackage"]()}
@ -111,20 +136,23 @@
</div>
{#if form?.inventoryAdded}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["inventory_packageAdded"]()}</div>
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{m["inventory_packageAdded"]()}</div>
{/if}
{#if form?.inventoryUpdated}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["inventory_packageUpdated"]()}</div>
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{m["inventory_packageUpdated"]()}</div>
{/if}
{#if form?.inventoryDeleted}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["inventory_packageDeleted"]()}</div>
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{m["inventory_packageDeleted"]()}</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="col-span-2 flex items-center gap-2">
<CardHeader class="pb-2">
<CardTitle class="text-base">{m["inventory_addPackage"]()}</CardTitle>
</CardHeader>
<CardContent>
<form method="POST" action="?/addInventory" use:enhance class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="sm:col-span-2 flex items-center gap-2">
<input type="checkbox" id="add_is_opened" name="is_opened" value="true" class="h-4 w-4" />
<Label for="add_is_opened">{m["inventory_alreadyOpened"]()}</Label>
</div>
@ -137,20 +165,20 @@
<Input id="add_finished_at" name="finished_at" type="date" />
</div>
<div class="space-y-1">
<Label for="expiry_date">{m["inventory_expiryDate"]()}</Label>
<Input id="expiry_date" name="expiry_date" type="date" />
<Label for="add_expiry_date">{m["inventory_expiryDate"]()}</Label>
<Input id="add_expiry_date" name="expiry_date" type="date" />
</div>
<div class="space-y-1">
<Label for="current_weight_g">{m["inventory_currentWeight"]()}</Label>
<Input id="current_weight_g" name="current_weight_g" type="number" min="0" />
<Label for="add_current_weight_g">{m["inventory_currentWeight"]()}</Label>
<Input id="add_current_weight_g" name="current_weight_g" type="number" min="0" />
</div>
<div class="space-y-1">
<Label for="add_last_weighed_at">{m["inventory_lastWeighed"]()}</Label>
<Input id="add_last_weighed_at" name="last_weighed_at" type="date" />
</div>
<div class="space-y-1">
<Label for="notes">{m.inventory_notes()}</Label>
<Input id="notes" name="notes" />
<Label for="add_notes">{m.inventory_notes()}</Label>
<Input id="add_notes" name="notes" />
</div>
<div class="flex items-end">
<Button type="submit" size="sm">{m.common_add()}</Button>
@ -164,8 +192,8 @@
<div class="space-y-2">
{#each product.inventory as pkg (pkg.id)}
<div class="rounded-md border border-border text-sm">
<div class="flex items-center justify-between px-4 py-3">
<div class="flex flex-wrap items-center gap-2">
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<Badge variant={pkg.is_opened ? 'default' : 'secondary'}>
{pkg.is_opened ? m["inventory_badgeOpen"]() : m["inventory_badgeSealed"]()}
</Badge>
@ -206,7 +234,7 @@
onsubmit={(e) => { if (!confirm(m["inventory_confirmDelete"]())) e.preventDefault(); }}
>
<input type="hidden" name="inventory_id" value={pkg.id} />
<Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive">×</Button>
<Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive"><X class="size-4" /></Button>
</form>
</div>
</div>
@ -222,10 +250,10 @@
if (result.type === 'success') editingInventoryId = null;
};
}}
class="grid grid-cols-2 gap-4"
class="grid grid-cols-1 gap-4 sm:grid-cols-2"
>
<input type="hidden" name="inventory_id" value={pkg.id} />
<div class="col-span-2 flex items-center gap-2">
<div class="sm:col-span-2 flex items-center gap-2">
<input
type="checkbox"
id="edit_is_opened_{pkg.id}"
@ -238,49 +266,23 @@
</div>
<div class="space-y-1">
<Label for="edit_opened_at_{pkg.id}">{m["inventory_openedDate"]()}</Label>
<Input
id="edit_opened_at_{pkg.id}"
name="opened_at"
type="date"
value={pkg.opened_at?.slice(0, 10) ?? ''}
/>
<Input id="edit_opened_at_{pkg.id}" name="opened_at" type="date" value={pkg.opened_at?.slice(0, 10) ?? ''} />
</div>
<div class="space-y-1">
<Label for="edit_finished_at_{pkg.id}">{m["inventory_finishedDate"]()}</Label>
<Input
id="edit_finished_at_{pkg.id}"
name="finished_at"
type="date"
value={pkg.finished_at?.slice(0, 10) ?? ''}
/>
<Input id="edit_finished_at_{pkg.id}" name="finished_at" type="date" value={pkg.finished_at?.slice(0, 10) ?? ''} />
</div>
<div class="space-y-1">
<Label for="edit_expiry_{pkg.id}">{m["inventory_expiryDate"]()}</Label>
<Input
id="edit_expiry_{pkg.id}"
name="expiry_date"
type="date"
value={pkg.expiry_date?.slice(0, 10) ?? ''}
/>
<Input id="edit_expiry_{pkg.id}" name="expiry_date" type="date" value={pkg.expiry_date?.slice(0, 10) ?? ''} />
</div>
<div class="space-y-1">
<Label for="edit_weight_{pkg.id}">{m["inventory_currentWeight"]()}</Label>
<Input
id="edit_weight_{pkg.id}"
name="current_weight_g"
type="number"
min="0"
value={pkg.current_weight_g ?? ''}
/>
<Input id="edit_weight_{pkg.id}" name="current_weight_g" type="number" min="0" value={pkg.current_weight_g ?? ''} />
</div>
<div class="space-y-1">
<Label for="edit_last_weighed_{pkg.id}">{m["inventory_lastWeighed"]()}</Label>
<Input
id="edit_last_weighed_{pkg.id}"
name="last_weighed_at"
type="date"
value={pkg.last_weighed_at?.slice(0, 10) ?? ''}
/>
<Input id="edit_last_weighed_{pkg.id}" name="last_weighed_at" type="date" value={pkg.last_weighed_at?.slice(0, 10) ?? ''} />
</div>
<div class="space-y-1">
<Label for="edit_notes_{pkg.id}">{m.inventory_notes()}</Label>
@ -288,12 +290,7 @@
</div>
<div class="flex items-end gap-2">
<Button type="submit" size="sm">{m.common_save()}</Button>
<Button
type="button"
variant="ghost"
size="sm"
onclick={() => (editingInventoryId = null)}
>{m.common_cancel()}</Button>
<Button type="button" variant="ghost" size="sm" onclick={() => (editingInventoryId = null)}>{m.common_cancel()}</Button>
</div>
</form>
</div>
@ -305,20 +302,48 @@
<p class="text-sm text-muted-foreground">{m["products_noInventory"]()}</p>
{/if}
</div>
</TabsContent>
<Separator />
<!-- Danger zone -->
<div>
<TabsContent value="edit" class="space-y-4 pt-2">
<Card>
<CardHeader class="pb-2">
<div class="flex items-center justify-between gap-2">
<CardTitle class="text-base">{m.common_edit()}</CardTitle>
<Button type="button" variant="outline" size="sm" onclick={() => productFormRef?.openAiModal()}>
<Sparkles class="size-4" />
{m["productForm_aiPrefill"]()}
</Button>
</div>
</CardHeader>
<CardContent>
<form
id="product-edit-form"
method="POST"
action="?/delete"
use:enhance
onsubmit={(e) => {
if (!confirm(m["products_confirmDelete"]())) e.preventDefault();
action="?/update"
use:enhance={() => {
return async ({ result, update }) => {
await update();
if (result.type === 'success') {
isEditDirty = false;
editSaveVersion += 1;
}
};
}}
class="space-y-6"
>
<Button type="submit" variant="destructive" size="sm">{m["products_deleteProduct"]()}</Button>
<ProductForm
bind:this={productFormRef}
{product}
bind:dirty={isEditDirty}
saveVersion={editSaveVersion}
showAiTrigger={false}
computedPriceLabel={formatAmount(getPriceAmount(), getPriceCurrency())}
computedPricePerUseLabel={formatPricePerUse(getPricePerUse())}
computedPriceTierLabel={formatTier(product.price_tier)}
/>
</form>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>

View file

@ -4,6 +4,7 @@
import type { ActionData } from './$types';
import { m } from '$lib/paraglide/messages.js';
import { Button } from '$lib/components/ui/button';
import { ArrowLeft } from 'lucide-svelte';
import ProductForm from '$lib/components/ProductForm.svelte';
let { form }: { form: ActionData } = $props();
@ -21,7 +22,7 @@
<div class="max-w-2xl space-y-6">
<div class="flex items-center gap-4">
<a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
<a href={resolve('/products')} class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:underline"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
<h2 class="text-2xl font-bold tracking-tight">{m["products_newTitle"]()}</h2>
</div>

View file

@ -6,6 +6,7 @@
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 { Sparkles, ArrowLeft } from 'lucide-svelte';
let suggestions = $state<ProductSuggestion[] | null>(null);
let reasoning = $state('');
@ -33,7 +34,7 @@
<div class="max-w-2xl space-y-6">
<div class="flex items-center gap-4">
<a href={resolve('/products')} class="text-sm text-muted-foreground hover:underline">{m["products_backToList"]()}</a>
<a href={resolve('/products')} class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:underline"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
<h2 class="text-2xl font-bold tracking-tight">{m["products_suggestTitle"]()}</h2>
</div>
@ -53,7 +54,7 @@
<span class="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
{m["products_suggestGenerating"]()}
{:else}
{m["products_suggestBtn"]()}
<Sparkles class="size-4" /> {m["products_suggestBtn"]()}
{/if}
</Button>
</form>

View file

@ -20,6 +20,7 @@
} from '$lib/components/ui/select';
import { Separator } from '$lib/components/ui/separator';
import { SvelteMap } from 'svelte/reactivity';
import { GripVertical, Pencil, X, ArrowLeft } from 'lucide-svelte';
let { data, form }: { data: PageData; form: ActionData } = $props();
let { routine, products } = $derived(data);
@ -142,7 +143,7 @@
<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">{m["routines_backToList"]()}</a>
<a href="/routines" class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:underline"><ArrowLeft class="size-4" /> {m["routines_backToList"]()}</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()}
@ -272,7 +273,7 @@
{:else}
<!-- Action step: change action_type / notes -->
<div class="space-y-1">
<Label>Action</Label>
<Label>{m["routines_action"]()}</Label>
<Select
type="single"
value={editDraft.action_type ?? ''}
@ -280,7 +281,7 @@
(editDraft.action_type = (v || undefined) as GroomingAction | undefined)}
>
<SelectTrigger>
{editDraft.action_type?.replace(/_/g, ' ') ?? 'Select action'}
{editDraft.action_type?.replace(/_/g, ' ') ?? m["routines_selectAction"]()}
</SelectTrigger>
<SelectContent>
{#each GROOMING_ACTIONS as action (action)}
@ -290,11 +291,11 @@
</Select>
</div>
<div class="space-y-1">
<Label>Notes</Label>
<Label>{m.routines_notes()}</Label>
<Input
value={editDraft.action_notes ?? ''}
oninput={(e) => (editDraft.action_notes = e.currentTarget.value)}
placeholder="optional notes"
placeholder={m["routines_actionNotesPlaceholder"]()}
/>
</div>
{/if}
@ -318,8 +319,8 @@
<span
use:dragHandle
class="cursor-grab select-none px-1 text-muted-foreground/60 hover:text-muted-foreground"
aria-label="drag to reorder"
>⋮⋮</span>
aria-label={m.common_dragToReorder()}
><GripVertical class="size-4" /></span>
<span class="w-5 shrink-0 text-xs text-muted-foreground">{i + 1}.</span>
<div class="flex-1 min-w-0 px-1">
{#if step.product_id}
@ -340,8 +341,8 @@
size="sm"
class="shrink-0 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onclick={() => startEdit(step)}
aria-label="edit step"
></Button>
aria-label={m.common_editStep()}
><Pencil class="size-4" /></Button>
<form method="POST" action="?/removeStep" use:enhance>
<input type="hidden" name="step_id" value={step.id} />
<Button
@ -349,7 +350,7 @@
variant="ghost"
size="sm"
class="shrink-0 h-7 w-7 p-0 text-destructive hover:text-destructive"
>×</Button>
><X class="size-4" /></Button>
</form>
</div>
{/if}

View file

@ -8,6 +8,7 @@
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Separator } from '$lib/components/ui/separator';
import { ArrowLeft } from 'lucide-svelte';
import type { GroomingAction, GroomingSchedule } from '$lib/types';
let { data, form }: { data: PageData; form: ActionData } = $props();
@ -48,7 +49,7 @@
<div class="max-w-2xl space-y-6">
<div class="flex items-center justify-between">
<div>
<a href="/routines" class="text-sm text-muted-foreground hover:underline">{m["grooming_backToRoutines"]()}</a>
<a href="/routines" class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:underline"><ArrowLeft class="size-4" /> {m["grooming_backToRoutines"]()}</a>
<h2 class="mt-1 text-2xl font-bold tracking-tight">{m.grooming_title()}</h2>
</div>
<Button variant="outline" size="sm" onclick={() => (showAddForm = !showAddForm)}>

View file

@ -3,6 +3,7 @@
import type { ActionData, PageData } from './$types';
import { m } from '$lib/paraglide/messages.js';
import { Button } from '$lib/components/ui/button';
import { ArrowLeft } from 'lucide-svelte';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
@ -16,7 +17,7 @@
<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">{m["routines_backToList"]()}</a>
<a href="/routines" class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:underline"><ArrowLeft class="size-4" /> {m["routines_backToList"]()}</a>
<h2 class="text-2xl font-bold tracking-tight">{m["routines_newTitle"]()}</h2>
</div>
@ -39,8 +40,8 @@
<Select type="single" value={partOfDay} onValueChange={(v) => (partOfDay = v)}>
<SelectTrigger>{partOfDay.toUpperCase()}</SelectTrigger>
<SelectContent>
<SelectItem value="am">AM</SelectItem>
<SelectItem value="pm">PM</SelectItem>
<SelectItem value="am">{m.common_am()}</SelectItem>
<SelectItem value="pm">{m.common_pm()}</SelectItem>
</SelectContent>
</Select>
</div>

View file

@ -12,6 +12,7 @@
import { Label } from '$lib/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
import { ChevronUp, ChevronDown, ArrowLeft } from 'lucide-svelte';
let { data }: { data: PageData } = $props();
@ -105,7 +106,7 @@
<div class="max-w-2xl space-y-6">
<div class="flex items-center gap-4">
<a href={resolve('/routines')} class="text-sm text-muted-foreground hover:underline">{m["suggest_backToRoutines"]()}</a>
<a href={resolve('/routines')} class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:underline"><ArrowLeft class="size-4" /> {m["suggest_backToRoutines"]()}</a>
<h2 class="text-2xl font-bold tracking-tight">{m.suggest_title()}</h2>
</div>
@ -316,8 +317,8 @@
class="mt-0.5 h-4 w-4 rounded border-input"
/>
<div class="space-y-0.5">
<Label for="batch_minimize_products" class="font-medium">Minimalizuj produkty</Label>
<p class="text-xs text-muted-foreground">Ogranicz liczbę różnych produktów</p>
<Label for="batch_minimize_products" class="font-medium">{m["suggest_minimizeProductsLabel"]()}</Label>
<p class="text-xs text-muted-foreground">{m["suggest_minimizeProductsHint"]()}</p>
</div>
</div>
@ -359,9 +360,13 @@
<span class="font-medium text-sm">{day.date}</span>
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">
AM {day.am_steps.length} {m["suggest_amSteps"]()} · PM {day.pm_steps.length} {m["suggest_pmSteps"]()}
{m.common_am()} {day.am_steps.length} {m["suggest_amSteps"]()} · {m.common_pm()} {day.pm_steps.length} {m["suggest_pmSteps"]()}
</span>
<span class="text-muted-foreground">{isOpen ? '▲' : '▼'}</span>
{#if isOpen}
<ChevronUp class="size-4 text-muted-foreground" />
{:else}
<ChevronDown class="size-4 text-muted-foreground" />
{/if}
</div>
</button>
@ -374,7 +379,7 @@
<!-- AM steps -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<Badge>AM</Badge>
<Badge>{m.common_am()}</Badge>
<span class="text-xs text-muted-foreground">{day.am_steps.length} {m["suggest_amSteps"]()}</span>
</div>
{#if day.am_steps.length}
@ -399,7 +404,7 @@
<!-- PM steps -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<Badge variant="secondary">PM</Badge>
<Badge variant="secondary">{m.common_pm()}</Badge>
<span class="text-xs text-muted-foreground">{day.pm_steps.length} {m["suggest_pmSteps"]()}</span>
</div>
{#if day.pm_steps.length}

View file

@ -9,6 +9,7 @@
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 { Sparkles, Pencil, X } from 'lucide-svelte';
let { data, form }: { data: PageData; form: ActionData } = $props();
@ -102,7 +103,7 @@
}
// AI photo analysis state
let aiPanelOpen = $state(false);
let aiModalOpen = $state(false);
let selectedFiles = $state<File[]>([]);
let previewUrls = $state<string[]>([]);
let aiLoading = $state(false);
@ -137,16 +138,35 @@
if (r.active_concerns?.length) activeConcernsRaw = r.active_concerns.join(', ');
if (r.priorities?.length) prioritiesRaw = r.priorities.join(', ');
if (r.notes) notes = r.notes;
aiPanelOpen = false;
aiModalOpen = false;
} catch (e) {
aiError = (e as Error).message;
} finally {
aiLoading = false;
}
}
function openAiModal() {
aiError = '';
aiModalOpen = true;
}
function closeAiModal() {
if (aiLoading) return;
aiModalOpen = false;
}
function handleModalKeydown(event: KeyboardEvent) {
if (!aiModalOpen) return;
if (event.key === 'Escape') {
event.preventDefault();
closeAiModal();
}
}
</script>
<svelte:head><title>{m.skin_title()} — innercontext</title></svelte:head>
<svelte:window onkeydown={handleModalKeydown} />
<div class="space-y-6">
<div class="flex items-center justify-between">
@ -154,10 +174,18 @@
<h2 class="text-2xl font-bold tracking-tight">{m.skin_title()}</h2>
<p class="text-muted-foreground">{m.skin_count({ count: data.snapshots.length })}</p>
</div>
<div class="flex items-center gap-2">
{#if showForm}
<Button type="button" variant="outline" size="sm" onclick={openAiModal}>
<Sparkles class="size-4" />
{m["skin_aiAnalysisTitle"]()}
</Button>
{/if}
<Button variant="outline" onclick={() => (showForm = !showForm)}>
{showForm ? m.common_cancel() : m["skin_addNew"]()}
</Button>
</div>
</div>
{#if form?.error}
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
@ -173,52 +201,52 @@
{/if}
{#if showForm}
<!-- AI photo analysis card -->
<Card>
<CardHeader>
{#if aiModalOpen}
<button
type="button"
class="flex w-full items-center justify-between text-left"
onclick={() => (aiPanelOpen = !aiPanelOpen)}
>
class="fixed inset-0 z-50 bg-black/50"
onclick={closeAiModal}
aria-label={m.common_cancel()}
></button>
<div class="fixed inset-x-3 bottom-3 top-3 z-50 mx-auto flex max-w-2xl items-center md:inset-x-6 md:inset-y-8">
<Card class="max-h-full w-full overflow-hidden">
<CardHeader class="border-b border-border">
<div class="flex items-center justify-between gap-3">
<CardTitle>{m["skin_aiAnalysisTitle"]()}</CardTitle>
<span class="text-sm text-muted-foreground">{aiPanelOpen ? '▲' : '▼'}</span>
</button>
<Button type="button" variant="ghost" size="sm" class="h-8 w-8 p-0" onclick={closeAiModal} aria-label={m.common_cancel()}>
<X class="size-4" />
</Button>
</div>
</CardHeader>
{#if aiPanelOpen}
<CardContent class="space-y-3">
<p class="text-sm text-muted-foreground">
{m["skin_aiUploadText"]()}
</p>
<CardContent class="space-y-3 overflow-y-auto p-4">
<p class="text-sm text-muted-foreground">{m["skin_aiUploadText"]()}</p>
<input
type="file"
accept="image/heic,image/heif,image/jpeg,image/png,image/webp"
multiple
onchange={handleFileSelect}
class="block w-full text-sm text-muted-foreground
file:mr-4 file:rounded-md file:border-0 file:bg-primary
file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-foreground"
class="block w-full text-sm text-muted-foreground file:mr-4 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-foreground"
/>
{#if previewUrls.length}
<div class="flex flex-wrap gap-2">
{#each previewUrls as url (url)}
<img src={url} alt="skin preview" class="h-24 w-24 rounded-md object-cover border" />
<img src={url} alt="skin preview" class="h-24 w-24 rounded-md border object-cover" />
{/each}
</div>
{/if}
{#if aiError}
<p class="text-sm text-destructive">{aiError}</p>
{/if}
<Button
type="button"
onclick={analyzePhotos}
disabled={aiLoading || !selectedFiles.length}
>
<div class="flex justify-end gap-2">
<Button type="button" variant="outline" onclick={closeAiModal} disabled={aiLoading}>{m.common_cancel()}</Button>
<Button type="button" onclick={analyzePhotos} disabled={aiLoading || !selectedFiles.length}>
{aiLoading ? m.skin_analyzing() : m["skin_analyzePhotos"]()}
</Button>
</div>
</CardContent>
{/if}
</Card>
</div>
{/if}
<!-- New snapshot form -->
<Card>
@ -426,10 +454,10 @@
<div class="flex items-center justify-between">
<span class="font-medium">{snap.snapshot_date}</span>
<div class="flex items-center gap-1">
<Button variant="ghost" size="sm" onclick={() => startEdit(snap)} class="h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-foreground" aria-label={m.common_edit()}>✎</Button>
<Button variant="ghost" size="sm" onclick={() => startEdit(snap)} class="h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-foreground" aria-label={m.common_edit()}><Pencil class="size-4" /></Button>
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={snap.id} />
<Button type="submit" variant="ghost" size="sm" class="h-7 w-7 shrink-0 p-0 text-destructive hover:text-destructive" aria-label={m.common_delete()}>×</Button>
<Button type="submit" variant="ghost" size="sm" class="h-7 w-7 shrink-0 p-0 text-destructive hover:text-destructive" aria-label={m.common_delete()}><X class="size-4" /></Button>
</form>
</div>
</div>

View file

@ -3,8 +3,19 @@ import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
import { paraglideVitePlugin } from '@inlang/paraglide-js';
const stripDeprecatedRollupOptions = {
name: 'strip-deprecated-rollup-options',
outputOptions(options: Record<string, unknown>) {
if ('codeSplitting' in options) {
delete options.codeSplitting;
}
return options;
}
};
export default defineConfig({
plugins: [
stripDeprecatedRollupOptions,
paraglideVitePlugin({ project: './project.inlang', outdir: './src/lib/paraglide' }),
tailwindcss(),
sveltekit()