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:
parent
83ba4cc5c0
commit
d4fbc1faf5
15 changed files with 921 additions and 532 deletions
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</Card>
|
||||
</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 Glycerin 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
use:enhance
|
||||
onsubmit={(e) => {
|
||||
if (!confirm(m["products_confirmDelete"]())) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Button type="submit" variant="destructive" size="sm">{m["products_deleteProduct"]()}</Button>
|
||||
</form>
|
||||
<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="?/update"
|
||||
use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
await update();
|
||||
if (result.type === 'success') {
|
||||
isEditDirty = false;
|
||||
editSaveVersion += 1;
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="space-y-6"
|
||||
>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue