feat(frontend): unify page shell and move create flows to dedicated routes
This commit is contained in:
parent
e20c18c2ee
commit
0253b2377d
50 changed files with 2235 additions and 1042 deletions
|
|
@ -80,10 +80,12 @@ Use these wrappers before introducing route-specific structure:
|
||||||
|
|
||||||
- `editorial-page`: standard constrained content width for route pages.
|
- `editorial-page`: standard constrained content width for route pages.
|
||||||
- `editorial-hero`: top summary strip for title, subtitle, and primary actions.
|
- `editorial-hero`: top summary strip for title, subtitle, and primary actions.
|
||||||
|
- `PageHeader.svelte`: preferred reusable wrapper for page-level hero sections; use it to keep title hierarchy, backlinks, meta rows, and action placement consistent.
|
||||||
- `editorial-panel`: primary surface for forms, tables, and ledgers.
|
- `editorial-panel`: primary surface for forms, tables, and ledgers.
|
||||||
- `editorial-toolbar`: compact action row under hero copy.
|
- `editorial-toolbar`: compact action row under hero copy.
|
||||||
- `editorial-backlink`: standard top-left back navigation style.
|
- `editorial-backlink`: standard top-left back navigation style.
|
||||||
- `editorial-alert`, `editorial-alert--error`, `editorial-alert--success`, `editorial-alert--warning`, `editorial-alert--info`: feedback banners.
|
- `editorial-alert`, `editorial-alert--error`, `editorial-alert--success`, `editorial-alert--warning`, `editorial-alert--info`: feedback banners.
|
||||||
|
- `page-header-meta`, `page-header-foot`, `hero-strip`: shared secondary rows inside page headers for compact metadata and summary stats.
|
||||||
|
|
||||||
### Collapsible panels
|
### Collapsible panels
|
||||||
|
|
||||||
|
|
@ -117,10 +119,14 @@ This matches the warm editorial aesthetic and maintains visual consistency with
|
||||||
|
|
||||||
These classes are already in use and should be reused:
|
These classes are already in use and should be reused:
|
||||||
|
|
||||||
- Lists and ledgers: `routine-ledger-row`, `products-mobile-card`, `health-entry-row`
|
- Lists and ledgers: `routine-ledger-row`, `editorial-mobile-card`, `health-entry-row`
|
||||||
- Group headers: `products-section-title`
|
- Group headers: `editorial-section-title`
|
||||||
- Table shell: `products-table-shell`
|
- Table shell: `editorial-table-shell`
|
||||||
|
- Compact metadata rows: `editorial-meta-strip`
|
||||||
- Tabs shell: `products-tabs`, `editorial-tabs`
|
- Tabs shell: `products-tabs`, `editorial-tabs`
|
||||||
|
- App shell/navigation: `app-mobile-header`, `app-drawer`, `app-nav-list`, `app-nav-link`, `app-sidebar-footer`
|
||||||
|
- Reusable locale control: `LanguageSwitcher.svelte` with `language-switcher*` classes
|
||||||
|
- Dashboard summary patterns: `dashboard-stat-strip`, `dashboard-stat-card`, `dashboard-attention-list`, `dashboard-attention-item`
|
||||||
- Health semantic pills: `health-kind-pill*`, `health-flag-pill*`
|
- Health semantic pills: `health-kind-pill*`, `health-flag-pill*`
|
||||||
- Lab results utilities:
|
- Lab results utilities:
|
||||||
- metadata chips: `lab-results-meta-strip`, `lab-results-meta-pill`
|
- metadata chips: `lab-results-meta-strip`, `lab-results-meta-pill`
|
||||||
|
|
@ -145,6 +151,7 @@ These classes are already in use and should be reused:
|
||||||
- In dense row-based lists, prefer `ghost` action controls; use icon-only buttons on desktop tables and short text+icon `ghost` actions on mobile cards to keep row actions subordinate to data.
|
- In dense row-based lists, prefer `ghost` action controls; use icon-only buttons on desktop tables and short text+icon `ghost` actions on mobile cards to keep row actions subordinate to data.
|
||||||
- For editable data tables, open a dedicated inline edit panel above the list (instead of per-row expanded forms) and prefill it from row actions; keep users on the same filtered/paginated context after save.
|
- For editable data tables, open a dedicated inline edit panel above the list (instead of per-row expanded forms) and prefill it from row actions; keep users on the same filtered/paginated context after save.
|
||||||
- When a list is narrowed to a single entity key (for example `test_code`), display an explicit "filtered by" banner with a one-click clear action and avoid extra grouping wrappers that add no context.
|
- When a list is narrowed to a single entity key (for example `test_code`), display an explicit "filtered by" banner with a one-click clear action and avoid extra grouping wrappers that add no context.
|
||||||
|
- For dashboard-style summaries, prefer compact stat strips and attention rows over large decorative cards; each item should pair one strong value with one short explanatory line.
|
||||||
|
|
||||||
### DRY form primitives
|
### DRY form primitives
|
||||||
|
|
||||||
|
|
@ -211,6 +218,7 @@ These classes are already in use and should be reused:
|
||||||
|
|
||||||
- Core tokens and global look: `frontend/src/app.css`
|
- Core tokens and global look: `frontend/src/app.css`
|
||||||
- App shell and route domain mapping: `frontend/src/routes/+layout.svelte`
|
- App shell and route domain mapping: `frontend/src/routes/+layout.svelte`
|
||||||
|
- Shared page header: `frontend/src/lib/components/PageHeader.svelte`
|
||||||
- Route examples using the pattern:
|
- Route examples using the pattern:
|
||||||
- `frontend/src/routes/+page.svelte`
|
- `frontend/src/routes/+page.svelte`
|
||||||
- `frontend/src/routes/products/+page.svelte`
|
- `frontend/src/routes/products/+page.svelte`
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,74 @@
|
||||||
|
|
||||||
"dashboard_title": "Dashboard",
|
"dashboard_title": "Dashboard",
|
||||||
"dashboard_subtitle": "Your recent health & skincare overview",
|
"dashboard_subtitle": "Your recent health & skincare overview",
|
||||||
|
"dashboard_dailyBriefing": "A quick read on what changed, what is missing, and where to look next.",
|
||||||
"dashboard_latestSnapshot": "Latest Skin Snapshot",
|
"dashboard_latestSnapshot": "Latest Skin Snapshot",
|
||||||
"dashboard_recentRoutines": "Recent Routines",
|
"dashboard_recentRoutines": "Recent Routines",
|
||||||
|
"dashboard_requiresAttention": "Requires attention",
|
||||||
|
"dashboard_healthPulse": "Health Pulse",
|
||||||
|
"dashboard_viewSkinHistory": "Open skin history",
|
||||||
|
"dashboard_viewLabResults": "Open lab results",
|
||||||
|
"dashboard_heroFreshness": "Freshness",
|
||||||
|
"dashboard_sinceLastSnapshot": "since last snapshot",
|
||||||
|
"dashboard_skinFreshness": [
|
||||||
|
{
|
||||||
|
"declarations": ["input count", "local countPlural = count: plural"],
|
||||||
|
"selectors": ["countPlural"],
|
||||||
|
"match": {
|
||||||
|
"countPlural=one": "Last snapshot was {count} day ago.",
|
||||||
|
"countPlural=*": "Last snapshot was {count} days ago."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dashboard_daysAgo": [
|
||||||
|
{
|
||||||
|
"declarations": ["input count", "local countPlural = count: plural"],
|
||||||
|
"selectors": ["countPlural"],
|
||||||
|
"match": {
|
||||||
|
"countPlural=one": "{count} day ago",
|
||||||
|
"countPlural=*": "{count} days ago"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dashboard_daysAgoShort": [
|
||||||
|
{
|
||||||
|
"declarations": ["input count", "local countPlural = count: plural"],
|
||||||
|
"selectors": ["countPlural"],
|
||||||
|
"match": {
|
||||||
|
"countPlural=one": "{count}d",
|
||||||
|
"countPlural=*": "{count}d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dashboard_flaggedResults": "flagged results",
|
||||||
|
"dashboard_flaggedLabsCount": [
|
||||||
|
{
|
||||||
|
"declarations": ["input count", "local countPlural = count: plural"],
|
||||||
|
"selectors": ["countPlural"],
|
||||||
|
"match": {
|
||||||
|
"countPlural=one": "{count} flagged result",
|
||||||
|
"countPlural=*": "{count} flagged results"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dashboard_attentionSnapshot": "Skin log",
|
||||||
|
"dashboard_attentionRoutineAM": "AM routine",
|
||||||
|
"dashboard_attentionRoutinePM": "PM routine",
|
||||||
|
"dashboard_attentionLabs": "Lab review",
|
||||||
|
"dashboard_attentionMissing": "Missing",
|
||||||
|
"dashboard_attentionToday": "Today",
|
||||||
|
"dashboard_attentionStable": "No flagged items",
|
||||||
|
"dashboard_statusLogged": "Logged",
|
||||||
|
"dashboard_statusOpen": "Open",
|
||||||
|
"dashboard_metricHydration": "Hydration",
|
||||||
|
"dashboard_metricSensitivity": "Sensitivity",
|
||||||
|
"dashboard_metricSebumTzone": "Sebum T-zone",
|
||||||
|
"dashboard_metricSebumCheeks": "Sebum cheeks",
|
||||||
|
"dashboard_metricDelta": "Delta {delta}",
|
||||||
|
"dashboard_averageSteps": "Avg. {count} steps",
|
||||||
|
"dashboard_lastRoutine": "Last routine",
|
||||||
|
"dashboard_lastLabDate": "Last collection",
|
||||||
|
"dashboard_noLabResults": "No lab results yet.",
|
||||||
"dashboard_noSnapshots": "No skin snapshots yet.",
|
"dashboard_noSnapshots": "No skin snapshots yet.",
|
||||||
"dashboard_noRoutines": "No routines in the past 2 weeks.",
|
"dashboard_noRoutines": "No routines in the past 2 weeks.",
|
||||||
|
|
||||||
|
|
@ -152,6 +218,9 @@
|
||||||
"grooming_title": "Grooming Schedule",
|
"grooming_title": "Grooming Schedule",
|
||||||
"grooming_backToRoutines": "Routines",
|
"grooming_backToRoutines": "Routines",
|
||||||
"grooming_addEntry": "+ Add entry",
|
"grooming_addEntry": "+ Add entry",
|
||||||
|
"grooming_newTitle": "New grooming entry",
|
||||||
|
"grooming_newSubtitle": "Add a recurring entry to your weekly grooming schedule.",
|
||||||
|
"grooming_newSectionIntro": "Choose the day, action, and an optional note.",
|
||||||
"grooming_entryAdded": "Entry added.",
|
"grooming_entryAdded": "Entry added.",
|
||||||
"grooming_entryUpdated": "Entry updated.",
|
"grooming_entryUpdated": "Entry updated.",
|
||||||
"grooming_entryDeleted": "Entry deleted.",
|
"grooming_entryDeleted": "Entry deleted.",
|
||||||
|
|
@ -254,6 +323,8 @@
|
||||||
],
|
],
|
||||||
"medications_addNew": "+ Add medication",
|
"medications_addNew": "+ Add medication",
|
||||||
"medications_newTitle": "New medication",
|
"medications_newTitle": "New medication",
|
||||||
|
"medications_newSubtitle": "Add a basic medication or supplement record for later tracking.",
|
||||||
|
"medications_newSectionIntro": "Start with the type, product name, and active substance.",
|
||||||
"medications_kind": "Kind",
|
"medications_kind": "Kind",
|
||||||
"medications_productName": "Product name *",
|
"medications_productName": "Product name *",
|
||||||
"medications_productNamePlaceholder": "e.g. Vitamin D3",
|
"medications_productNamePlaceholder": "e.g. Vitamin D3",
|
||||||
|
|
@ -291,6 +362,8 @@
|
||||||
],
|
],
|
||||||
"labResults_addNew": "+ Add result",
|
"labResults_addNew": "+ Add result",
|
||||||
"labResults_newTitle": "New lab result",
|
"labResults_newTitle": "New lab result",
|
||||||
|
"labResults_newSubtitle": "Save a single lab result and add it to your health history.",
|
||||||
|
"labResults_newSectionIntro": "Start with the date and LOINC code, then add the remaining details.",
|
||||||
"labResults_flagFilter": "Flag:",
|
"labResults_flagFilter": "Flag:",
|
||||||
"labResults_flagAll": "All",
|
"labResults_flagAll": "All",
|
||||||
"labResults_flagNone": "None",
|
"labResults_flagNone": "None",
|
||||||
|
|
@ -374,6 +447,8 @@
|
||||||
"skin_analyzePhotos": "Analyze photos",
|
"skin_analyzePhotos": "Analyze photos",
|
||||||
"skin_analyzing": "Analyzing…",
|
"skin_analyzing": "Analyzing…",
|
||||||
"skin_newSnapshotTitle": "New skin snapshot",
|
"skin_newSnapshotTitle": "New skin snapshot",
|
||||||
|
"skin_newSubtitle": "Capture today’s skin state manually or prefill the form with AI photo analysis.",
|
||||||
|
"skin_newSectionIntro": "Start with the date and overall condition, then refine the details.",
|
||||||
"skin_date": "Date *",
|
"skin_date": "Date *",
|
||||||
"skin_overallState": "Overall state",
|
"skin_overallState": "Overall state",
|
||||||
"skin_texture": "Texture",
|
"skin_texture": "Texture",
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,82 @@
|
||||||
|
|
||||||
"dashboard_title": "Dashboard",
|
"dashboard_title": "Dashboard",
|
||||||
"dashboard_subtitle": "Przegląd zdrowia i pielęgnacji",
|
"dashboard_subtitle": "Przegląd zdrowia i pielęgnacji",
|
||||||
|
"dashboard_dailyBriefing": "Szybki rzut oka na zmiany, braki i miejsca, które warto teraz sprawdzić.",
|
||||||
"dashboard_latestSnapshot": "Ostatni stan skóry",
|
"dashboard_latestSnapshot": "Ostatni stan skóry",
|
||||||
"dashboard_recentRoutines": "Ostatnie rutyny",
|
"dashboard_recentRoutines": "Ostatnie rutyny",
|
||||||
|
"dashboard_requiresAttention": "Wymaga uwagi",
|
||||||
|
"dashboard_healthPulse": "Puls zdrowia",
|
||||||
|
"dashboard_viewSkinHistory": "Otwórz historię skóry",
|
||||||
|
"dashboard_viewLabResults": "Otwórz wyniki badań",
|
||||||
|
"dashboard_heroFreshness": "Świeżość",
|
||||||
|
"dashboard_sinceLastSnapshot": "od ostatniego wpisu",
|
||||||
|
"dashboard_skinFreshness": [
|
||||||
|
{
|
||||||
|
"declarations": ["input count", "local countPlural = count: plural"],
|
||||||
|
"selectors": ["countPlural"],
|
||||||
|
"match": {
|
||||||
|
"countPlural=one": "Ostatni wpis był {count} dzień temu.",
|
||||||
|
"countPlural=few": "Ostatni wpis był {count} dni temu.",
|
||||||
|
"countPlural=many": "Ostatni wpis był {count} dni temu.",
|
||||||
|
"countPlural=*": "Ostatni wpis był {count} dni temu."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dashboard_daysAgo": [
|
||||||
|
{
|
||||||
|
"declarations": ["input count", "local countPlural = count: plural"],
|
||||||
|
"selectors": ["countPlural"],
|
||||||
|
"match": {
|
||||||
|
"countPlural=one": "{count} dzień temu",
|
||||||
|
"countPlural=few": "{count} dni temu",
|
||||||
|
"countPlural=many": "{count} dni temu",
|
||||||
|
"countPlural=*": "{count} dni temu"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dashboard_daysAgoShort": [
|
||||||
|
{
|
||||||
|
"declarations": ["input count", "local countPlural = count: plural"],
|
||||||
|
"selectors": ["countPlural"],
|
||||||
|
"match": {
|
||||||
|
"countPlural=one": "{count} d",
|
||||||
|
"countPlural=few": "{count} d",
|
||||||
|
"countPlural=many": "{count} d",
|
||||||
|
"countPlural=*": "{count} d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dashboard_flaggedResults": "wyników oznaczonych flagą",
|
||||||
|
"dashboard_flaggedLabsCount": [
|
||||||
|
{
|
||||||
|
"declarations": ["input count", "local countPlural = count: plural"],
|
||||||
|
"selectors": ["countPlural"],
|
||||||
|
"match": {
|
||||||
|
"countPlural=one": "{count} wynik z flagą",
|
||||||
|
"countPlural=few": "{count} wyniki z flagą",
|
||||||
|
"countPlural=many": "{count} wyników z flagą",
|
||||||
|
"countPlural=*": "{count} wyników z flagą"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dashboard_attentionSnapshot": "Dziennik skóry",
|
||||||
|
"dashboard_attentionRoutineAM": "Rutyna AM",
|
||||||
|
"dashboard_attentionRoutinePM": "Rutyna PM",
|
||||||
|
"dashboard_attentionLabs": "Przegląd badań",
|
||||||
|
"dashboard_attentionMissing": "Brak",
|
||||||
|
"dashboard_attentionToday": "Dzisiaj",
|
||||||
|
"dashboard_attentionStable": "Brak flagowanych pozycji",
|
||||||
|
"dashboard_statusLogged": "Zapisane",
|
||||||
|
"dashboard_statusOpen": "Do uzupełnienia",
|
||||||
|
"dashboard_metricHydration": "Nawodnienie",
|
||||||
|
"dashboard_metricSensitivity": "Wrażliwość",
|
||||||
|
"dashboard_metricSebumTzone": "Sebum T-zone",
|
||||||
|
"dashboard_metricSebumCheeks": "Sebum policzki",
|
||||||
|
"dashboard_metricDelta": "Zmiana {delta}",
|
||||||
|
"dashboard_averageSteps": "Śr. {count} kroków",
|
||||||
|
"dashboard_lastRoutine": "Ostatnia rutyna",
|
||||||
|
"dashboard_lastLabDate": "Ostatnie badanie",
|
||||||
|
"dashboard_noLabResults": "Brak wyników badań.",
|
||||||
"dashboard_noSnapshots": "Brak wpisów o stanie skóry.",
|
"dashboard_noSnapshots": "Brak wpisów o stanie skóry.",
|
||||||
"dashboard_noRoutines": "Brak rutyn w ciągu ostatnich 2 tygodni.",
|
"dashboard_noRoutines": "Brak rutyn w ciągu ostatnich 2 tygodni.",
|
||||||
|
|
||||||
|
|
@ -156,6 +230,9 @@
|
||||||
"grooming_title": "Harmonogram pielęgnacji",
|
"grooming_title": "Harmonogram pielęgnacji",
|
||||||
"grooming_backToRoutines": "Rutyny",
|
"grooming_backToRoutines": "Rutyny",
|
||||||
"grooming_addEntry": "+ Dodaj wpis",
|
"grooming_addEntry": "+ Dodaj wpis",
|
||||||
|
"grooming_newTitle": "Nowy wpis pielęgnacyjny",
|
||||||
|
"grooming_newSubtitle": "Dodaj stały wpis do tygodniowego harmonogramu pielęgnacji.",
|
||||||
|
"grooming_newSectionIntro": "Ustal dzień, czynność i krótką notatkę, jeśli chcesz.",
|
||||||
"grooming_entryAdded": "Wpis dodany.",
|
"grooming_entryAdded": "Wpis dodany.",
|
||||||
"grooming_entryUpdated": "Wpis zaktualizowany.",
|
"grooming_entryUpdated": "Wpis zaktualizowany.",
|
||||||
"grooming_entryDeleted": "Wpis usunięty.",
|
"grooming_entryDeleted": "Wpis usunięty.",
|
||||||
|
|
@ -262,6 +339,8 @@
|
||||||
],
|
],
|
||||||
"medications_addNew": "+ Dodaj lek",
|
"medications_addNew": "+ Dodaj lek",
|
||||||
"medications_newTitle": "Nowy lek",
|
"medications_newTitle": "Nowy lek",
|
||||||
|
"medications_newSubtitle": "Dodaj podstawowy rekord leku lub suplementu do dalszego śledzenia.",
|
||||||
|
"medications_newSectionIntro": "Zacznij od rodzaju, nazwy i substancji czynnej.",
|
||||||
"medications_kind": "Rodzaj",
|
"medications_kind": "Rodzaj",
|
||||||
"medications_productName": "Nazwa produktu *",
|
"medications_productName": "Nazwa produktu *",
|
||||||
"medications_productNamePlaceholder": "np. Witamina D3",
|
"medications_productNamePlaceholder": "np. Witamina D3",
|
||||||
|
|
@ -303,6 +382,8 @@
|
||||||
],
|
],
|
||||||
"labResults_addNew": "+ Dodaj wynik",
|
"labResults_addNew": "+ Dodaj wynik",
|
||||||
"labResults_newTitle": "Nowy wynik badania",
|
"labResults_newTitle": "Nowy wynik badania",
|
||||||
|
"labResults_newSubtitle": "Zapisz pojedynczy wynik badania, aby dołączyć go do historii zdrowia.",
|
||||||
|
"labResults_newSectionIntro": "Najpierw podaj datę i kod LOINC, resztę możesz uzupełnić skrótowo.",
|
||||||
"labResults_flagFilter": "Flaga:",
|
"labResults_flagFilter": "Flaga:",
|
||||||
"labResults_flagAll": "Wszystkie",
|
"labResults_flagAll": "Wszystkie",
|
||||||
"labResults_flagNone": "Brak",
|
"labResults_flagNone": "Brak",
|
||||||
|
|
@ -388,6 +469,8 @@
|
||||||
"skin_analyzePhotos": "Analizuj zdjęcia",
|
"skin_analyzePhotos": "Analizuj zdjęcia",
|
||||||
"skin_analyzing": "Analizuję…",
|
"skin_analyzing": "Analizuję…",
|
||||||
"skin_newSnapshotTitle": "Nowy wpis",
|
"skin_newSnapshotTitle": "Nowy wpis",
|
||||||
|
"skin_newSubtitle": "Zapisz bieżący stan skóry ręcznie lub uzupełnij pola analizą AI ze zdjęć.",
|
||||||
|
"skin_newSectionIntro": "Zacznij od daty i ogólnej oceny, a potem doprecyzuj szczegóły.",
|
||||||
"skin_date": "Data *",
|
"skin_date": "Data *",
|
||||||
"skin_overallState": "Ogólny stan",
|
"skin_overallState": "Ogólny stan",
|
||||||
"skin_texture": "Tekstura",
|
"skin_texture": "Tekstura",
|
||||||
|
|
|
||||||
|
|
@ -153,40 +153,182 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-mobile-header {
|
.app-mobile-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 40;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
border-bottom: 1px solid hsl(35 22% 76% / 0.7);
|
border-bottom: 1px solid hsl(35 22% 76% / 0.7);
|
||||||
background: linear-gradient(180deg, hsl(44 35% 97%), hsl(44 25% 94%));
|
background: linear-gradient(180deg, hsl(44 35% 97% / 0.92), hsl(44 25% 94% / 0.96));
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-mobile-titleblock {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-mobile-overline,
|
||||||
|
.app-sidebar-subtitle {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-mobile-title,
|
.app-mobile-title,
|
||||||
.app-brand {
|
.app-brand {
|
||||||
|
display: block;
|
||||||
font-family: 'Cormorant Infant', 'Times New Roman', serif;
|
font-family: 'Cormorant Infant', 'Times New Roman', serif;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-mobile-toggle,
|
||||||
.app-icon-button {
|
.app-icon-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 2rem;
|
height: 2.6rem;
|
||||||
width: 2rem;
|
width: 2.6rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: 1px solid hsl(34 21% 75%);
|
border: 1px solid hsl(34 21% 75% / 0.95);
|
||||||
border-radius: 0.45rem;
|
border-radius: 999px;
|
||||||
|
background: hsl(42 32% 95% / 0.92);
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
|
box-shadow: 0 10px 24px -20px hsl(220 32% 14% / 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-mobile-toggle:hover,
|
||||||
.app-icon-button:hover {
|
.app-icon-button:hover {
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
border-color: var(--page-accent);
|
border-color: var(--page-accent);
|
||||||
background: var(--page-accent-soft);
|
background: var(--page-accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-drawer-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
background: hsl(220 40% 8% / 0.42);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
.app-sidebar {
|
.app-sidebar {
|
||||||
border-right: 1px solid hsl(36 20% 73% / 0.75);
|
border-right: 1px solid hsl(36 20% 73% / 0.75);
|
||||||
background: linear-gradient(180deg, hsl(44 34% 97%), hsl(42 28% 94%));
|
background: linear-gradient(180deg, hsl(44 34% 97%), hsl(42 28% 94%));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-drawer {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
z-index: 60;
|
||||||
|
display: flex;
|
||||||
|
width: min(20rem, calc(100vw - 1.5rem));
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-right: 1px solid hsl(36 20% 73% / 0.75);
|
||||||
|
background: linear-gradient(180deg, hsl(44 35% 97%), hsl(42 29% 94%));
|
||||||
|
padding: 1.1rem 0.85rem 1rem;
|
||||||
|
box-shadow: 0 28px 56px -28px hsl(220 34% 14% / 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar-brandblock {
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar-brandcopy {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-mobile-header--menu-open {
|
||||||
|
border-bottom-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.95rem;
|
||||||
|
padding: 0.8rem 0.9rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.93rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 140ms ease, background-color 140ms ease, color 140ms ease, transform 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav-link:hover {
|
||||||
|
transform: translateX(2px);
|
||||||
|
border-color: hsl(35 23% 76% / 0.75);
|
||||||
|
background: hsl(42 28% 92% / 0.78);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav-link--active {
|
||||||
|
border-color: color-mix(in srgb, var(--page-accent) 45%, white);
|
||||||
|
background: color-mix(in srgb, var(--page-accent) 13%, white);
|
||||||
|
color: var(--foreground);
|
||||||
|
box-shadow: inset 0 1px 0 hsl(0 0% 100% / 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-switcher {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid hsl(35 22% 75% / 0.82);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: hsl(42 30% 93% / 0.9);
|
||||||
|
padding: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-switcher__button {
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transition: background-color 140ms ease, color 140ms ease, box-shadow 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-switcher__button:hover {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-switcher__button--active {
|
||||||
|
background: color-mix(in srgb, var(--page-accent) 14%, white);
|
||||||
|
color: var(--foreground);
|
||||||
|
box-shadow: inset 0 1px 0 hsl(0 0% 100% / 0.74);
|
||||||
|
}
|
||||||
|
|
||||||
.app-sidebar a {
|
.app-sidebar a {
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
@ -212,6 +354,7 @@ body {
|
||||||
width: min(1160px, 100%);
|
width: min(1160px, 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-main h1,
|
||||||
.app-main h2 {
|
.app-main h2 {
|
||||||
font-family: 'Cormorant Infant', 'Times New Roman', serif;
|
font-family: 'Cormorant Infant', 'Times New Roman', serif;
|
||||||
font-size: clamp(1.9rem, 3.3vw, 2.7rem);
|
font-size: clamp(1.9rem, 3.3vw, 2.7rem);
|
||||||
|
|
@ -241,6 +384,19 @@ body {
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-header-meta,
|
||||||
|
.page-header-foot {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header-meta {
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header-foot {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.editorial-toolbar {
|
.editorial-toolbar {
|
||||||
margin-top: 0.9rem;
|
margin-top: 0.9rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -289,7 +445,7 @@ body {
|
||||||
color: hsl(207 78% 28%);
|
color: hsl(207 78% 28%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.products-table-shell {
|
.editorial-table-shell {
|
||||||
border: 1px solid hsl(35 24% 74% / 0.85);
|
border: 1px solid hsl(35 24% 74% / 0.85);
|
||||||
border-radius: 0.9rem;
|
border-radius: 0.9rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -299,14 +455,14 @@ body {
|
||||||
background: color-mix(in srgb, var(--page-accent) 10%, white);
|
background: color-mix(in srgb, var(--page-accent) 10%, white);
|
||||||
}
|
}
|
||||||
|
|
||||||
.products-mobile-card {
|
.editorial-mobile-card {
|
||||||
display: block;
|
display: block;
|
||||||
border: 1px solid hsl(35 21% 76% / 0.85);
|
border: 1px solid hsl(35 21% 76% / 0.85);
|
||||||
border-radius: 0.8rem;
|
border-radius: 0.8rem;
|
||||||
padding: 0.95rem;
|
padding: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.products-section-title {
|
.editorial-section-title {
|
||||||
border-bottom: 1px dashed color-mix(in srgb, var(--page-accent) 35%, var(--border));
|
border-bottom: 1px dashed color-mix(in srgb, var(--page-accent) 35%, var(--border));
|
||||||
padding-bottom: 0.3rem;
|
padding-bottom: 0.3rem;
|
||||||
padding-top: 0.5rem;
|
padding-top: 0.5rem;
|
||||||
|
|
@ -317,11 +473,7 @@ body {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.products-sticky-actions {
|
.editorial-meta-strip {
|
||||||
border-color: color-mix(in srgb, var(--page-accent) 25%, var(--border));
|
|
||||||
}
|
|
||||||
|
|
||||||
.products-meta-strip {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -488,7 +640,7 @@ body {
|
||||||
font-feature-settings: 'tnum';
|
font-feature-settings: 'tnum';
|
||||||
}
|
}
|
||||||
|
|
||||||
.lab-results-mobile-grid .products-section-title {
|
.lab-results-mobile-grid .editorial-section-title {
|
||||||
margin-top: 0.15rem;
|
margin-top: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -525,6 +677,20 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
|
.app-mobile-header,
|
||||||
|
.app-drawer,
|
||||||
|
.app-drawer-backdrop {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
align-self: flex-start;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
|
|
@ -556,6 +722,10 @@ body {
|
||||||
grid-area: subtitle;
|
grid-area: subtitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-stat-strip {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
.editorial-toolbar {
|
.editorial-toolbar {
|
||||||
grid-area: actions;
|
grid-area: actions;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
|
|
@ -661,6 +831,122 @@ body {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-stat-strip {
|
||||||
|
margin-top: 1.8rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
border-top: 1px dashed color-mix(in srgb, var(--page-accent) 24%, var(--editorial-line));
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stat-card {
|
||||||
|
border: 1px solid hsl(36 18% 77% / 0.62);
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
background: linear-gradient(180deg, hsl(44 32% 96% / 0.74), hsl(45 24% 93% / 0.66));
|
||||||
|
padding: 0.72rem 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stat-label,
|
||||||
|
.dashboard-metric-label,
|
||||||
|
.dashboard-featured-label,
|
||||||
|
.dashboard-health-subline,
|
||||||
|
.dashboard-attention-label {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--editorial-muted);
|
||||||
|
font-size: 0.73rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stat-value,
|
||||||
|
.dashboard-metric-value,
|
||||||
|
.dashboard-featured-value {
|
||||||
|
margin: 0.28rem 0 0;
|
||||||
|
font-family: 'Cormorant Infant', 'Times New Roman', serif;
|
||||||
|
font-size: 1.28rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stat-detail,
|
||||||
|
.dashboard-featured-meta,
|
||||||
|
.dashboard-featured-notes,
|
||||||
|
.dashboard-health-value,
|
||||||
|
.dashboard-attention-value,
|
||||||
|
.dashboard-panel-note,
|
||||||
|
.dashboard-metric-trend {
|
||||||
|
margin: 0.28rem 0 0;
|
||||||
|
color: var(--editorial-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-attention-panel {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border: 1px solid hsl(36 25% 74% / 0.8);
|
||||||
|
border-radius: 1.2rem;
|
||||||
|
background: linear-gradient(180deg, hsl(44 40% 95% / 0.92), hsl(42 32% 93% / 0.94));
|
||||||
|
box-shadow:
|
||||||
|
0 20px 40px -34px hsl(219 32% 14% / 0.38),
|
||||||
|
inset 0 1px 0 hsl(0 0% 100% / 0.7);
|
||||||
|
padding: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-attention-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-attention-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Cormorant Infant', 'Times New Roman', serif;
|
||||||
|
font-size: clamp(1.3rem, 2.3vw, 1.6rem);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-attention-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-attention-item {
|
||||||
|
display: flex;
|
||||||
|
min-height: 4.35rem;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
border: 1px solid hsl(36 22% 74% / 0.75);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
padding: 0.72rem 0.8rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: transform 140ms ease, border-color 140ms ease, background-color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-attention-item:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: color-mix(in srgb, var(--page-accent) 42%, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-attention-item--alert {
|
||||||
|
background: linear-gradient(180deg, hsl(27 76% 95%), hsl(18 60% 91%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-attention-item--calm {
|
||||||
|
background: linear-gradient(180deg, hsl(130 24% 95%), hsl(136 26% 91%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-attention-value {
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.editorial-grid {
|
.editorial-grid {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|
@ -671,11 +957,16 @@ body {
|
||||||
|
|
||||||
.editorial-panel {
|
.editorial-panel {
|
||||||
border-radius: 1.2rem;
|
border-radius: 1.2rem;
|
||||||
padding: 1rem;
|
padding: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid {
|
||||||
|
grid-template-columns: minmax(0, 1.08fr) minmax(0, 1fr) minmax(0, 0.92fr);
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
margin-bottom: 0.9rem;
|
margin-bottom: 0.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
@ -687,7 +978,7 @@ body {
|
||||||
.panel-header h3 {
|
.panel-header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: 'Cormorant Infant', 'Times New Roman', serif;
|
font-family: 'Cormorant Infant', 'Times New Roman', serif;
|
||||||
font-size: clamp(1.35rem, 2.4vw, 1.7rem);
|
font-size: clamp(1.22rem, 2.1vw, 1.56rem);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -714,7 +1005,7 @@ body {
|
||||||
|
|
||||||
.snapshot-date {
|
.snapshot-date {
|
||||||
color: var(--editorial-muted);
|
color: var(--editorial-muted);
|
||||||
font-size: 0.9rem;
|
font-size: 0.84rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -786,33 +1077,51 @@ body {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-metric-row {
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-metric-card {
|
||||||
|
border: 1px solid hsl(36 23% 74% / 0.76);
|
||||||
|
border-radius: 0.8rem;
|
||||||
|
background: hsl(42 36% 93% / 0.7);
|
||||||
|
padding: 0.65rem 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-metric-value {
|
||||||
|
font-size: 1.08rem;
|
||||||
|
}
|
||||||
|
|
||||||
.routine-summary-strip {
|
.routine-summary-strip {
|
||||||
margin-bottom: 0.7rem;
|
margin-bottom: 0.6rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.routine-summary-chip {
|
.routine-summary-chip {
|
||||||
border: 1px solid hsl(35 24% 71% / 0.85);
|
border: 1px solid hsl(35 24% 71% / 0.85);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.22rem 0.62rem;
|
padding: 0.16rem 0.5rem;
|
||||||
color: var(--editorial-muted);
|
color: var(--editorial-muted);
|
||||||
font-size: 0.74rem;
|
font-size: 0.68rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.06em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-action-link,
|
.panel-action-link,
|
||||||
.routine-summary-link {
|
.routine-summary-link {
|
||||||
border: 1px solid color-mix(in srgb, var(--page-accent) 38%, var(--editorial-line));
|
border: 1px solid color-mix(in srgb, var(--page-accent) 38%, var(--editorial-line));
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.24rem 0.64rem;
|
padding: 0.2rem 0.56rem;
|
||||||
color: var(--page-accent);
|
color: var(--page-accent);
|
||||||
font-size: 0.76rem;
|
font-size: 0.68rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.06em;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
@ -826,6 +1135,32 @@ body {
|
||||||
background: var(--page-accent-soft);
|
background: var(--page-accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-featured-routine {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.7rem;
|
||||||
|
border: 1px solid hsl(36 24% 73% / 0.82);
|
||||||
|
border-radius: 0.88rem;
|
||||||
|
background: linear-gradient(155deg, color-mix(in srgb, var(--page-accent) 5%, white), hsl(44 30% 94%));
|
||||||
|
padding: 0.78rem 0.84rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-featured-routine:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--page-accent) 44%, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-featured-routine-topline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-featured-notes {
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
.routine-item + .routine-item {
|
.routine-item + .routine-item {
|
||||||
border-top: 1px dashed hsl(36 26% 72% / 0.7);
|
border-top: 1px dashed hsl(36 26% 72% / 0.7);
|
||||||
}
|
}
|
||||||
|
|
@ -834,7 +1169,7 @@ body {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
padding: 0.78rem 0;
|
padding: 0.68rem 0;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
transition: transform 140ms ease, color 160ms ease;
|
transition: transform 140ms ease, color 160ms ease;
|
||||||
|
|
@ -858,9 +1193,9 @@ body {
|
||||||
.routine-meta {
|
.routine-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.75rem;
|
gap: 0.6rem;
|
||||||
color: var(--editorial-muted);
|
color: var(--editorial-muted);
|
||||||
font-size: 0.8rem;
|
font-size: 0.76rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.routine-note-inline {
|
.routine-note-inline {
|
||||||
|
|
@ -882,7 +1217,7 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.routine-date {
|
.routine-date {
|
||||||
font-size: 0.93rem;
|
font-size: 0.88rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -909,6 +1244,56 @@ body {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-health-meta-strip {
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-health-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-health-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.65rem;
|
||||||
|
border: 1px solid hsl(36 22% 75% / 0.78);
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
background: hsl(44 34% 95% / 0.75);
|
||||||
|
padding: 0.68rem 0.78rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-health-item:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--page-accent) 36%, var(--border));
|
||||||
|
background: var(--page-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-health-test {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-health-value-wrap {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-health-value {
|
||||||
|
margin-top: 0;
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
.reveal-1,
|
.reveal-1,
|
||||||
.reveal-2,
|
.reveal-2,
|
||||||
.reveal-3 {
|
.reveal-3 {
|
||||||
|
|
@ -933,12 +1318,23 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
|
.dashboard-stat-strip,
|
||||||
|
.dashboard-attention-list,
|
||||||
.editorial-grid {
|
.editorial-grid {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
.dashboard-stat-strip {
|
||||||
|
margin-top: 1.35rem;
|
||||||
|
padding-top: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
.editorial-title {
|
.editorial-title {
|
||||||
font-size: 2.05rem;
|
font-size: 2.05rem;
|
||||||
}
|
}
|
||||||
|
|
@ -951,6 +1347,21 @@ body {
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-metric-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-health-item,
|
||||||
|
.dashboard-featured-routine-topline,
|
||||||
|
.dashboard-attention-item {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-health-value-wrap {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.state-pill,
|
.state-pill,
|
||||||
.routine-pill {
|
.routine-pill {
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Sparkles } from 'lucide-svelte';
|
import { Sparkles } from 'lucide-svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
autoFixes: string[];
|
autoFixes: string[];
|
||||||
|
|
|
||||||
18
frontend/src/lib/components/FlashMessages.svelte
Normal file
18
frontend/src/lib/components/FlashMessages.svelte
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<script lang="ts">
|
||||||
|
type FlashMessage = {
|
||||||
|
kind: 'error' | 'success' | 'warning' | 'info';
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { messages = [] }: { messages?: FlashMessage[] } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if messages.length}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each messages as message, index (`${message.kind}-${index}-${message.text}`)}
|
||||||
|
<div class={`editorial-alert editorial-alert--${message.kind}`}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
@ -1,15 +1,21 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getLocale, setLocale } from '$lib/paraglide/runtime.js';
|
import * as runtime from '$lib/paraglide/runtime.js';
|
||||||
|
|
||||||
|
const locales = [
|
||||||
|
{ code: 'pl', label: 'PL' },
|
||||||
|
{ code: 'en', label: 'EN' }
|
||||||
|
] as const;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex gap-1 font-mono text-xs text-muted-foreground">
|
<div class="language-switcher" role="group" aria-label="Language switcher">
|
||||||
|
{#each locales as locale (locale.code)}
|
||||||
<button
|
<button
|
||||||
class="hover:text-foreground transition-colors {getLocale() === 'pl' ? 'font-bold text-foreground' : ''}"
|
type="button"
|
||||||
onclick={() => setLocale('pl')}
|
class:language-switcher__button={true}
|
||||||
>PL</button>
|
class:language-switcher__button--active={runtime.getLocale() === locale.code}
|
||||||
<span>|</span>
|
onclick={() => runtime.setLocale(locale.code)}
|
||||||
<button
|
>
|
||||||
class="hover:text-foreground transition-colors {getLocale() === 'en' ? 'font-bold text-foreground' : ''}"
|
{locale.label}
|
||||||
onclick={() => setLocale('en')}
|
</button>
|
||||||
>EN</button>
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Info, ChevronDown, ChevronRight } from 'lucide-svelte';
|
import { Info, ChevronDown, ChevronRight } from 'lucide-svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import type { ResponseMetadata } from '$lib/types';
|
import type { ResponseMetadata } from '$lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
|
||||||
62
frontend/src/lib/components/PageHeader.svelte
Normal file
62
frontend/src/lib/components/PageHeader.svelte
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { ArrowLeft } from 'lucide-svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
kicker,
|
||||||
|
subtitle,
|
||||||
|
backHref,
|
||||||
|
backLabel,
|
||||||
|
className = '',
|
||||||
|
titleClassName = 'editorial-title',
|
||||||
|
meta,
|
||||||
|
actions,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
kicker?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
backHref?: string;
|
||||||
|
backLabel?: string;
|
||||||
|
className?: string;
|
||||||
|
titleClassName?: string;
|
||||||
|
meta?: Snippet;
|
||||||
|
actions?: Snippet;
|
||||||
|
children?: Snippet;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class={`editorial-hero reveal-1 ${className}`.trim()}>
|
||||||
|
{#if backHref && backLabel}
|
||||||
|
<a href={backHref} class="editorial-backlink"><ArrowLeft class="size-4" /> {backLabel}</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if kicker}
|
||||||
|
<p class="editorial-kicker">{kicker}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<h1 class={titleClassName}>{title}</h1>
|
||||||
|
|
||||||
|
{#if subtitle}
|
||||||
|
<p class="editorial-subtitle">{subtitle}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if meta}
|
||||||
|
<div class="page-header-meta">
|
||||||
|
{@render meta()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if actions}
|
||||||
|
<div class="editorial-toolbar">
|
||||||
|
{@render actions()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if children}
|
||||||
|
<div class="page-header-foot">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
import { baseSelectClass, baseTextareaClass } from '$lib/components/forms/form-classes';
|
import { baseSelectClass, baseTextareaClass } from '$lib/components/forms/form-classes';
|
||||||
import type { ProductParseResponse } from '$lib/api';
|
import type { ProductParseResponse } from '$lib/api';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import { Sparkles, X } from 'lucide-svelte';
|
import { Sparkles, X } from 'lucide-svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
import { Sparkles, X } from 'lucide-svelte';
|
import { Sparkles, X } from 'lucide-svelte';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Brain, ChevronDown, ChevronRight } from 'lucide-svelte';
|
import { Brain, ChevronDown, ChevronRight } from 'lucide-svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
reasoningChain?: string;
|
reasoningChain?: string;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { XCircle } from 'lucide-svelte';
|
import { XCircle } from 'lucide-svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
error: string;
|
error: string;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AlertTriangle } from 'lucide-svelte';
|
import { AlertTriangle } from 'lucide-svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
|
||||||
|
|
|
||||||
5
frontend/src/lib/utils/forms.ts
Normal file
5
frontend/src/lib/utils/forms.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export function preventIfNotConfirmed(event: SubmitEvent, message: string) {
|
||||||
|
if (!confirm(message)) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
57
frontend/src/lib/utils/skin-display.ts
Normal file
57
frontend/src/lib/utils/skin-display.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import {
|
||||||
|
common_unknown,
|
||||||
|
productForm_concernAcne,
|
||||||
|
productForm_concernAging,
|
||||||
|
productForm_concernDamagedBarrier,
|
||||||
|
productForm_concernDehydration,
|
||||||
|
productForm_concernHairGrowth,
|
||||||
|
productForm_concernHyperpigmentation,
|
||||||
|
productForm_concernPoreVisibility,
|
||||||
|
productForm_concernRedness,
|
||||||
|
productForm_concernRosacea,
|
||||||
|
productForm_concernSebumExcess,
|
||||||
|
productForm_concernUnevenTexture,
|
||||||
|
skin_stateExcellent,
|
||||||
|
skin_stateFair,
|
||||||
|
skin_stateGood,
|
||||||
|
skin_statePoor
|
||||||
|
} from '$lib/paraglide/messages.js';
|
||||||
|
import type { OverallSkinState, SkinConcern } from '$lib/types';
|
||||||
|
|
||||||
|
export function humanizeUnderscore(text: string): string {
|
||||||
|
return text
|
||||||
|
.split('_')
|
||||||
|
.map((chunk) => chunk.charAt(0).toUpperCase() + chunk.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOverallSkinStateLabel(state?: OverallSkinState | null): string {
|
||||||
|
if (!state) return common_unknown();
|
||||||
|
|
||||||
|
const labels: Record<OverallSkinState, () => string> = {
|
||||||
|
excellent: skin_stateExcellent,
|
||||||
|
good: skin_stateGood,
|
||||||
|
fair: skin_stateFair,
|
||||||
|
poor: skin_statePoor
|
||||||
|
};
|
||||||
|
|
||||||
|
return labels[state]?.() ?? humanizeUnderscore(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSkinConcernLabel(concern: SkinConcern | string): string {
|
||||||
|
const labels: Partial<Record<SkinConcern, () => string>> = {
|
||||||
|
acne: productForm_concernAcne,
|
||||||
|
rosacea: productForm_concernRosacea,
|
||||||
|
hyperpigmentation: productForm_concernHyperpigmentation,
|
||||||
|
aging: productForm_concernAging,
|
||||||
|
dehydration: productForm_concernDehydration,
|
||||||
|
redness: productForm_concernRedness,
|
||||||
|
damaged_barrier: productForm_concernDamagedBarrier,
|
||||||
|
pore_visibility: productForm_concernPoreVisibility,
|
||||||
|
uneven_texture: productForm_concernUnevenTexture,
|
||||||
|
hair_growth: productForm_concernHairGrowth,
|
||||||
|
sebum_excess: productForm_concernSebumExcess
|
||||||
|
};
|
||||||
|
|
||||||
|
return labels[concern as SkinConcern]?.() ?? humanizeUnderscore(concern);
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
|
import { afterNavigate } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
|
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
|
||||||
import {
|
import {
|
||||||
House,
|
House,
|
||||||
|
|
@ -22,6 +23,10 @@
|
||||||
let mobileMenuOpen = $state(false);
|
let mobileMenuOpen = $state(false);
|
||||||
const domainClass = $derived(getDomainClass(page.url.pathname));
|
const domainClass = $derived(getDomainClass(page.url.pathname));
|
||||||
|
|
||||||
|
afterNavigate(() => {
|
||||||
|
mobileMenuOpen = false;
|
||||||
|
});
|
||||||
|
|
||||||
const navItems = $derived([
|
const navItems = $derived([
|
||||||
{ href: resolve('/'), label: m.nav_dashboard(), icon: House },
|
{ href: resolve('/'), label: m.nav_dashboard(), icon: House },
|
||||||
{ href: resolve('/routines'), label: m.nav_routines(), icon: ClipboardList },
|
{ href: resolve('/routines'), label: m.nav_routines(), icon: ClipboardList },
|
||||||
|
|
@ -56,16 +61,17 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="app-shell {domainClass}">
|
<div class="app-shell {domainClass}">
|
||||||
<!-- Mobile header -->
|
<header class={`app-mobile-header md:hidden ${mobileMenuOpen ? 'app-mobile-header--menu-open' : ''}`}>
|
||||||
<header class="app-mobile-header md:hidden">
|
<div class="app-mobile-titleblock">
|
||||||
<div>
|
<p class="app-mobile-overline">{m["nav_appSubtitle"]()}</p>
|
||||||
<span class="app-mobile-title">{m["nav_appName"]()}</span>
|
<span class="app-mobile-title">{m["nav_appName"]()}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
||||||
class="app-icon-button"
|
class="app-mobile-toggle"
|
||||||
aria-label={m.common_toggleMenu()}
|
aria-label={m.common_toggleMenu()}
|
||||||
|
aria-expanded={mobileMenuOpen}
|
||||||
>
|
>
|
||||||
{#if mobileMenuOpen}
|
{#if mobileMenuOpen}
|
||||||
<X class="size-[18px]" />
|
<X class="size-[18px]" />
|
||||||
|
|
@ -75,74 +81,73 @@
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Mobile drawer overlay -->
|
|
||||||
{#if mobileMenuOpen}
|
{#if mobileMenuOpen}
|
||||||
<!-- Backdrop: closes drawer on click -->
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="fixed inset-0 z-50 bg-black/50 md:hidden"
|
class="app-drawer-backdrop md:hidden"
|
||||||
onclick={() => (mobileMenuOpen = false)}
|
onclick={() => (mobileMenuOpen = false)}
|
||||||
aria-label={m.common_cancel()}
|
aria-label={m.common_cancel()}
|
||||||
></button>
|
></button>
|
||||||
<!-- Drawer (same z-50 but later in DOM, on top) -->
|
<aside class="app-drawer md:hidden" aria-label={m.common_toggleMenu()}>
|
||||||
<nav
|
<div class="app-sidebar-brandblock">
|
||||||
class="fixed inset-y-0 left-0 z-50 w-64 overflow-y-auto bg-card px-3 py-6 md:hidden app-sidebar"
|
<div class="app-sidebar-brandcopy">
|
||||||
>
|
|
||||||
<div class="mb-8 px-3">
|
|
||||||
<h1 class="app-brand">{m["nav_appName"]()}</h1>
|
<h1 class="app-brand">{m["nav_appName"]()}</h1>
|
||||||
<p class="text-xs text-muted-foreground">{m["nav_appSubtitle"]()}</p>
|
<p class="app-sidebar-subtitle">{m["nav_appSubtitle"]()}</p>
|
||||||
</div>
|
</div>
|
||||||
<ul class="space-y-1">
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (mobileMenuOpen = false)}
|
||||||
|
class="app-icon-button"
|
||||||
|
aria-label={m.common_cancel()}
|
||||||
|
>
|
||||||
|
<X class="size-[18px]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul class="app-nav-list">
|
||||||
{#each navItems as item (item.href)}
|
{#each navItems as item (item.href)}
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
onclick={() => (mobileMenuOpen = false)}
|
onclick={() => (mobileMenuOpen = false)}
|
||||||
class="flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors
|
class={`app-nav-link ${isActive(item.href) ? 'app-nav-link--active' : ''}`}
|
||||||
{isActive(item.href)
|
|
||||||
? 'bg-accent text-accent-foreground font-medium'
|
|
||||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
|
|
||||||
>
|
>
|
||||||
<item.icon class="size-4 shrink-0" />
|
<item.icon class="size-4 shrink-0" />
|
||||||
{item.label}
|
<span>{item.label}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
<div class="mt-6 px-3">
|
<div class="app-sidebar-footer">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</aside>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Desktop Sidebar -->
|
|
||||||
<nav class="app-sidebar hidden w-56 shrink-0 flex-col px-3 py-6 md:flex">
|
<nav class="app-sidebar hidden w-56 shrink-0 flex-col px-3 py-6 md:flex">
|
||||||
<div class="mb-8 px-3">
|
<div class="app-sidebar-brandblock">
|
||||||
|
<div class="app-sidebar-brandcopy">
|
||||||
<h1 class="app-brand">{m["nav_appName"]()}</h1>
|
<h1 class="app-brand">{m["nav_appName"]()}</h1>
|
||||||
<p class="text-xs text-muted-foreground">{m["nav_appSubtitle"]()}</p>
|
<p class="app-sidebar-subtitle">{m["nav_appSubtitle"]()}</p>
|
||||||
</div>
|
</div>
|
||||||
<ul class="space-y-1">
|
</div>
|
||||||
|
<ul class="app-nav-list">
|
||||||
{#each navItems as item (item.href)}
|
{#each navItems as item (item.href)}
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class="flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors
|
class={`app-nav-link ${isActive(item.href) ? 'app-nav-link--active' : ''}`}
|
||||||
{isActive(item.href)
|
|
||||||
? 'bg-accent text-accent-foreground font-medium'
|
|
||||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
|
|
||||||
>
|
>
|
||||||
<item.icon class="size-4 shrink-0" />
|
<item.icon class="size-4 shrink-0" />
|
||||||
{item.label}
|
<span>{item.label}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
<div class="mt-6 px-3">
|
<div class="app-sidebar-footer">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Main content -->
|
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
import { getRoutines, getSkinSnapshots } from '$lib/api';
|
import { getLabResults, getRoutines, getSkinSnapshots } from '$lib/api';
|
||||||
import type { SkinConditionSnapshot } from '$lib/types';
|
import type { Routine, SkinConditionSnapshot } from '$lib/types';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
const [routines, snapshots] = await Promise.all([
|
const [routines, snapshots, labResults] = await Promise.all([
|
||||||
getRoutines({ from_date: recentDate(14) }),
|
getRoutines({ from_date: recentDate(14) }),
|
||||||
getSkinSnapshots({ from_date: recentDate(60) })
|
getSkinSnapshots({ from_date: recentDate(60) }),
|
||||||
|
getLabResults({ latest_only: true, limit: 8 })
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
recentRoutines: routines.slice(0, 10),
|
recentRoutines: getFreshestRoutines(routines).slice(0, 10),
|
||||||
latestSnapshot: getFreshestSnapshot(snapshots)
|
recentSnapshots: snapshots,
|
||||||
|
latestSnapshot: getFreshestSnapshot(snapshots),
|
||||||
|
recentLabResults: labResults.items
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -27,3 +30,11 @@ function getFreshestSnapshot(snapshots: SkinConditionSnapshot[]): SkinConditionS
|
||||||
return current.created_at > freshest.created_at ? current : freshest;
|
return current.created_at > freshest.created_at ? current : freshest;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFreshestRoutines(routines: Routine[]): Routine[] {
|
||||||
|
return [...routines].sort((a, b) => {
|
||||||
|
if (a.routine_date !== b.routine_date) return b.routine_date.localeCompare(a.routine_date);
|
||||||
|
if (a.part_of_day !== b.part_of_day) return b.part_of_day.localeCompare(a.part_of_day);
|
||||||
|
return b.created_at.localeCompare(a.created_at);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,56 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
|
import {
|
||||||
|
common_am,
|
||||||
|
common_pm,
|
||||||
|
common_steps,
|
||||||
|
dashboard_attentionLabs,
|
||||||
|
dashboard_attentionMissing,
|
||||||
|
dashboard_attentionRoutineAM,
|
||||||
|
dashboard_attentionRoutinePM,
|
||||||
|
dashboard_attentionSnapshot,
|
||||||
|
dashboard_attentionStable,
|
||||||
|
dashboard_attentionToday,
|
||||||
|
dashboard_averageSteps,
|
||||||
|
dashboard_dailyBriefing,
|
||||||
|
dashboard_daysAgo,
|
||||||
|
dashboard_daysAgoShort,
|
||||||
|
dashboard_flaggedLabsCount,
|
||||||
|
dashboard_flaggedResults,
|
||||||
|
dashboard_healthPulse,
|
||||||
|
dashboard_heroFreshness,
|
||||||
|
dashboard_lastLabDate,
|
||||||
|
dashboard_lastRoutine,
|
||||||
|
dashboard_latestSnapshot,
|
||||||
|
dashboard_metricDelta,
|
||||||
|
dashboard_metricHydration,
|
||||||
|
dashboard_metricSebumCheeks,
|
||||||
|
dashboard_metricSebumTzone,
|
||||||
|
dashboard_metricSensitivity,
|
||||||
|
dashboard_noLabResults,
|
||||||
|
dashboard_noRoutines,
|
||||||
|
dashboard_noSnapshots,
|
||||||
|
dashboard_recentRoutines,
|
||||||
|
dashboard_requiresAttention,
|
||||||
|
dashboard_sinceLastSnapshot,
|
||||||
|
dashboard_skinFreshness,
|
||||||
|
dashboard_statusLogged,
|
||||||
|
dashboard_statusOpen,
|
||||||
|
dashboard_title,
|
||||||
|
dashboard_viewLabResults,
|
||||||
|
dashboard_viewSkinHistory,
|
||||||
|
labResults_boolFalse,
|
||||||
|
labResults_boolTrue,
|
||||||
|
labResults_valueEmpty,
|
||||||
|
nav_appSubtitle,
|
||||||
|
routines_addNew,
|
||||||
|
routines_notes,
|
||||||
|
skin_activeConcerns,
|
||||||
|
skin_addNew
|
||||||
|
} from '$lib/paraglide/messages.js';
|
||||||
|
import type { LabResult, OverallSkinState, SkinConditionSnapshot } from '$lib/types';
|
||||||
|
import { getOverallSkinStateLabel, getSkinConcernLabel } from '$lib/utils/skin-display';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
|
@ -17,95 +66,306 @@
|
||||||
pm: 'routine-pill routine-pill--pm'
|
pm: 'routine-pill routine-pill--pm'
|
||||||
};
|
};
|
||||||
|
|
||||||
function humanize(text: string): string {
|
function daysSince(dateString: string): number {
|
||||||
return text
|
const todayStart = Date.parse(new Date().toISOString().slice(0, 10));
|
||||||
.split('_')
|
const dateStart = Date.parse(`${dateString}T00:00:00`);
|
||||||
.map((chunk) => chunk.charAt(0).toUpperCase() + chunk.slice(1))
|
const diffMs = todayStart - dateStart;
|
||||||
.join(' ');
|
return Math.max(0, Math.round(diffMs / 86_400_000));
|
||||||
}
|
}
|
||||||
|
|
||||||
const routineStats = $derived.by(() => {
|
function formatState(state?: OverallSkinState | null): string {
|
||||||
const amCount = data.recentRoutines.filter((routine) => routine.part_of_day === 'am').length;
|
return getOverallSkinStateLabel(state);
|
||||||
const pmCount = data.recentRoutines.length - amCount;
|
}
|
||||||
return { amCount, pmCount };
|
|
||||||
|
function formatConcern(concern: string): string {
|
||||||
|
return getSkinConcernLabel(concern);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLabLabel(lab: LabResult): string {
|
||||||
|
return lab.test_name_original ?? lab.test_name_loinc ?? lab.test_code;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLabValue(lab: LabResult): string {
|
||||||
|
if (lab.value_num != null) {
|
||||||
|
return `${lab.value_num}${lab.unit_original ? ` ${lab.unit_original}` : ''}`;
|
||||||
|
}
|
||||||
|
if (lab.value_text) return lab.value_text;
|
||||||
|
if (lab.value_bool != null) return lab.value_bool ? labResults_boolTrue() : labResults_boolFalse();
|
||||||
|
return labResults_valueEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
const metricLabels = {
|
||||||
|
hydration_level: dashboard_metricHydration,
|
||||||
|
sensitivity_level: dashboard_metricSensitivity,
|
||||||
|
sebum_tzone: dashboard_metricSebumTzone,
|
||||||
|
sebum_cheeks: dashboard_metricSebumCheeks
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const sortedRoutines = $derived.by(() => {
|
||||||
|
return [...data.recentRoutines].sort((a, b) => {
|
||||||
|
if (a.routine_date !== b.routine_date) return b.routine_date.localeCompare(a.routine_date);
|
||||||
|
return b.created_at.localeCompare(a.created_at);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedSnapshots = $derived.by(() => {
|
||||||
|
return [...(data.recentSnapshots ?? [])].sort((a, b) => {
|
||||||
|
if (a.snapshot_date !== b.snapshot_date) return b.snapshot_date.localeCompare(a.snapshot_date);
|
||||||
|
return b.created_at.localeCompare(a.created_at);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedLabResults = $derived.by(() => {
|
||||||
|
return [...(data.recentLabResults ?? [])].sort((a, b) => {
|
||||||
|
if (a.collected_at !== b.collected_at) return b.collected_at.localeCompare(a.collected_at);
|
||||||
|
return b.created_at.localeCompare(a.created_at);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const routineStats = $derived.by(() => {
|
||||||
|
const amCount = sortedRoutines.filter((routine) => routine.part_of_day === 'am').length;
|
||||||
|
const pmCount = sortedRoutines.length - amCount;
|
||||||
|
const totalSteps = sortedRoutines.reduce((sum, routine) => sum + (routine.steps?.length ?? 0), 0);
|
||||||
|
const averageSteps = sortedRoutines.length ? Math.round((totalSteps / sortedRoutines.length) * 10) / 10 : 0;
|
||||||
|
return { amCount, pmCount, averageSteps };
|
||||||
|
});
|
||||||
|
|
||||||
|
const latestRoutine = $derived(sortedRoutines[0] ?? null);
|
||||||
|
const latestSnapshot = $derived(data.latestSnapshot ?? sortedSnapshots[0] ?? null);
|
||||||
|
const previousSnapshot = $derived(
|
||||||
|
latestSnapshot ? sortedSnapshots.find((snapshot) => snapshot.id !== latestSnapshot.id) ?? null : null
|
||||||
|
);
|
||||||
|
const todayIso = $derived(new Date().toISOString().slice(0, 10));
|
||||||
|
const hasRoutineTodayAM = $derived(
|
||||||
|
sortedRoutines.some((routine) => routine.routine_date === todayIso && routine.part_of_day === 'am')
|
||||||
|
);
|
||||||
|
const hasRoutineTodayPM = $derived(
|
||||||
|
sortedRoutines.some((routine) => routine.routine_date === todayIso && routine.part_of_day === 'pm')
|
||||||
|
);
|
||||||
|
const daysSinceSnapshot = $derived(latestSnapshot ? daysSince(latestSnapshot.snapshot_date) : null);
|
||||||
|
|
||||||
|
const snapshotMetrics = $derived.by(() => {
|
||||||
|
if (!latestSnapshot) return [] as Array<{ label: string; value: string; trend?: string }>;
|
||||||
|
|
||||||
|
const metrics = Object.entries(metricLabels) as Array<[
|
||||||
|
keyof typeof metricLabels,
|
||||||
|
() => string
|
||||||
|
]>;
|
||||||
|
|
||||||
|
return metrics
|
||||||
|
.map(([key, label]) => {
|
||||||
|
const current = latestSnapshot[key as keyof SkinConditionSnapshot];
|
||||||
|
const previous = previousSnapshot?.[key as keyof SkinConditionSnapshot];
|
||||||
|
if (typeof current !== 'number') return null;
|
||||||
|
|
||||||
|
let trend: string | undefined;
|
||||||
|
if (typeof previous === 'number') {
|
||||||
|
const delta = current - previous;
|
||||||
|
if (delta > 0) trend = `+${delta}`;
|
||||||
|
if (delta < 0) trend = `${delta}`;
|
||||||
|
if (delta === 0) trend = '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: label(),
|
||||||
|
value: `${current}/5`,
|
||||||
|
trend
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((metric) => metric !== null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const notableFlags = new Set(['ABN', 'H', 'L', 'POS']);
|
||||||
|
const flaggedLabs = $derived(sortedLabResults.filter((lab) => lab.flag && notableFlags.has(lab.flag)));
|
||||||
|
const featuredLabs = $derived((flaggedLabs.length ? flaggedLabs : sortedLabResults).slice(0, 4));
|
||||||
|
const latestLabDate = $derived(sortedLabResults[0]?.collected_at.slice(0, 10) ?? null);
|
||||||
|
type AttentionRoute = '/skin' | '/routines/new' | '/health/lab-results';
|
||||||
|
|
||||||
|
const attentionItems = $derived.by(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: dashboard_attentionSnapshot(),
|
||||||
|
value:
|
||||||
|
daysSinceSnapshot == null
|
||||||
|
? dashboard_attentionMissing()
|
||||||
|
: daysSinceSnapshot === 0
|
||||||
|
? dashboard_attentionToday()
|
||||||
|
: dashboard_daysAgo({ count: daysSinceSnapshot }),
|
||||||
|
route: '/skin',
|
||||||
|
tone: daysSinceSnapshot == null || daysSinceSnapshot > 7 ? 'alert' : 'calm'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: dashboard_attentionRoutineAM(),
|
||||||
|
value: hasRoutineTodayAM ? dashboard_statusLogged() : dashboard_statusOpen(),
|
||||||
|
route: '/routines/new',
|
||||||
|
tone: hasRoutineTodayAM ? 'calm' : 'alert'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: dashboard_attentionRoutinePM(),
|
||||||
|
value: hasRoutineTodayPM ? dashboard_statusLogged() : dashboard_statusOpen(),
|
||||||
|
route: '/routines/new',
|
||||||
|
tone: hasRoutineTodayPM ? 'calm' : 'alert'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: dashboard_attentionLabs(),
|
||||||
|
value: flaggedLabs.length
|
||||||
|
? dashboard_flaggedLabsCount({ count: flaggedLabs.length })
|
||||||
|
: dashboard_attentionStable(),
|
||||||
|
route: '/health/lab-results',
|
||||||
|
tone: flaggedLabs.length ? 'alert' : 'calm'
|
||||||
|
}
|
||||||
|
] as Array<{ label: string; value: string; route: AttentionRoute; tone: 'alert' | 'calm' }>;
|
||||||
|
});
|
||||||
|
|
||||||
|
const heroStats = $derived.by(() => [
|
||||||
|
{
|
||||||
|
label: dashboard_latestSnapshot(),
|
||||||
|
value: latestSnapshot ? latestSnapshot.snapshot_date : dashboard_attentionMissing(),
|
||||||
|
detail: latestSnapshot ? formatState(latestSnapshot.overall_state) : dashboard_noSnapshots()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: dashboard_recentRoutines(),
|
||||||
|
value: String(sortedRoutines.length),
|
||||||
|
detail: `${common_am()} ${routineStats.amCount} - ${common_pm()} ${routineStats.pmCount}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: dashboard_heroFreshness(),
|
||||||
|
value: daysSinceSnapshot == null ? '-' : dashboard_daysAgoShort({ count: daysSinceSnapshot }),
|
||||||
|
detail: latestSnapshot ? dashboard_sinceLastSnapshot() : dashboard_noSnapshots()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: dashboard_healthPulse(),
|
||||||
|
value: String(flaggedLabs.length),
|
||||||
|
detail: flaggedLabs.length ? dashboard_flaggedResults() : dashboard_attentionStable()
|
||||||
|
}
|
||||||
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{m.dashboard_title()} — innercontext</title></svelte:head>
|
<svelte:head><title>{dashboard_title()} - innercontext</title></svelte:head>
|
||||||
|
|
||||||
<div class="editorial-dashboard">
|
<div class="editorial-dashboard">
|
||||||
<div class="editorial-atmosphere" aria-hidden="true"></div>
|
<div class="editorial-atmosphere" aria-hidden="true"></div>
|
||||||
|
|
||||||
<section class="editorial-hero reveal-1">
|
<section class="editorial-hero reveal-1">
|
||||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
<p class="editorial-kicker">{nav_appSubtitle()}</p>
|
||||||
<h2 class="editorial-title">{m.dashboard_title()}</h2>
|
<h1 class="editorial-title">{dashboard_title()}</h1>
|
||||||
<p class="editorial-subtitle">{m.dashboard_subtitle()}</p>
|
<p class="editorial-subtitle">{dashboard_dailyBriefing()}</p>
|
||||||
|
|
||||||
{#if data.latestSnapshot}
|
<div class="dashboard-stat-strip" aria-label={dashboard_dailyBriefing()}>
|
||||||
{@const snapshot = data.latestSnapshot}
|
{#each heroStats as stat (stat.label)}
|
||||||
<div class="hero-strip">
|
<div class="dashboard-stat-card">
|
||||||
<div>
|
<p class="dashboard-stat-label">{stat.label}</p>
|
||||||
<p class="hero-strip-label">{m["dashboard_latestSnapshot"]()}</p>
|
<p class="dashboard-stat-value">{stat.value}</p>
|
||||||
<p class="hero-strip-value">{snapshot.snapshot_date}</p>
|
<p class="dashboard-stat-detail">{stat.detail}</p>
|
||||||
</div>
|
</div>
|
||||||
{#if snapshot.overall_state}
|
{/each}
|
||||||
<span class={stateTone[snapshot.overall_state] ?? 'state-pill'}>
|
</div>
|
||||||
{humanize(snapshot.overall_state)}
|
</section>
|
||||||
|
|
||||||
|
<section class="dashboard-attention-panel reveal-2" aria-labelledby="dashboard-attention-title">
|
||||||
|
<div class="dashboard-attention-header">
|
||||||
|
<p class="panel-index">01</p>
|
||||||
|
<h3 id="dashboard-attention-title">{dashboard_requiresAttention()}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-attention-list">
|
||||||
|
{#each attentionItems as item (item.label)}
|
||||||
|
<a href={resolve(item.route)} class={`dashboard-attention-item dashboard-attention-item--${item.tone}`}>
|
||||||
|
<span class="dashboard-attention-label">{item.label}</span>
|
||||||
|
<span class="dashboard-attention-value">{item.value}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="editorial-grid dashboard-grid">
|
||||||
|
<section class="editorial-panel reveal-2">
|
||||||
|
<header class="panel-header">
|
||||||
|
<p class="panel-index">02</p>
|
||||||
|
<h3>{dashboard_latestSnapshot()}</h3>
|
||||||
|
</header>
|
||||||
|
<div class="panel-action-row">
|
||||||
|
<a href={resolve('/skin')} class="panel-action-link">{dashboard_viewSkinHistory()}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if latestSnapshot}
|
||||||
|
<div class="snapshot-meta-row">
|
||||||
|
<span class="snapshot-date">{latestSnapshot.snapshot_date}</span>
|
||||||
|
{#if latestSnapshot.overall_state}
|
||||||
|
<span class={stateTone[latestSnapshot.overall_state] ?? 'state-pill'}>
|
||||||
|
{formatState(latestSnapshot.overall_state)}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if daysSinceSnapshot != null}
|
||||||
|
<p class="dashboard-panel-note">{dashboard_skinFreshness({ count: daysSinceSnapshot })}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="editorial-grid">
|
{#if latestSnapshot.active_concerns.length}
|
||||||
<section class="editorial-panel reveal-2">
|
<div class="concern-cloud" aria-label={skin_activeConcerns()}>
|
||||||
<header class="panel-header">
|
{#each latestSnapshot.active_concerns as concern (concern)}
|
||||||
<p class="panel-index">01</p>
|
<span class="concern-chip">{formatConcern(concern)}</span>
|
||||||
<h3>{m["dashboard_latestSnapshot"]()}</h3>
|
|
||||||
</header>
|
|
||||||
<div class="panel-action-row">
|
|
||||||
<a href={resolve('/skin')} class="panel-action-link">{m["skin_addNew"]()}</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if data.latestSnapshot}
|
|
||||||
{@const s = data.latestSnapshot}
|
|
||||||
<div class="snapshot-meta-row">
|
|
||||||
<span class="snapshot-date">{s.snapshot_date}</span>
|
|
||||||
{#if s.overall_state}
|
|
||||||
<span class={stateTone[s.overall_state] ?? 'state-pill'}>{humanize(s.overall_state)}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if s.active_concerns.length}
|
|
||||||
<div class="concern-cloud" aria-label={m["skin_activeConcerns"]()}>
|
|
||||||
{#each s.active_concerns as concern (concern)}
|
|
||||||
<span class="concern-chip">{humanize(concern)}</span>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if s.notes}
|
{#if snapshotMetrics.length}
|
||||||
<p class="snapshot-notes">{s.notes}</p>
|
<div class="dashboard-metric-row">
|
||||||
|
{#each snapshotMetrics as metric (metric.label)}
|
||||||
|
<div class="dashboard-metric-card">
|
||||||
|
<p class="dashboard-metric-label">{metric.label}</p>
|
||||||
|
<p class="dashboard-metric-value">{metric.value}</p>
|
||||||
|
{#if metric.trend}
|
||||||
|
<p class="dashboard-metric-trend">{dashboard_metricDelta({ delta: metric.trend })}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if latestSnapshot.notes}
|
||||||
|
<p class="snapshot-notes">{latestSnapshot.notes}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<p class="empty-copy">{m["dashboard_noSnapshots"]()}</p>
|
<p class="empty-copy">{dashboard_noSnapshots()}</p>
|
||||||
|
<div class="empty-actions">
|
||||||
|
<a href={resolve('/skin')} class="routine-summary-link">{skin_addNew()}</a>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="editorial-panel reveal-3">
|
<section class="editorial-panel reveal-3">
|
||||||
<header class="panel-header">
|
<header class="panel-header">
|
||||||
<p class="panel-index">02</p>
|
<p class="panel-index">03</p>
|
||||||
<h3>{m["dashboard_recentRoutines"]()}</h3>
|
<h3>{dashboard_recentRoutines()}</h3>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if data.recentRoutines.length}
|
{#if sortedRoutines.length}
|
||||||
<div class="routine-summary-strip">
|
<div class="routine-summary-strip">
|
||||||
<span class="routine-summary-chip">AM {routineStats.amCount}</span>
|
<span class="routine-summary-chip">AM {routineStats.amCount}</span>
|
||||||
<span class="routine-summary-chip">PM {routineStats.pmCount}</span>
|
<span class="routine-summary-chip">PM {routineStats.pmCount}</span>
|
||||||
<a href={resolve('/routines/new')} class="routine-summary-link">{m["routines_addNew"]()}</a>
|
<span class="routine-summary-chip">{dashboard_averageSteps({ count: routineStats.averageSteps })}</span>
|
||||||
|
<a href={resolve('/routines/new')} class="routine-summary-link">{routines_addNew()}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if latestRoutine}
|
||||||
|
<a href={resolve(`/routines/${latestRoutine.id}`)} class="dashboard-featured-routine">
|
||||||
|
<div class="dashboard-featured-routine-topline">
|
||||||
|
<p class="dashboard-featured-label">{dashboard_lastRoutine()}</p>
|
||||||
|
<span class={routineTone[latestRoutine.part_of_day] ?? 'routine-pill'}>
|
||||||
|
{latestRoutine.part_of_day.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="dashboard-featured-value">{latestRoutine.routine_date}</p>
|
||||||
|
<p class="dashboard-featured-meta">{latestRoutine.steps?.length ?? 0} {common_steps()}</p>
|
||||||
|
{#if latestRoutine.notes}
|
||||||
|
<p class="dashboard-featured-notes">{latestRoutine.notes}</p>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<ol class="routine-list">
|
<ol class="routine-list">
|
||||||
{#each data.recentRoutines as routine (routine.id)}
|
{#each sortedRoutines.slice(0, 5) as routine (routine.id)}
|
||||||
<li class="routine-item">
|
<li class="routine-item">
|
||||||
<a href={resolve(`/routines/${routine.id}`)} class="routine-link">
|
<a href={resolve(`/routines/${routine.id}`)} class="routine-link">
|
||||||
<div class="routine-main">
|
<div class="routine-main">
|
||||||
|
|
@ -116,9 +376,9 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="routine-meta">
|
<div class="routine-meta">
|
||||||
<span>{routine.steps?.length ?? 0} {m.common_steps()}</span>
|
<span>{routine.steps?.length ?? 0} {common_steps()}</span>
|
||||||
{#if routine.notes}
|
{#if routine.notes}
|
||||||
<span class="routine-note-inline">{m.routines_notes()}: {routine.notes}</span>
|
<span class="routine-note-inline">{routines_notes()}: {routine.notes}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -127,11 +387,49 @@
|
||||||
{/each}
|
{/each}
|
||||||
</ol>
|
</ol>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="empty-copy">{m["dashboard_noRoutines"]()}</p>
|
<p class="empty-copy">{dashboard_noRoutines()}</p>
|
||||||
<div class="empty-actions">
|
<div class="empty-actions">
|
||||||
<a href={resolve('/routines/new')} class="routine-summary-link">{m["routines_addNew"]()}</a>
|
<a href={resolve('/routines/new')} class="routine-summary-link">{routines_addNew()}</a>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="editorial-panel reveal-3">
|
||||||
|
<header class="panel-header">
|
||||||
|
<p class="panel-index">04</p>
|
||||||
|
<h3>{dashboard_healthPulse()}</h3>
|
||||||
|
</header>
|
||||||
|
<div class="panel-action-row">
|
||||||
|
<a href={resolve('/health/lab-results')} class="panel-action-link">{dashboard_viewLabResults()}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if sortedLabResults.length}
|
||||||
|
<div class="lab-results-meta-strip dashboard-health-meta-strip">
|
||||||
|
<span class="lab-results-meta-pill">{dashboard_lastLabDate()}: {latestLabDate}</span>
|
||||||
|
<span class="lab-results-meta-pill">{dashboard_flaggedLabsCount({ count: flaggedLabs.length })}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-health-list">
|
||||||
|
{#each featuredLabs as lab (lab.record_id)}
|
||||||
|
<a href={resolve('/health/lab-results')} class="dashboard-health-item">
|
||||||
|
<div>
|
||||||
|
<p class="dashboard-health-test">{formatLabLabel(lab)}</p>
|
||||||
|
<p class="dashboard-health-subline">{lab.collected_at.slice(0, 10)} - {lab.test_code}</p>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-health-value-wrap">
|
||||||
|
{#if lab.flag}
|
||||||
|
<span class={flaggedLabs.some((item) => item.record_id === lab.record_id) ? 'health-flag-pill health-flag-pill--abnormal' : 'health-flag-pill health-flag-pill--normal'}>
|
||||||
|
{lab.flag}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="dashboard-health-value">{formatLabValue(lab)}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="empty-copy">{dashboard_noLabResults()}</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { createLabResult, deleteLabResult, getLabResults, updateLabResult } from '$lib/api';
|
import { deleteLabResult, getLabResults, updateLabResult } from '$lib/api';
|
||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
|
@ -41,38 +41,6 @@ export const load: PageServerLoad = async ({ url }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
create: async ({ request }) => {
|
|
||||||
const form = await request.formData();
|
|
||||||
const collected_at = form.get('collected_at') as string;
|
|
||||||
const test_code = form.get('test_code') as string;
|
|
||||||
const test_name_original = form.get('test_name_original') as string;
|
|
||||||
const value_num = form.get('value_num') as string;
|
|
||||||
const unit_original = form.get('unit_original') as string;
|
|
||||||
const flag = form.get('flag') as string;
|
|
||||||
const lab = form.get('lab') as string;
|
|
||||||
|
|
||||||
if (!collected_at || !test_code) {
|
|
||||||
return fail(400, { error: 'Date and test code are required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body: Record<string, unknown> = {
|
|
||||||
collected_at,
|
|
||||||
test_code
|
|
||||||
};
|
|
||||||
if (test_name_original) body.test_name_original = test_name_original;
|
|
||||||
if (value_num) body.value_num = Number(value_num);
|
|
||||||
if (unit_original) body.unit_original = unit_original;
|
|
||||||
if (flag) body.flag = flag;
|
|
||||||
if (lab) body.lab = lab;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createLabResult(body);
|
|
||||||
return { created: true };
|
|
||||||
} catch (e) {
|
|
||||||
return fail(500, { error: (e as Error).message });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
update: async ({ request }) => {
|
update: async ({ request }) => {
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const id = form.get('id') as string;
|
const id = form.get('id') as string;
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import type { ActionData, PageData } from './$types';
|
import type { ActionData, PageData } from './$types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import FlashMessages from '$lib/components/FlashMessages.svelte';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { baseSelectClass, baseTextareaClass } from '$lib/components/forms/form-classes';
|
import { baseSelectClass, baseTextareaClass } from '$lib/components/forms/form-classes';
|
||||||
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
|
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
|
||||||
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
|
||||||
import { Pencil, X } from 'lucide-svelte';
|
import { Pencil, X } from 'lucide-svelte';
|
||||||
|
import { preventIfNotConfirmed } from '$lib/utils/forms';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -30,8 +32,6 @@
|
||||||
H: 'health-flag-pill health-flag-pill--high'
|
H: 'health-flag-pill health-flag-pill--high'
|
||||||
};
|
};
|
||||||
|
|
||||||
let showForm = $state(false);
|
|
||||||
let selectedFlag = $state('');
|
|
||||||
let editingId = $state<string | null>(null);
|
let editingId = $state<string | null>(null);
|
||||||
let editCollectedAt = $state('');
|
let editCollectedAt = $state('');
|
||||||
let editTestCode = $state('');
|
let editTestCode = $state('');
|
||||||
|
|
@ -185,21 +185,27 @@
|
||||||
return '—';
|
return '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
const flagOptions = flags.map((f) => ({ value: f, label: f }));
|
|
||||||
const textareaClass = `${baseTextareaClass} min-h-[5rem] resize-y`;
|
const textareaClass = `${baseTextareaClass} min-h-[5rem] resize-y`;
|
||||||
let filterFlagOverride = $state<string | null>(null);
|
let filterFlagOverride = $state<string | null>(null);
|
||||||
let filterLatestOnlyOverride = $state<'true' | 'false' | null>(null);
|
let filterLatestOnlyOverride = $state<'true' | 'false' | null>(null);
|
||||||
const activeFilterFlag = $derived(filterFlagOverride ?? (data.flag ?? ''));
|
const activeFilterFlag = $derived(filterFlagOverride ?? (data.flag ?? ''));
|
||||||
const activeLatestOnly = $derived(filterLatestOnlyOverride ?? (data.latestOnly ? 'true' : 'false'));
|
const activeLatestOnly = $derived(filterLatestOnlyOverride ?? (data.latestOnly ? 'true' : 'false'));
|
||||||
|
const flashMessages = $derived([
|
||||||
|
...(form?.error ? [{ kind: 'error' as const, text: form.error }] : []),
|
||||||
|
...(form?.deleted ? [{ kind: 'success' as const, text: m["labResults_deleted"]() }] : []),
|
||||||
|
...(form?.updated ? [{ kind: 'success' as const, text: m["labResults_updated"]() }] : [])
|
||||||
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{m["labResults_title"]()} — innercontext</title></svelte:head>
|
<svelte:head><title>{m["labResults_title"]()} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
<div class="editorial-page space-y-4">
|
<div class="editorial-page space-y-4">
|
||||||
<section class="editorial-hero reveal-1">
|
<PageHeader
|
||||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
title={m["labResults_title"]()}
|
||||||
<h2 class="editorial-title">{m["labResults_title"]()}</h2>
|
kicker={m["nav_appSubtitle"]()}
|
||||||
<p class="editorial-subtitle">{m["labResults_count"]({ count: data.resultPage.total })}</p>
|
subtitle={m["labResults_count"]({ count: data.resultPage.total })}
|
||||||
|
>
|
||||||
|
{#snippet meta()}
|
||||||
<div class="lab-results-meta-strip">
|
<div class="lab-results-meta-strip">
|
||||||
<span class="lab-results-meta-pill">
|
<span class="lab-results-meta-pill">
|
||||||
{m['labResults_view']()}: {data.latestOnly ? m['labResults_viewLatest']() : m['labResults_viewAll']()}
|
{m['labResults_view']()}: {data.latestOnly ? m['labResults_viewLatest']() : m['labResults_viewAll']()}
|
||||||
|
|
@ -214,25 +220,14 @@
|
||||||
<span class="lab-results-meta-pill lab-results-meta-pill--alert">{data.test_code}</span>
|
<span class="lab-results-meta-pill lab-results-meta-pill--alert">{data.test_code}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="editorial-toolbar">
|
{/snippet}
|
||||||
<Button variant="outline" onclick={() => (showForm = !showForm)}>
|
|
||||||
{showForm ? m.common_cancel() : m["labResults_addNew"]()}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{#if form?.error}
|
{#snippet actions()}
|
||||||
<div class="editorial-alert editorial-alert--error">{form.error}</div>
|
<Button variant="outline" href="/health/lab-results/new">{m["labResults_addNew"]()}</Button>
|
||||||
{/if}
|
{/snippet}
|
||||||
{#if form?.created}
|
</PageHeader>
|
||||||
<div class="editorial-alert editorial-alert--success">{m["labResults_added"]()}</div>
|
|
||||||
{/if}
|
<FlashMessages messages={flashMessages} />
|
||||||
{#if form?.deleted}
|
|
||||||
<div class="editorial-alert editorial-alert--success">{m["labResults_deleted"]()}</div>
|
|
||||||
{/if}
|
|
||||||
{#if form?.updated}
|
|
||||||
<div class="editorial-alert editorial-alert--success">{m["labResults_updated"]()}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if editingId}
|
{#if editingId}
|
||||||
<FormSectionCard title={m["labResults_editTitle"]()} className="reveal-2">
|
<FormSectionCard title={m["labResults_editTitle"]()} className="reveal-2">
|
||||||
|
|
@ -457,56 +452,6 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showForm}
|
|
||||||
<FormSectionCard title={m["labResults_newTitle"]()} className="reveal-2">
|
|
||||||
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label for="collected_at">{m["labResults_date"]()}</Label>
|
|
||||||
<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"
|
|
||||||
>({m["labResults_loincExample"]()})</span
|
|
||||||
></Label
|
|
||||||
>
|
|
||||||
<Input id="test_code" name="test_code" required placeholder="718-7" />
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label for="test_name_original">{m["labResults_testName"]()}</Label>
|
|
||||||
<Input
|
|
||||||
id="test_name_original"
|
|
||||||
name="test_name_original"
|
|
||||||
placeholder={m["labResults_testNamePlaceholder"]()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label for="lab_create">{m["labResults_lab"]()}</Label>
|
|
||||||
<Input id="lab_create" name="lab" placeholder={m["labResults_labPlaceholder"]()} />
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label for="value_num">{m["labResults_value"]()}</Label>
|
|
||||||
<Input id="value_num" name="value_num" type="number" step="any" />
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label for="unit_original">{m["labResults_unit"]()}</Label>
|
|
||||||
<Input id="unit_original" name="unit_original" placeholder={m["labResults_unitPlaceholder"]()} />
|
|
||||||
</div>
|
|
||||||
<SimpleSelect
|
|
||||||
id="flag_create"
|
|
||||||
name="flag"
|
|
||||||
label={m["labResults_flag"]()}
|
|
||||||
options={flagOptions}
|
|
||||||
placeholder={m["labResults_flagNone"]()}
|
|
||||||
bind:value={selectedFlag}
|
|
||||||
/>
|
|
||||||
<div class="flex items-end">
|
|
||||||
<Button type="submit">{m.common_add()}</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</FormSectionCard>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if data.totalPages > 1}
|
{#if data.totalPages > 1}
|
||||||
<div class="editorial-panel lab-results-pager reveal-2 flex items-center justify-between gap-3">
|
<div class="editorial-panel lab-results-pager reveal-2 flex items-center justify-between gap-3">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -545,9 +490,7 @@
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/delete"
|
action="?/delete"
|
||||||
use:enhance
|
use:enhance
|
||||||
onsubmit={(event) => {
|
onsubmit={(event) => preventIfNotConfirmed(event, m['labResults_confirmDelete']())}
|
||||||
if (!confirm(m['labResults_confirmDelete']())) event.preventDefault();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={r.record_id} />
|
<input type="hidden" name="id" value={r.record_id} />
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -573,9 +516,7 @@
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/delete"
|
action="?/delete"
|
||||||
use:enhance
|
use:enhance
|
||||||
onsubmit={(event) => {
|
onsubmit={(event) => preventIfNotConfirmed(event, m['labResults_confirmDelete']())}
|
||||||
if (!confirm(m['labResults_confirmDelete']())) event.preventDefault();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={r.record_id} />
|
<input type="hidden" name="id" value={r.record_id} />
|
||||||
<Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive">
|
<Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive">
|
||||||
|
|
@ -615,7 +556,7 @@
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet mobileCard(r: LabResultItem)}
|
{#snippet mobileCard(r: LabResultItem)}
|
||||||
<div class="products-mobile-card lab-results-mobile-card flex flex-col gap-1">
|
<div class="editorial-mobile-card lab-results-mobile-card flex flex-col gap-1">
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<div class="flex min-w-0 flex-col gap-1">
|
<div class="flex min-w-0 flex-col gap-1">
|
||||||
<button
|
<button
|
||||||
|
|
@ -649,7 +590,7 @@
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<!-- Desktop: table -->
|
<!-- Desktop: table -->
|
||||||
<div class="products-table-shell lab-results-table hidden md:block reveal-2">
|
<div class="editorial-table-shell lab-results-table hidden md:block reveal-2">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
|
@ -667,7 +608,7 @@
|
||||||
{#if group.label}
|
{#if group.label}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colspan={7} class="bg-muted/35 py-2">
|
<TableCell colspan={7} class="bg-muted/35 py-2">
|
||||||
<div class="products-section-title text-xs uppercase tracking-[0.12em]">
|
<div class="editorial-section-title text-xs uppercase tracking-[0.12em]">
|
||||||
{group.label}
|
{group.label}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -692,7 +633,7 @@
|
||||||
<div class="lab-results-mobile-grid flex flex-col gap-3 md:hidden reveal-3">
|
<div class="lab-results-mobile-grid flex flex-col gap-3 md:hidden reveal-3">
|
||||||
{#each displayGroups as group (group.key)}
|
{#each displayGroups as group (group.key)}
|
||||||
{#if group.label}
|
{#if group.label}
|
||||||
<div class="products-section-title text-xs uppercase tracking-[0.12em]">{group.label}</div>
|
<div class="editorial-section-title text-xs uppercase tracking-[0.12em]">{group.label}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#each group.items as r (r.record_id)}
|
{#each group.items as r (r.record_id)}
|
||||||
{@render mobileCard(r)}
|
{@render mobileCard(r)}
|
||||||
|
|
|
||||||
42
frontend/src/routes/health/lab-results/new/+page.server.ts
Normal file
42
frontend/src/routes/health/lab-results/new/+page.server.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { createLabResult } from '$lib/api';
|
||||||
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async ({ request }) => {
|
||||||
|
const form = await request.formData();
|
||||||
|
const collected_at = form.get('collected_at') as string;
|
||||||
|
const test_code = form.get('test_code') as string;
|
||||||
|
const test_name_original = form.get('test_name_original') as string;
|
||||||
|
const value_num = form.get('value_num') as string;
|
||||||
|
const unit_original = form.get('unit_original') as string;
|
||||||
|
const flag = form.get('flag') as string;
|
||||||
|
const lab = form.get('lab') as string;
|
||||||
|
|
||||||
|
if (!collected_at || !test_code) {
|
||||||
|
return fail(400, { error: 'Date and test code are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
collected_at,
|
||||||
|
test_code
|
||||||
|
};
|
||||||
|
if (test_name_original) body.test_name_original = test_name_original;
|
||||||
|
if (value_num) body.value_num = Number(value_num);
|
||||||
|
if (unit_original) body.unit_original = unit_original;
|
||||||
|
if (flag) body.flag = flag;
|
||||||
|
if (lab) body.lab = lab;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createLabResult(body);
|
||||||
|
} catch (error) {
|
||||||
|
return fail(500, { error: (error as Error).message });
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(303, '/health/lab-results');
|
||||||
|
}
|
||||||
|
};
|
||||||
68
frontend/src/routes/health/lab-results/new/+page.svelte
Normal file
68
frontend/src/routes/health/lab-results/new/+page.svelte
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import FlashMessages from '$lib/components/FlashMessages.svelte';
|
||||||
|
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
|
||||||
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
|
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import type { ActionData } from './$types';
|
||||||
|
|
||||||
|
let { form }: { form: ActionData } = $props();
|
||||||
|
let selectedFlag = $state('');
|
||||||
|
|
||||||
|
const flags = ['N', 'ABN', 'POS', 'NEG', 'L', 'H'];
|
||||||
|
const flagOptions = flags.map((flag) => ({ value: flag, label: flag }));
|
||||||
|
const flashMessages = $derived(form?.error ? [{ kind: 'error' as const, text: form.error }] : []);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>{m['labResults_newTitle']()} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="editorial-page space-y-4">
|
||||||
|
<PageHeader
|
||||||
|
title={m['labResults_newTitle']()}
|
||||||
|
kicker={m['nav_appSubtitle']()}
|
||||||
|
backHref={resolve('/health/lab-results')}
|
||||||
|
backLabel={m['labResults_title']()}
|
||||||
|
subtitle={m['labResults_newSubtitle']()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FlashMessages messages={flashMessages} />
|
||||||
|
|
||||||
|
<FormSectionCard title={m['labResults_newTitle']()} className="reveal-2">
|
||||||
|
<form method="POST" class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<p class="sm:col-span-2 text-sm leading-6 text-muted-foreground">{m['labResults_newSectionIntro']()}</p>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="collected_at">{m['labResults_date']()}</Label>
|
||||||
|
<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">({m['labResults_loincExample']()})</span></Label>
|
||||||
|
<Input id="test_code" name="test_code" required placeholder="718-7" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="test_name_original">{m['labResults_testName']()}</Label>
|
||||||
|
<Input id="test_name_original" name="test_name_original" placeholder={m['labResults_testNamePlaceholder']()} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="lab">{m['labResults_lab']()}</Label>
|
||||||
|
<Input id="lab" name="lab" placeholder={m['labResults_labPlaceholder']()} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="value_num">{m['labResults_value']()}</Label>
|
||||||
|
<Input id="value_num" name="value_num" type="number" step="any" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="unit_original">{m['labResults_unit']()}</Label>
|
||||||
|
<Input id="unit_original" name="unit_original" placeholder={m['labResults_unitPlaceholder']()} />
|
||||||
|
</div>
|
||||||
|
<SimpleSelect id="flag" name="flag" label={m['labResults_flag']()} options={flagOptions} placeholder={m['labResults_flagNone']()} bind:value={selectedFlag} />
|
||||||
|
<div class="flex items-center justify-end gap-2 sm:col-span-2">
|
||||||
|
<Button type="button" variant="outline" href={resolve('/health/lab-results')}>{m.common_cancel()}</Button>
|
||||||
|
<Button type="submit">{m.common_add()}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</FormSectionCard>
|
||||||
|
</div>
|
||||||
|
|
@ -1,35 +1,8 @@
|
||||||
import { createMedication, getMedications } from '$lib/api';
|
import { getMedications } from '$lib/api';
|
||||||
import { fail } from '@sveltejs/kit';
|
import type { PageServerLoad } from './$types';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url }) => {
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
const kind = url.searchParams.get('kind') ?? undefined;
|
const kind = url.searchParams.get('kind') ?? undefined;
|
||||||
const medications = await getMedications({ kind });
|
const medications = await getMedications({ kind });
|
||||||
return { medications, kind };
|
return { medications, kind };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
|
||||||
create: async ({ request }) => {
|
|
||||||
const form = await request.formData();
|
|
||||||
const kind = form.get('kind') as string;
|
|
||||||
const product_name = form.get('product_name') as string;
|
|
||||||
const active_substance = form.get('active_substance') as string;
|
|
||||||
const notes = form.get('notes') as string;
|
|
||||||
|
|
||||||
if (!kind || !product_name) {
|
|
||||||
return fail(400, { error: 'Kind and product name are required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createMedication({
|
|
||||||
kind,
|
|
||||||
product_name,
|
|
||||||
active_substance: active_substance || undefined,
|
|
||||||
notes: notes || undefined
|
|
||||||
});
|
|
||||||
return { created: true };
|
|
||||||
} catch (e) {
|
|
||||||
return fail(500, { error: (e as Error).message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import type { PageData } from './$types';
|
||||||
import type { ActionData, PageData } from './$types';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Input } from '$lib/components/ui/input';
|
|
||||||
import { Label } from '$lib/components/ui/label';
|
|
||||||
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
|
|
||||||
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
|
||||||
|
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
const kinds = ['prescription', 'otc', 'supplement', 'herbal', 'other'];
|
|
||||||
let showForm = $state(false);
|
|
||||||
let kind = $state('supplement');
|
|
||||||
|
|
||||||
const kindPills: Record<string, string> = {
|
const kindPills: Record<string, string> = {
|
||||||
prescription: 'health-kind-pill health-kind-pill--prescription',
|
prescription: 'health-kind-pill health-kind-pill--prescription',
|
||||||
|
|
@ -31,7 +22,6 @@
|
||||||
other: m["medications_kindOther"]
|
other: m["medications_kindOther"]
|
||||||
};
|
};
|
||||||
|
|
||||||
const kindOptions = $derived(kinds.map((k) => ({ value: k, label: kindLabels[k]?.() ?? k })));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{m.medications_title()} — innercontext</title></svelte:head>
|
<svelte:head><title>{m.medications_title()} — innercontext</title></svelte:head>
|
||||||
|
|
@ -39,53 +29,13 @@
|
||||||
<div class="editorial-page space-y-4">
|
<div class="editorial-page space-y-4">
|
||||||
<section class="editorial-hero reveal-1">
|
<section class="editorial-hero reveal-1">
|
||||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||||
<h2 class="editorial-title">{m.medications_title()}</h2>
|
<h1 class="editorial-title">{m.medications_title()}</h1>
|
||||||
<p class="editorial-subtitle">{m.medications_count({ count: data.medications.length })}</p>
|
<p class="editorial-subtitle">{m.medications_count({ count: data.medications.length })}</p>
|
||||||
<div class="editorial-toolbar">
|
<div class="editorial-toolbar">
|
||||||
<Button variant="outline" onclick={() => (showForm = !showForm)}>
|
<Button variant="outline" href="/health/medications/new">{m["medications_addNew"]()}</Button>
|
||||||
{showForm ? m.common_cancel() : m["medications_addNew"]()}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if form?.error}
|
|
||||||
<div class="editorial-alert editorial-alert--error">{form.error}</div>
|
|
||||||
{/if}
|
|
||||||
{#if form?.created}
|
|
||||||
<div class="editorial-alert editorial-alert--success">{m.medications_added()}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showForm}
|
|
||||||
<FormSectionCard title={m["medications_newTitle"]()} className="reveal-2">
|
|
||||||
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div class="col-span-2">
|
|
||||||
<SimpleSelect
|
|
||||||
id="kind"
|
|
||||||
name="kind"
|
|
||||||
label={m.medications_kind()}
|
|
||||||
options={kindOptions}
|
|
||||||
bind:value={kind}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label for="product_name">{m["medications_productName"]()}</Label>
|
|
||||||
<Input id="product_name" name="product_name" required placeholder={m["medications_productNamePlaceholder"]()} />
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label for="active_substance">{m["medications_activeSubstance"]()}</Label>
|
|
||||||
<Input id="active_substance" name="active_substance" placeholder={m["medications_activeSubstancePlaceholder"]()} />
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1 col-span-2">
|
|
||||||
<Label for="notes">{m.medications_notes()}</Label>
|
|
||||||
<Input id="notes" name="notes" />
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2">
|
|
||||||
<Button type="submit">{m.common_add()}</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</FormSectionCard>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="editorial-panel reveal-2 space-y-3">
|
<div class="editorial-panel reveal-2 space-y-3">
|
||||||
{#each data.medications as med (med.record_id)}
|
{#each data.medications as med (med.record_id)}
|
||||||
<div class="health-entry-row">
|
<div class="health-entry-row">
|
||||||
|
|
|
||||||
34
frontend/src/routes/health/medications/new/+page.server.ts
Normal file
34
frontend/src/routes/health/medications/new/+page.server.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { createMedication } from '$lib/api';
|
||||||
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async ({ request }) => {
|
||||||
|
const form = await request.formData();
|
||||||
|
const kind = form.get('kind') as string;
|
||||||
|
const product_name = form.get('product_name') as string;
|
||||||
|
const active_substance = form.get('active_substance') as string;
|
||||||
|
const notes = form.get('notes') as string;
|
||||||
|
|
||||||
|
if (!kind || !product_name) {
|
||||||
|
return fail(400, { error: 'Kind and product name are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createMedication({
|
||||||
|
kind,
|
||||||
|
product_name,
|
||||||
|
active_substance: active_substance || undefined,
|
||||||
|
notes: notes || undefined
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return fail(500, { error: (error as Error).message });
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(303, '/health/medications');
|
||||||
|
}
|
||||||
|
};
|
||||||
65
frontend/src/routes/health/medications/new/+page.svelte
Normal file
65
frontend/src/routes/health/medications/new/+page.svelte
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import FlashMessages from '$lib/components/FlashMessages.svelte';
|
||||||
|
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
|
||||||
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
|
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import type { ActionData } from './$types';
|
||||||
|
|
||||||
|
let { form }: { form: ActionData } = $props();
|
||||||
|
let kind = $state('supplement');
|
||||||
|
|
||||||
|
const kinds = ['prescription', 'otc', 'supplement', 'herbal', 'other'];
|
||||||
|
const kindLabels: Record<string, () => string> = {
|
||||||
|
prescription: m['medications_kindPrescription'],
|
||||||
|
otc: m['medications_kindOtc'],
|
||||||
|
supplement: m['medications_kindSupplement'],
|
||||||
|
herbal: m['medications_kindHerbal'],
|
||||||
|
other: m['medications_kindOther']
|
||||||
|
};
|
||||||
|
const kindOptions = kinds.map((entry) => ({ value: entry, label: kindLabels[entry]?.() ?? entry }));
|
||||||
|
const flashMessages = $derived(form?.error ? [{ kind: 'error' as const, text: form.error }] : []);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>{m['medications_newTitle']()} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="editorial-page space-y-4">
|
||||||
|
<PageHeader
|
||||||
|
title={m['medications_newTitle']()}
|
||||||
|
kicker={m['nav_appSubtitle']()}
|
||||||
|
backHref={resolve('/health/medications')}
|
||||||
|
backLabel={m.medications_title()}
|
||||||
|
subtitle={m['medications_newSubtitle']()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FlashMessages messages={flashMessages} />
|
||||||
|
|
||||||
|
<FormSectionCard title={m['medications_newTitle']()} className="reveal-2">
|
||||||
|
<form method="POST" class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<p class="sm:col-span-2 text-sm leading-6 text-muted-foreground">{m['medications_newSectionIntro']()}</p>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<SimpleSelect id="kind" name="kind" label={m.medications_kind()} options={kindOptions} bind:value={kind} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="product_name">{m['medications_productName']()}</Label>
|
||||||
|
<Input id="product_name" name="product_name" required placeholder={m['medications_productNamePlaceholder']()} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="active_substance">{m['medications_activeSubstance']()}</Label>
|
||||||
|
<Input id="active_substance" name="active_substance" placeholder={m['medications_activeSubstancePlaceholder']()} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 sm:col-span-2">
|
||||||
|
<Label for="notes">{m.medications_notes()}</Label>
|
||||||
|
<Input id="notes" name="notes" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end gap-2 sm:col-span-2">
|
||||||
|
<Button type="button" variant="outline" href={resolve('/health/medications')}>{m.common_cancel()}</Button>
|
||||||
|
<Button type="submit">{m.common_add()}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</FormSectionCard>
|
||||||
|
</div>
|
||||||
|
|
@ -2,6 +2,13 @@ import { getProductSummaries } from '$lib/api';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
|
try {
|
||||||
const products = await getProductSummaries();
|
const products = await getProductSummaries();
|
||||||
return { products };
|
return { products, loadError: null };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
products: [],
|
||||||
|
loadError: error instanceof Error ? error.message : 'Failed to load products'
|
||||||
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
import type { ProductSummary } from '$lib/types';
|
import type { ProductSummary } from '$lib/types';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
|
@ -17,7 +18,7 @@
|
||||||
TableRow
|
TableRow
|
||||||
} from '$lib/components/ui/table';
|
} from '$lib/components/ui/table';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData & { loadError: string | null } } = $props();
|
||||||
|
|
||||||
type OwnershipFilter = 'all' | 'owned' | 'unowned';
|
type OwnershipFilter = 'all' | 'owned' | 'unowned';
|
||||||
type SortKey = 'brand' | 'name' | 'time' | 'price';
|
type SortKey = 'brand' | 'name' | 'time' | 'price';
|
||||||
|
|
@ -106,6 +107,7 @@
|
||||||
})());
|
})());
|
||||||
|
|
||||||
const totalCount = $derived(groupedProducts.reduce((s, [, arr]) => s + arr.length, 0));
|
const totalCount = $derived(groupedProducts.reduce((s, [, arr]) => s + arr.length, 0));
|
||||||
|
const ownedCount = $derived(data.products.filter((product) => product.is_owned).length);
|
||||||
|
|
||||||
function formatPricePerUse(value?: number): string {
|
function formatPricePerUse(value?: number): string {
|
||||||
if (value == null) return '-';
|
if (value == null) return '-';
|
||||||
|
|
@ -134,15 +136,35 @@
|
||||||
<svelte:head><title>{m.products_title()} — innercontext</title></svelte:head>
|
<svelte:head><title>{m.products_title()} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
<div class="editorial-page space-y-4">
|
<div class="editorial-page space-y-4">
|
||||||
<section class="editorial-hero reveal-1">
|
<PageHeader
|
||||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
title={m.products_title()}
|
||||||
<h2 class="editorial-title">{m.products_title()}</h2>
|
kicker={m["nav_appSubtitle"]()}
|
||||||
<p class="editorial-subtitle">{m.products_count({ count: totalCount })}</p>
|
subtitle={m.products_count({ count: totalCount })}
|
||||||
<div class="editorial-toolbar">
|
>
|
||||||
|
{#snippet actions()}
|
||||||
<Button href={resolve('/products/suggest')} variant="outline"><Sparkles class="size-4" /> {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>
|
<Button href={resolve('/products/new')}>{m["products_addNew"]()}</Button>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="hero-strip" aria-label={m.products_title()}>
|
||||||
|
<div>
|
||||||
|
<p class="hero-strip-label">{m["products_filterOwned"]()}</p>
|
||||||
|
<p class="hero-strip-value">{ownedCount}</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div>
|
||||||
|
<p class="hero-strip-label">{m["products_filterAll"]()}</p>
|
||||||
|
<p class="hero-strip-value">{data.products.length}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="hero-strip-label">{m.products_count({ count: totalCount })}</p>
|
||||||
|
<p class="hero-strip-value">{totalCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{#if data.loadError}
|
||||||
|
<div class="editorial-alert editorial-alert--warning">{data.loadError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="editorial-panel reveal-2">
|
<div class="editorial-panel reveal-2">
|
||||||
<div class="editorial-filter-row">
|
<div class="editorial-filter-row">
|
||||||
|
|
@ -187,7 +209,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Desktop: table -->
|
<!-- Desktop: table -->
|
||||||
<div class="products-table-shell hidden md:block reveal-2">
|
<div class="editorial-table-shell hidden md:block reveal-2">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
|
@ -251,13 +273,13 @@
|
||||||
<p class="py-8 text-center text-sm text-muted-foreground">{m["products_noProducts"]()}</p>
|
<p class="py-8 text-center text-sm text-muted-foreground">{m["products_noProducts"]()}</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#each groupedProducts as [category, products] (category)}
|
{#each groupedProducts as [category, products] (category)}
|
||||||
<div class="products-section-title">
|
<div class="editorial-section-title">
|
||||||
{formatCategory(category)}
|
{formatCategory(category)}
|
||||||
</div>
|
</div>
|
||||||
{#each products as product (product.id)}
|
{#each products as product (product.id)}
|
||||||
<a
|
<a
|
||||||
href={resolve(`/products/${product.id}`)}
|
href={resolve(`/products/${product.id}`)}
|
||||||
class={`products-mobile-card ${isOwned(product) ? 'hover:bg-muted/50' : 'bg-muted/20 text-muted-foreground hover:bg-muted/30'}`}
|
class={`editorial-mobile-card ${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 class="flex items-start justify-between gap-2">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,19 @@
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import type { ActionData, PageData } from './$types';
|
import type { ActionData, PageData } from './$types';
|
||||||
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import FlashMessages from '$lib/components/FlashMessages.svelte';
|
||||||
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
|
||||||
import { Save, Trash2, Boxes, Pencil, X, ArrowLeft, Sparkles } from 'lucide-svelte';
|
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
|
||||||
|
import { Save, Trash2, Boxes, Pencil, X, Sparkles } from 'lucide-svelte';
|
||||||
import ProductForm from '$lib/components/ProductForm.svelte';
|
import ProductForm from '$lib/components/ProductForm.svelte';
|
||||||
|
import { preventIfNotConfirmed } from '$lib/utils/forms';
|
||||||
|
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
let { product } = $derived(data);
|
let { product } = $derived(data);
|
||||||
|
|
@ -77,41 +81,31 @@
|
||||||
if (value === 'nearly_empty') return m['inventory_remainingNearlyEmpty']();
|
if (value === 'nearly_empty') return m['inventory_remainingNearlyEmpty']();
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canSaveEdit = $derived(activeTab === 'edit' && isEditDirty);
|
||||||
|
const pageFlashMessages = $derived([
|
||||||
|
...(form?.error ? [{ kind: 'error' as const, text: form.error }] : []),
|
||||||
|
...(form?.success ? [{ kind: 'success' as const, text: m.common_saved() }] : [])
|
||||||
|
]);
|
||||||
|
const inventoryFlashMessages = $derived([
|
||||||
|
...(form?.inventoryAdded ? [{ kind: 'success' as const, text: m["inventory_packageAdded"]() }] : []),
|
||||||
|
...(form?.inventoryUpdated ? [{ kind: 'success' as const, text: m["inventory_packageUpdated"]() }] : []),
|
||||||
|
...(form?.inventoryDeleted ? [{ kind: 'success' as const, text: m["inventory_packageDeleted"]() }] : [])
|
||||||
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{product.name} — innercontext</title></svelte:head>
|
<svelte:head><title>{product.name} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
<div class="products-sticky-actions 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">
|
<div class="editorial-page space-y-4">
|
||||||
<Button
|
<PageHeader
|
||||||
type="submit"
|
title={product.name}
|
||||||
form="product-edit-form"
|
kicker={m["nav_appSubtitle"]()}
|
||||||
disabled={activeTab !== 'edit' || !isEditDirty}
|
backHref={resolve('/products')}
|
||||||
size="sm"
|
backLabel={m["products_backToList"]()}
|
||||||
|
titleClassName="break-words editorial-title"
|
||||||
>
|
>
|
||||||
<Save class="size-4" aria-hidden="true" />
|
{#snippet meta()}
|
||||||
<span class="sr-only md:not-sr-only">{m["products_saveChanges"]()}</span>
|
<div class="editorial-meta-strip">
|
||||||
</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="editorial-page space-y-4 pb-20 md:pb-0">
|
|
||||||
<section class="editorial-hero reveal-1 space-y-3">
|
|
||||||
<a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
|
|
||||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
|
||||||
<h2 class="break-words editorial-title">{product.name}</h2>
|
|
||||||
<div class="products-meta-strip">
|
|
||||||
<span class="font-medium text-foreground">{product.brand}</span>
|
<span class="font-medium text-foreground">{product.brand}</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span class="uppercase">{product.recommended_time}</span>
|
<span class="uppercase">{product.recommended_time}</span>
|
||||||
|
|
@ -119,14 +113,34 @@
|
||||||
<span>{product.category.replace(/_/g, ' ')}</span>
|
<span>{product.category.replace(/_/g, ' ')}</span>
|
||||||
<Badge variant="outline" class="ml-1">ID: {product.id.slice(0, 8)}</Badge>
|
<Badge variant="outline" class="ml-1">ID: {product.id.slice(0, 8)}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
{/snippet}
|
||||||
|
|
||||||
{#if form?.error}
|
{#snippet actions()}
|
||||||
<div class="editorial-alert editorial-alert--error">{form.error}</div>
|
{#if activeTab === 'edit'}
|
||||||
{/if}
|
<Button type="submit" form="product-edit-form" disabled={!canSaveEdit} size="sm">
|
||||||
{#if form?.success}
|
<Save class="size-4" aria-hidden="true" />
|
||||||
<div class="editorial-alert editorial-alert--success">{m.common_saved()}</div>
|
{m["products_saveChanges"]()}
|
||||||
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="hero-strip" aria-label={product.name}>
|
||||||
|
<div>
|
||||||
|
<p class="hero-strip-label">{m.inventory_title({ count: product.inventory.length })}</p>
|
||||||
|
<p class="hero-strip-value">{product.inventory.length}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="hero-strip-label">{m["productForm_price"]()}</p>
|
||||||
|
<p class="hero-strip-value">{formatAmount(getPriceAmount(), getPriceCurrency())}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="hero-strip-label">{m["products_colPricePerUse"]()}</p>
|
||||||
|
<p class="hero-strip-value">{formatPricePerUse(getPricePerUse())}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<FlashMessages messages={pageFlashMessages} />
|
||||||
|
|
||||||
<Tabs bind:value={activeTab} class="products-tabs space-y-2 reveal-2">
|
<Tabs bind:value={activeTab} class="products-tabs space-y-2 reveal-2">
|
||||||
<TabsList class="h-auto w-full justify-start gap-1 overflow-x-auto p-1 whitespace-nowrap">
|
<TabsList class="h-auto w-full justify-start gap-1 overflow-x-auto p-1 whitespace-nowrap">
|
||||||
|
|
@ -149,15 +163,7 @@
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if form?.inventoryAdded}
|
<FlashMessages messages={inventoryFlashMessages} />
|
||||||
<div class="editorial-alert editorial-alert--success">{m["inventory_packageAdded"]()}</div>
|
|
||||||
{/if}
|
|
||||||
{#if form?.inventoryUpdated}
|
|
||||||
<div class="editorial-alert editorial-alert--success">{m["inventory_packageUpdated"]()}</div>
|
|
||||||
{/if}
|
|
||||||
{#if form?.inventoryDeleted}
|
|
||||||
<div class="editorial-alert editorial-alert--success">{m["inventory_packageDeleted"]()}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showInventoryForm}
|
{#if showInventoryForm}
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -196,7 +202,8 @@
|
||||||
<Label for="add_notes">{m.inventory_notes()}</Label>
|
<Label for="add_notes">{m.inventory_notes()}</Label>
|
||||||
<Input id="add_notes" name="notes" />
|
<Input id="add_notes" name="notes" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-end">
|
<div class="flex items-end gap-2 sm:col-span-2 sm:justify-end">
|
||||||
|
<Button type="button" variant="outline" size="sm" onclick={() => (showInventoryForm = false)}>{m.common_cancel()}</Button>
|
||||||
<Button type="submit" size="sm">{m.common_add()}</Button>
|
<Button type="submit" size="sm">{m.common_add()}</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -251,7 +258,7 @@
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/deleteInventory"
|
action="?/deleteInventory"
|
||||||
use:enhance
|
use:enhance
|
||||||
onsubmit={(e) => { if (!confirm(m["inventory_confirmDelete"]())) e.preventDefault(); }}
|
onsubmit={(e) => preventIfNotConfirmed(e, m["inventory_confirmDelete"]())}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="inventory_id" value={pkg.id} />
|
<input type="hidden" name="inventory_id" value={pkg.id} />
|
||||||
<Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive"><X class="size-4" /></Button>
|
<Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive"><X class="size-4" /></Button>
|
||||||
|
|
@ -325,7 +332,9 @@
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
<div class="editorial-panel">
|
||||||
<p class="text-sm text-muted-foreground">{m["products_noInventory"]()}</p>
|
<p class="text-sm text-muted-foreground">{m["products_noInventory"]()}</p>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
@ -370,6 +379,21 @@
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<FormSectionCard title={m["products_deleteProduct"]()} contentClassName="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p class="text-sm text-muted-foreground">{m["products_confirmDelete"]()}</p>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/delete"
|
||||||
|
use:enhance
|
||||||
|
onsubmit={(e) => preventIfNotConfirmed(e, m["products_confirmDelete"]())}
|
||||||
|
>
|
||||||
|
<Button type="submit" variant="destructive" size="sm">
|
||||||
|
<Trash2 class="size-4" aria-hidden="true" />
|
||||||
|
{m["products_deleteProduct"]()}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</FormSectionCard>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import type { ActionData } from './$types';
|
import type { ActionData } from './$types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { ArrowLeft } from 'lucide-svelte';
|
|
||||||
import ProductForm from '$lib/components/ProductForm.svelte';
|
import ProductForm from '$lib/components/ProductForm.svelte';
|
||||||
|
|
||||||
let { form }: { form: ActionData } = $props();
|
let { form }: { form: ActionData } = $props();
|
||||||
|
|
@ -21,11 +21,12 @@
|
||||||
<svelte:head><title>{m["products_newTitle"]()} — innercontext</title></svelte:head>
|
<svelte:head><title>{m["products_newTitle"]()} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
<div class="editorial-page space-y-4">
|
<div class="editorial-page space-y-4">
|
||||||
<section class="editorial-hero reveal-1 space-y-3">
|
<PageHeader
|
||||||
<a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
|
title={m["products_newTitle"]()}
|
||||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
kicker={m["nav_appSubtitle"]()}
|
||||||
<h2 class="editorial-title">{m["products_newTitle"]()}</h2>
|
backHref={resolve('/products')}
|
||||||
</section>
|
backLabel={m["products_backToList"]()}
|
||||||
|
/>
|
||||||
|
|
||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
<div bind:this={errorEl} class="editorial-alert editorial-alert--error">
|
<div bind:this={errorEl} class="editorial-alert editorial-alert--error">
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import type { ProductSuggestion, ResponseMetadata, ShoppingPriority } from '$lib/types';
|
import type { ProductSuggestion, ResponseMetadata, ShoppingPriority } from '$lib/types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
import { Sparkles, ArrowLeft } from 'lucide-svelte';
|
import { Sparkles } from 'lucide-svelte';
|
||||||
import ValidationWarningsAlert from '$lib/components/ValidationWarningsAlert.svelte';
|
import ValidationWarningsAlert from '$lib/components/ValidationWarningsAlert.svelte';
|
||||||
import StructuredErrorDisplay from '$lib/components/StructuredErrorDisplay.svelte';
|
import StructuredErrorDisplay from '$lib/components/StructuredErrorDisplay.svelte';
|
||||||
import AutoFixBadge from '$lib/components/AutoFixBadge.svelte';
|
import AutoFixBadge from '$lib/components/AutoFixBadge.svelte';
|
||||||
|
|
@ -68,12 +69,13 @@
|
||||||
<svelte:head><title>{m["products_suggestTitle"]()} — innercontext</title></svelte:head>
|
<svelte:head><title>{m["products_suggestTitle"]()} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
<div class="editorial-page space-y-4">
|
<div class="editorial-page space-y-4">
|
||||||
<section class="editorial-hero reveal-1 space-y-3">
|
<PageHeader
|
||||||
<a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
|
title={m["products_suggestTitle"]()}
|
||||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
kicker={m["nav_appSubtitle"]()}
|
||||||
<h2 class="editorial-title">{m["products_suggestTitle"]()}</h2>
|
subtitle={m["products_suggestSubtitle"]()}
|
||||||
<p class="editorial-subtitle">{m["products_suggestSubtitle"]()}</p>
|
backHref={resolve('/products')}
|
||||||
</section>
|
backLabel={m["products_backToList"]()}
|
||||||
|
/>
|
||||||
|
|
||||||
{#if errorMsg}
|
{#if errorMsg}
|
||||||
<StructuredErrorDisplay error={errorMsg} />
|
<StructuredErrorDisplay error={errorMsg} />
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import type { ActionData, PageData } from './$types';
|
import type { ActionData, PageData } from './$types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import FlashMessages from '$lib/components/FlashMessages.svelte';
|
||||||
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
|
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
|
||||||
import LabeledInputField from '$lib/components/forms/LabeledInputField.svelte';
|
import LabeledInputField from '$lib/components/forms/LabeledInputField.svelte';
|
||||||
|
|
@ -18,23 +20,23 @@
|
||||||
{ value: 'male', label: m.profile_sexMale() },
|
{ value: 'male', label: m.profile_sexMale() },
|
||||||
{ value: 'intersex', label: m.profile_sexIntersex() }
|
{ value: 'intersex', label: m.profile_sexIntersex() }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const flashMessages = $derived([
|
||||||
|
...(form?.error ? [{ kind: 'error' as const, text: form.error }] : []),
|
||||||
|
...(form?.saved ? [{ kind: 'success' as const, text: m.profile_saved() }] : [])
|
||||||
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{m.profile_title()} — innercontext</title></svelte:head>
|
<svelte:head><title>{m.profile_title()} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
<div class="editorial-page space-y-4">
|
<div class="editorial-page space-y-4">
|
||||||
<section class="editorial-hero reveal-1">
|
<PageHeader
|
||||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
title={m.profile_title()}
|
||||||
<h2 class="editorial-title">{m.profile_title()}</h2>
|
kicker={m["nav_appSubtitle"]()}
|
||||||
<p class="editorial-subtitle">{m.profile_subtitle()}</p>
|
subtitle={m.profile_subtitle()}
|
||||||
</section>
|
/>
|
||||||
|
|
||||||
{#if form?.error}
|
<FlashMessages messages={flashMessages} />
|
||||||
<div class="editorial-alert editorial-alert--error">{form.error}</div>
|
|
||||||
{/if}
|
|
||||||
{#if form?.saved}
|
|
||||||
<div class="editorial-alert editorial-alert--success">{m.profile_saved()}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<form method="POST" action="?/save" use:enhance class="reveal-2 space-y-4">
|
<form method="POST" action="?/save" use:enhance class="reveal-2 space-y-4">
|
||||||
<FormSectionCard title={m.profile_sectionBasic()} contentClassName="space-y-4">
|
<FormSectionCard title={m.profile_sectionBasic()} contentClassName="space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Sparkles } from 'lucide-svelte';
|
import { Sparkles } from 'lucide-svelte';
|
||||||
|
|
@ -24,21 +25,22 @@
|
||||||
<svelte:head><title>{m.routines_title()} — innercontext</title></svelte:head>
|
<svelte:head><title>{m.routines_title()} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
<div class="editorial-page space-y-4">
|
<div class="editorial-page space-y-4">
|
||||||
<section class="editorial-hero reveal-1">
|
<PageHeader
|
||||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
title={m.routines_title()}
|
||||||
<h2 class="editorial-title">{m.routines_title()}</h2>
|
kicker={m["nav_appSubtitle"]()}
|
||||||
<p class="editorial-subtitle">{m.routines_count({ count: data.routines.length })}</p>
|
subtitle={m.routines_count({ count: data.routines.length })}
|
||||||
<div class="editorial-toolbar">
|
>
|
||||||
|
{#snippet actions()}
|
||||||
<Button href={resolve('/routines/suggest')} variant="outline"><Sparkles class="size-4" /> {m["routines_suggestAI"]()}</Button>
|
<Button href={resolve('/routines/suggest')} variant="outline"><Sparkles class="size-4" /> {m["routines_suggestAI"]()}</Button>
|
||||||
<Button href={resolve('/routines/new')}>{m["routines_addNew"]()}</Button>
|
<Button href={resolve('/routines/new')}>{m["routines_addNew"]()}</Button>
|
||||||
</div>
|
{/snippet}
|
||||||
</section>
|
</PageHeader>
|
||||||
|
|
||||||
{#if sortedDates.length}
|
{#if sortedDates.length}
|
||||||
<div class="editorial-panel reveal-2 space-y-4">
|
<div class="editorial-panel reveal-2 space-y-4">
|
||||||
{#each sortedDates as date (date)}
|
{#each sortedDates as date (date)}
|
||||||
<div>
|
<div>
|
||||||
<h3 class="products-section-title mb-2">{date}</h3>
|
<h3 class="editorial-section-title mb-2">{date}</h3>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
{#each byDate[date] as routine (routine.id)}
|
{#each byDate[date] as routine (routine.id)}
|
||||||
<a
|
<a
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,19 @@
|
||||||
import { updateRoutineStep } from '$lib/api';
|
import { updateRoutineStep } from '$lib/api';
|
||||||
import type { GroomingAction, RoutineStep } from '$lib/types';
|
import type { GroomingAction, RoutineStep } from '$lib/types';
|
||||||
import type { ActionData, PageData } from './$types';
|
import type { ActionData, PageData } from './$types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import FlashMessages from '$lib/components/FlashMessages.svelte';
|
||||||
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import GroupedSelect from '$lib/components/forms/GroupedSelect.svelte';
|
import GroupedSelect from '$lib/components/forms/GroupedSelect.svelte';
|
||||||
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
||||||
import { Separator } from '$lib/components/ui/separator';
|
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import { GripVertical, Pencil, X, ArrowLeft } from 'lucide-svelte';
|
import { GripVertical, Pencil, X } from 'lucide-svelte';
|
||||||
|
import { preventIfNotConfirmed } from '$lib/utils/forms';
|
||||||
|
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
let { routine, products } = $derived(data);
|
let { routine, products } = $derived(data);
|
||||||
|
|
@ -144,28 +146,53 @@
|
||||||
value: action,
|
value: action,
|
||||||
label: action.replace(/_/g, ' ')
|
label: action.replace(/_/g, ' ')
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const flashMessages = $derived([
|
||||||
|
...(form?.error ? [{ kind: 'error' as const, text: form.error }] : [])
|
||||||
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Routine {routine.routine_date} {routine.part_of_day.toUpperCase()} — innercontext</title></svelte:head>
|
<svelte:head><title>Routine {routine.routine_date} {routine.part_of_day.toUpperCase()} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
<div class="editorial-page space-y-4">
|
<div class="editorial-page space-y-4">
|
||||||
<section class="editorial-hero reveal-1 space-y-3">
|
<PageHeader
|
||||||
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["routines_backToList"]()}</a>
|
title={routine.routine_date}
|
||||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
kicker={m["nav_appSubtitle"]()}
|
||||||
<div class="flex items-center gap-3">
|
backHref={resolve('/routines')}
|
||||||
<h2 class="editorial-title text-[clamp(1.8rem,3vw,2.4rem)]">{routine.routine_date}</h2>
|
backLabel={m["routines_backToList"]()}
|
||||||
|
titleClassName="editorial-title text-[clamp(1.8rem,3vw,2.4rem)]"
|
||||||
|
>
|
||||||
|
{#snippet meta()}
|
||||||
|
<div class="editorial-meta-strip">
|
||||||
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
|
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
|
||||||
{routine.part_of_day.toUpperCase()}
|
{routine.part_of_day.toUpperCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
<span>{m.routines_steps({ count: steps.length })}</span>
|
||||||
</section>
|
{#if routine.notes}
|
||||||
|
<span>•</span>
|
||||||
{#if form?.error}
|
<span class="max-w-[46ch] truncate">{routine.notes}</span>
|
||||||
<div class="editorial-alert editorial-alert--error">{form.error}</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="hero-strip" aria-label={routine.routine_date}>
|
||||||
|
<div>
|
||||||
|
<p class="hero-strip-label">{m["routines_amOrPm"]()}</p>
|
||||||
|
<p class="hero-strip-value">{routine.part_of_day.toUpperCase()}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="hero-strip-label">{m.routines_steps({ count: steps.length })}</p>
|
||||||
|
<p class="hero-strip-value">{steps.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<FlashMessages messages={flashMessages} />
|
||||||
|
|
||||||
{#if routine.notes}
|
{#if routine.notes}
|
||||||
<p class="text-sm text-muted-foreground">{routine.notes}</p>
|
<div class="editorial-panel reveal-2">
|
||||||
|
<p class="text-sm leading-6 text-muted-foreground">{routine.notes}</p>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Steps -->
|
<!-- Steps -->
|
||||||
|
|
@ -178,9 +205,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showStepForm}
|
{#if showStepForm}
|
||||||
<Card>
|
<FormSectionCard title={m["routines_addStepTitle"]()}>
|
||||||
<CardHeader><CardTitle class="text-base">{m["routines_addStepTitle"]()}</CardTitle></CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form method="POST" action="?/addStep" use:enhance class="space-y-4">
|
<form method="POST" action="?/addStep" use:enhance class="space-y-4">
|
||||||
<GroupedSelect
|
<GroupedSelect
|
||||||
id="new_step_product"
|
id="new_step_product"
|
||||||
|
|
@ -191,7 +216,7 @@
|
||||||
bind:value={selectedProductId}
|
bind:value={selectedProductId}
|
||||||
/>
|
/>
|
||||||
<input type="hidden" name="order_index" value={nextOrderIndex} />
|
<input type="hidden" name="order_index" value={nextOrderIndex} />
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label for="dose">{m.routines_dose()}</Label>
|
<Label for="dose">{m.routines_dose()}</Label>
|
||||||
<Input id="dose" name="dose" placeholder={m["routines_dosePlaceholder"]()} />
|
<Input id="dose" name="dose" placeholder={m["routines_dosePlaceholder"]()} />
|
||||||
|
|
@ -201,10 +226,12 @@
|
||||||
<Input id="region" name="region" placeholder={m["routines_regionPlaceholder"]()} />
|
<Input id="region" name="region" placeholder={m["routines_regionPlaceholder"]()} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<Button type="button" size="sm" variant="outline" onclick={() => (showStepForm = false)}>{m.common_cancel()}</Button>
|
||||||
<Button type="submit" size="sm">{m["routines_addStepBtn"]()}</Button>
|
<Button type="submit" size="sm">{m["routines_addStepBtn"]()}</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</FormSectionCard>
|
||||||
</Card>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if steps.length}
|
{#if steps.length}
|
||||||
|
|
@ -229,7 +256,7 @@
|
||||||
value={editDraft.product_id ?? ''}
|
value={editDraft.product_id ?? ''}
|
||||||
onChange={(value) => (editDraft.product_id = value || undefined)}
|
onChange={(value) => (editDraft.product_id = value || undefined)}
|
||||||
/>
|
/>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label>{m.routines_dose()}</Label>
|
<Label>{m.routines_dose()}</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -330,16 +357,15 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator class="opacity-50" />
|
<FormSectionCard title={m.common_edit()} className="reveal-3" contentClassName="space-y-4">
|
||||||
|
<p class="text-sm text-muted-foreground">{m["routines_confirmDelete"]()}</p>
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/delete"
|
action="?/delete"
|
||||||
use:enhance
|
use:enhance
|
||||||
onsubmit={(e) => {
|
onsubmit={(e) => preventIfNotConfirmed(e, m["routines_confirmDelete"]())}
|
||||||
if (!confirm(m["routines_confirmDelete"]())) e.preventDefault();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Button type="submit" variant="destructive" size="sm">{m["routines_deleteRoutine"]()}</Button>
|
<Button type="submit" variant="destructive" size="sm">{m["routines_deleteRoutine"]()}</Button>
|
||||||
</form>
|
</form>
|
||||||
|
</FormSectionCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import {
|
import {
|
||||||
createGroomingScheduleEntry,
|
|
||||||
deleteGroomingScheduleEntry,
|
deleteGroomingScheduleEntry,
|
||||||
getGroomingSchedule,
|
getGroomingSchedule,
|
||||||
updateGroomingScheduleEntry
|
updateGroomingScheduleEntry
|
||||||
|
|
@ -13,27 +12,6 @@ export const load: PageServerLoad = async () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
create: async ({ request }) => {
|
|
||||||
const form = await request.formData();
|
|
||||||
const day_of_week = form.get('day_of_week');
|
|
||||||
const action = form.get('action') as string;
|
|
||||||
if (day_of_week === null || !action) {
|
|
||||||
return fail(400, { error: 'day_of_week and action are required' });
|
|
||||||
}
|
|
||||||
const body: Record<string, unknown> = {
|
|
||||||
day_of_week: Number(day_of_week),
|
|
||||||
action
|
|
||||||
};
|
|
||||||
const notes = (form.get('notes') as string)?.trim();
|
|
||||||
if (notes) body.notes = notes;
|
|
||||||
try {
|
|
||||||
await createGroomingScheduleEntry(body);
|
|
||||||
return { created: true };
|
|
||||||
} catch (e) {
|
|
||||||
return fail(500, { error: (e as Error).message });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
update: async ({ request }) => {
|
update: async ({ request }) => {
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const id = form.get('id') as string;
|
const id = form.get('id') as string;
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,20 @@
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import type { ActionData, PageData } from './$types';
|
import type { ActionData, PageData } from './$types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import FlashMessages from '$lib/components/FlashMessages.svelte';
|
||||||
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
|
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Card, CardContent } from '$lib/components/ui/card';
|
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from '$lib/components/ui/label';
|
|
||||||
import { Separator } from '$lib/components/ui/separator';
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
import { ArrowLeft } from 'lucide-svelte';
|
|
||||||
import type { GroomingAction, GroomingSchedule } from '$lib/types';
|
import type { GroomingAction, GroomingSchedule } from '$lib/types';
|
||||||
|
import { preventIfNotConfirmed } from '$lib/utils/forms';
|
||||||
|
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
let { schedule } = $derived(data);
|
let { schedule } = $derived(data);
|
||||||
|
|
||||||
let showAddForm = $state(false);
|
|
||||||
let editingId = $state<string | null>(null);
|
let editingId = $state<string | null>(null);
|
||||||
|
|
||||||
const DAY_NAMES = $derived([
|
const DAY_NAMES = $derived([
|
||||||
|
|
@ -35,6 +35,13 @@
|
||||||
} as Record<GroomingAction, string>);
|
} as Record<GroomingAction, string>);
|
||||||
|
|
||||||
const ALL_ACTIONS: GroomingAction[] = ['shaving_razor', 'shaving_oneblade', 'dermarolling'];
|
const ALL_ACTIONS: GroomingAction[] = ['shaving_razor', 'shaving_oneblade', 'dermarolling'];
|
||||||
|
const actionOptions = $derived(ALL_ACTIONS.map((action) => ({ value: action, label: ACTION_LABELS[action] })));
|
||||||
|
const dayOptions = $derived(DAY_NAMES.map((name, idx) => ({ value: String(idx), label: name })));
|
||||||
|
const flashMessages = $derived([
|
||||||
|
...(form?.error ? [{ kind: 'error' as const, text: form.error }] : []),
|
||||||
|
...(form?.updated ? [{ kind: 'success' as const, text: m["grooming_entryUpdated"]() }] : []),
|
||||||
|
...(form?.deleted ? [{ kind: 'success' as const, text: m["grooming_entryDeleted"]() }] : [])
|
||||||
|
]);
|
||||||
|
|
||||||
const byDay = $derived(
|
const byDay = $derived(
|
||||||
DAY_NAMES.map((name, idx) => ({
|
DAY_NAMES.map((name, idx) => ({
|
||||||
|
|
@ -48,85 +55,30 @@
|
||||||
<svelte:head><title>{m.grooming_title()} — innercontext</title></svelte:head>
|
<svelte:head><title>{m.grooming_title()} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
<div class="editorial-page space-y-4">
|
<div class="editorial-page space-y-4">
|
||||||
<section class="editorial-hero reveal-1 space-y-3">
|
<PageHeader
|
||||||
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["grooming_backToRoutines"]()}</a>
|
title={m.grooming_title()}
|
||||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
kicker={m["nav_appSubtitle"]()}
|
||||||
<h2 class="editorial-title text-[clamp(1.8rem,3vw,2.4rem)]">{m.grooming_title()}</h2>
|
backHref={resolve('/routines')}
|
||||||
<div class="editorial-toolbar">
|
backLabel={m["grooming_backToRoutines"]()}
|
||||||
<Button variant="outline" size="sm" onclick={() => (showAddForm = !showAddForm)}>
|
titleClassName="editorial-title text-[clamp(1.8rem,3vw,2.4rem)]"
|
||||||
{showAddForm ? m.common_cancel() : m["grooming_addEntry"]()}
|
>
|
||||||
</Button>
|
{#snippet actions()}
|
||||||
</div>
|
<Button variant="outline" size="sm" href="/routines/grooming-schedule/new">{m["grooming_addEntry"]()}</Button>
|
||||||
</section>
|
{/snippet}
|
||||||
|
|
||||||
{#if form?.error}
|
<div class="hero-strip" aria-label={m.grooming_title()}>
|
||||||
<div class="editorial-alert editorial-alert--error">{form.error}</div>
|
<div>
|
||||||
{/if}
|
<p class="hero-strip-label">{m["grooming_dayOfWeek"]()}</p>
|
||||||
{#if form?.created}
|
<p class="hero-strip-value">{byDay.length}</p>
|
||||||
<div class="editorial-alert editorial-alert--success">{m["grooming_entryAdded"]()}</div>
|
</div>
|
||||||
{/if}
|
<div>
|
||||||
{#if form?.updated}
|
<p class="hero-strip-label">{m["grooming_addEntry"]()}</p>
|
||||||
<div class="editorial-alert editorial-alert--success">{m["grooming_entryUpdated"]()}</div>
|
<p class="hero-strip-value">{schedule.length}</p>
|
||||||
{/if}
|
</div>
|
||||||
{#if form?.deleted}
|
</div>
|
||||||
<div class="editorial-alert editorial-alert--success">{m["grooming_entryDeleted"]()}</div>
|
</PageHeader>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Add form -->
|
<FlashMessages messages={flashMessages} />
|
||||||
{#if showAddForm}
|
|
||||||
<Card>
|
|
||||||
<CardContent class="pt-4">
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/create"
|
|
||||||
use:enhance={() => {
|
|
||||||
return async ({ update }) => {
|
|
||||||
await update();
|
|
||||||
showAddForm = false;
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
class="grid grid-cols-2 gap-4"
|
|
||||||
>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label for="add_day">{m["grooming_dayOfWeek"]()}</Label>
|
|
||||||
<select
|
|
||||||
id="add_day"
|
|
||||||
name="day_of_week"
|
|
||||||
required
|
|
||||||
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
{#each DAY_NAMES as name, idx (idx)}
|
|
||||||
<option value={idx}>{name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label for="add_action">{m.grooming_action()}</Label>
|
|
||||||
<select
|
|
||||||
id="add_action"
|
|
||||||
name="action"
|
|
||||||
required
|
|
||||||
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
{#each ALL_ACTIONS as action (action)}
|
|
||||||
<option value={action}>{ACTION_LABELS[action]}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2 space-y-1">
|
|
||||||
<Label for="add_notes">{m["grooming_notesOptional"]()}</Label>
|
|
||||||
<Input id="add_notes" name="notes" placeholder={m["grooming_notesPlaceholder"]()} />
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2 flex gap-2">
|
|
||||||
<Button type="submit" size="sm">{m.common_add()}</Button>
|
|
||||||
<Button type="button" variant="ghost" size="sm" onclick={() => (showAddForm = false)}>
|
|
||||||
{m.common_cancel()}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Entries grouped by day -->
|
<!-- Entries grouped by day -->
|
||||||
{#if schedule.length === 0}
|
{#if schedule.length === 0}
|
||||||
|
|
@ -158,9 +110,7 @@
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/delete"
|
action="?/delete"
|
||||||
use:enhance
|
use:enhance
|
||||||
onsubmit={(e) => {
|
onsubmit={(e) => preventIfNotConfirmed(e, m["grooming_confirmDelete"]())}
|
||||||
if (!confirm(m["grooming_confirmDelete"]())) e.preventDefault();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={entry.id} />
|
<input type="hidden" name="id" value={entry.id} />
|
||||||
<Button variant="ghost" size="sm" type="submit" class="text-destructive hover:text-destructive">
|
<Button variant="ghost" size="sm" type="submit" class="text-destructive hover:text-destructive">
|
||||||
|
|
@ -185,31 +135,11 @@
|
||||||
class="grid grid-cols-2 gap-3"
|
class="grid grid-cols-2 gap-3"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={entry.id} />
|
<input type="hidden" name="id" value={entry.id} />
|
||||||
<div class="space-y-1">
|
<SimpleSelect id={`edit_day_${entry.id}`} name="day_of_week" label={m["grooming_dayOfWeek"]()} options={dayOptions} value={String(entry.day_of_week)} />
|
||||||
<Label>{m["grooming_dayOfWeek"]()}</Label>
|
<SimpleSelect id={`edit_action_${entry.id}`} name="action" label={m.grooming_action()} options={actionOptions} value={entry.action} />
|
||||||
<select
|
|
||||||
name="day_of_week"
|
|
||||||
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
{#each DAY_NAMES as dayName, idx (idx)}
|
|
||||||
<option value={idx} selected={entry.day_of_week === idx}>{dayName}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<Label>{m.grooming_action()}</Label>
|
|
||||||
<select
|
|
||||||
name="action"
|
|
||||||
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
{#each ALL_ACTIONS as a (a)}
|
|
||||||
<option value={a} selected={entry.action === a}>{ACTION_LABELS[a]}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2 space-y-1">
|
<div class="col-span-2 space-y-1">
|
||||||
<Label>{m.inventory_notes()}</Label>
|
<label class="text-sm font-medium" for={`edit_notes_${entry.id}`}>{m.inventory_notes()}</label>
|
||||||
<Input name="notes" value={entry.notes ?? ''} placeholder={m["common_optional_notes"]()} />
|
<Input id={`edit_notes_${entry.id}`} name="notes" value={entry.notes ?? ''} placeholder={m["common_optional_notes"]()} />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-2 flex gap-2">
|
<div class="col-span-2 flex gap-2">
|
||||||
<Button type="submit" size="sm">{m.common_save()}</Button>
|
<Button type="submit" size="sm">{m.common_save()}</Button>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { createGroomingScheduleEntry } from '$lib/api';
|
||||||
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async ({ request }) => {
|
||||||
|
const form = await request.formData();
|
||||||
|
const day_of_week = form.get('day_of_week');
|
||||||
|
const action = form.get('action') as string;
|
||||||
|
|
||||||
|
if (day_of_week === null || !action) {
|
||||||
|
return fail(400, { error: 'day_of_week and action are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
day_of_week: Number(day_of_week),
|
||||||
|
action
|
||||||
|
};
|
||||||
|
const notes = (form.get('notes') as string)?.trim();
|
||||||
|
if (notes) body.notes = notes;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createGroomingScheduleEntry(body);
|
||||||
|
} catch (error) {
|
||||||
|
return fail(500, { error: (error as Error).message });
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(303, '/routines/grooming-schedule');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import FlashMessages from '$lib/components/FlashMessages.svelte';
|
||||||
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
|
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Card, CardContent } from '$lib/components/ui/card';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import type { GroomingAction } from '$lib/types';
|
||||||
|
import type { ActionData } from './$types';
|
||||||
|
|
||||||
|
let { form }: { form: ActionData } = $props();
|
||||||
|
|
||||||
|
const dayNames = [
|
||||||
|
m['grooming_dayMonday'](),
|
||||||
|
m['grooming_dayTuesday'](),
|
||||||
|
m['grooming_dayWednesday'](),
|
||||||
|
m['grooming_dayThursday'](),
|
||||||
|
m['grooming_dayFriday'](),
|
||||||
|
m['grooming_daySaturday'](),
|
||||||
|
m['grooming_daySunday']()
|
||||||
|
];
|
||||||
|
const actionLabels: Record<GroomingAction, string> = {
|
||||||
|
shaving_razor: m['grooming_actionShavingRazor'](),
|
||||||
|
shaving_oneblade: m['grooming_actionShavingOneblade'](),
|
||||||
|
dermarolling: m['grooming_actionDermarolling']()
|
||||||
|
};
|
||||||
|
const allActions: GroomingAction[] = ['shaving_razor', 'shaving_oneblade', 'dermarolling'];
|
||||||
|
const dayOptions = dayNames.map((name, index) => ({ value: String(index), label: name }));
|
||||||
|
const actionOptions = allActions.map((action) => ({ value: action, label: actionLabels[action] }));
|
||||||
|
const flashMessages = $derived(form?.error ? [{ kind: 'error' as const, text: form.error }] : []);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>{m['grooming_newTitle']()} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="editorial-page space-y-4">
|
||||||
|
<PageHeader
|
||||||
|
title={m['grooming_newTitle']()}
|
||||||
|
kicker={m['nav_appSubtitle']()}
|
||||||
|
backHref={resolve('/routines/grooming-schedule')}
|
||||||
|
backLabel={m.grooming_title()}
|
||||||
|
subtitle={m['grooming_newSubtitle']()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FlashMessages messages={flashMessages} />
|
||||||
|
|
||||||
|
<Card class="reveal-2">
|
||||||
|
<CardContent class="pt-4">
|
||||||
|
<form method="POST" class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<p class="sm:col-span-2 text-sm leading-6 text-muted-foreground">{m['grooming_newSectionIntro']()}</p>
|
||||||
|
<SimpleSelect id="day_of_week" name="day_of_week" label={m['grooming_dayOfWeek']()} options={dayOptions} required />
|
||||||
|
<SimpleSelect id="action" name="action" label={m.grooming_action()} options={actionOptions} required />
|
||||||
|
<div class="space-y-1 sm:col-span-2">
|
||||||
|
<label class="text-sm font-medium" for="notes">{m['grooming_notesOptional']()}</label>
|
||||||
|
<Input id="notes" name="notes" placeholder={m['grooming_notesPlaceholder']()} />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end gap-2 sm:col-span-2">
|
||||||
|
<Button type="button" variant="outline" href={resolve('/routines/grooming-schedule')}>{m.common_cancel()}</Button>
|
||||||
|
<Button type="submit">{m.common_add()}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import type { ActionData, PageData } from './$types';
|
import type { ActionData, PageData } from './$types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { ArrowLeft } from 'lucide-svelte';
|
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
|
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
|
||||||
|
|
@ -22,11 +22,12 @@
|
||||||
<svelte:head><title>{m["routines_newTitle"]()} — innercontext</title></svelte:head>
|
<svelte:head><title>{m["routines_newTitle"]()} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
<div class="editorial-page space-y-4">
|
<div class="editorial-page space-y-4">
|
||||||
<section class="editorial-hero reveal-1 space-y-3">
|
<PageHeader
|
||||||
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["routines_backToList"]()}</a>
|
title={m["routines_newTitle"]()}
|
||||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
kicker={m["nav_appSubtitle"]()}
|
||||||
<h2 class="editorial-title">{m["routines_newTitle"]()}</h2>
|
backHref={resolve('/routines')}
|
||||||
</section>
|
backLabel={m["routines_backToList"]()}
|
||||||
|
/>
|
||||||
|
|
||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
<div class="editorial-alert editorial-alert--error">{form.error}</div>
|
<div class="editorial-alert editorial-alert--error">{form.error}</div>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import type { BatchSuggestion, RoutineSuggestion, SuggestedStep } from '$lib/types';
|
import type { BatchSuggestion, RoutineSuggestion, SuggestedStep } from '$lib/types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Card, CardContent } from '$lib/components/ui/card';
|
import { Card, CardContent } from '$lib/components/ui/card';
|
||||||
|
|
@ -15,7 +16,7 @@
|
||||||
import HintCheckbox from '$lib/components/forms/HintCheckbox.svelte';
|
import HintCheckbox from '$lib/components/forms/HintCheckbox.svelte';
|
||||||
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
|
||||||
import { ChevronUp, ChevronDown, ArrowLeft, Sparkles } from 'lucide-svelte';
|
import { ChevronUp, ChevronDown, Sparkles } from 'lucide-svelte';
|
||||||
import ValidationWarningsAlert from '$lib/components/ValidationWarningsAlert.svelte';
|
import ValidationWarningsAlert from '$lib/components/ValidationWarningsAlert.svelte';
|
||||||
import StructuredErrorDisplay from '$lib/components/StructuredErrorDisplay.svelte';
|
import StructuredErrorDisplay from '$lib/components/StructuredErrorDisplay.svelte';
|
||||||
import AutoFixBadge from '$lib/components/AutoFixBadge.svelte';
|
import AutoFixBadge from '$lib/components/AutoFixBadge.svelte';
|
||||||
|
|
@ -119,11 +120,12 @@
|
||||||
<svelte:head><title>{m.suggest_title()} — innercontext</title></svelte:head>
|
<svelte:head><title>{m.suggest_title()} — innercontext</title></svelte:head>
|
||||||
|
|
||||||
<div class="editorial-page space-y-4">
|
<div class="editorial-page space-y-4">
|
||||||
<section class="editorial-hero reveal-1 space-y-3">
|
<PageHeader
|
||||||
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["suggest_backToRoutines"]()}</a>
|
title={m.suggest_title()}
|
||||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
kicker={m["nav_appSubtitle"]()}
|
||||||
<h2 class="editorial-title">{m.suggest_title()}</h2>
|
backHref={resolve('/routines')}
|
||||||
</section>
|
backLabel={m["suggest_backToRoutines"]()}
|
||||||
|
/>
|
||||||
|
|
||||||
{#if errorMsg}
|
{#if errorMsg}
|
||||||
<StructuredErrorDisplay error={errorMsg} />
|
<StructuredErrorDisplay error={errorMsg} />
|
||||||
|
|
@ -138,9 +140,9 @@
|
||||||
<!-- ── Single tab ─────────────────────────────────────────────────── -->
|
<!-- ── Single tab ─────────────────────────────────────────────────── -->
|
||||||
<TabsContent value="single" class="space-y-6 pt-4">
|
<TabsContent value="single" class="space-y-6 pt-4">
|
||||||
<FormSectionCard title={m["suggest_singleParams"]()}>
|
<FormSectionCard title={m["suggest_singleParams"]()}>
|
||||||
<form method="POST" action="?/suggest" use:enhance={enhanceSingle} class="space-y-4">
|
<form id="suggest-single-form" method="POST" action="?/suggest" use:enhance={enhanceSingle} class="space-y-4">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:items-start">
|
||||||
<div class="space-y-2">
|
<div class="space-y-1">
|
||||||
<Label for="single_date">{m.suggest_date()}</Label>
|
<Label for="single_date">{m.suggest_date()}</Label>
|
||||||
<Input id="single_date" name="routine_date" type="date" value={data.today} required />
|
<Input id="single_date" name="routine_date" type="date" value={data.today} required />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -284,12 +286,12 @@
|
||||||
<TabsContent value="batch" class="space-y-6 pt-4">
|
<TabsContent value="batch" class="space-y-6 pt-4">
|
||||||
<FormSectionCard title={m["suggest_batchRange"]()}>
|
<FormSectionCard title={m["suggest_batchRange"]()}>
|
||||||
<form id="batch-form" method="POST" action="?/suggestBatch" use:enhance={enhanceBatch} class="space-y-4">
|
<form id="batch-form" method="POST" action="?/suggestBatch" use:enhance={enhanceBatch} class="space-y-4">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:items-start">
|
||||||
<div class="space-y-2">
|
<div class="space-y-1">
|
||||||
<Label for="from_date">{m["suggest_fromDate"]()}</Label>
|
<Label for="from_date">{m["suggest_fromDate"]()}</Label>
|
||||||
<Input id="from_date" name="from_date" type="date" value={data.today} required />
|
<Input id="from_date" name="from_date" type="date" value={data.today} required />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-1">
|
||||||
<Label for="to_date">{m["suggest_toDate"]()}</Label>
|
<Label for="to_date">{m["suggest_toDate"]()}</Label>
|
||||||
<Input id="to_date" name="to_date" type="date" value={data.today} required />
|
<Input id="to_date" name="to_date" type="date" value={data.today} required />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { createSkinSnapshot, deleteSkinSnapshot, getSkinSnapshots, updateSkinSnapshot } from '$lib/api';
|
import { deleteSkinSnapshot, getSkinSnapshots, updateSkinSnapshot } from '$lib/api';
|
||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
|
@ -9,55 +9,6 @@ export const load: PageServerLoad = async ({ url }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
create: async ({ request }) => {
|
|
||||||
const form = await request.formData();
|
|
||||||
const snapshot_date = form.get('snapshot_date') as string;
|
|
||||||
const overall_state = form.get('overall_state') as string;
|
|
||||||
const texture = form.get('texture') as string;
|
|
||||||
const notes = form.get('notes') as string;
|
|
||||||
const hydration_level = form.get('hydration_level') as string;
|
|
||||||
const sensitivity_level = form.get('sensitivity_level') as string;
|
|
||||||
const barrier_state = form.get('barrier_state') as string;
|
|
||||||
const active_concerns_values = form
|
|
||||||
.getAll('active_concerns')
|
|
||||||
.map((value) => String(value).trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const priorities_raw = form.get('priorities') as string;
|
|
||||||
|
|
||||||
if (!snapshot_date) {
|
|
||||||
return fail(400, { error: 'Date is required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const active_concerns = active_concerns_values;
|
|
||||||
|
|
||||||
const priorities = priorities_raw
|
|
||||||
?.split(',')
|
|
||||||
.map((p) => p.trim())
|
|
||||||
.filter(Boolean) ?? [];
|
|
||||||
|
|
||||||
const skin_type = form.get('skin_type') as string;
|
|
||||||
const sebum_tzone = form.get('sebum_tzone') as string;
|
|
||||||
const sebum_cheeks = form.get('sebum_cheeks') as string;
|
|
||||||
|
|
||||||
const body: Record<string, unknown> = { snapshot_date, active_concerns, priorities };
|
|
||||||
if (overall_state) body.overall_state = overall_state;
|
|
||||||
if (texture) body.texture = texture;
|
|
||||||
if (notes) body.notes = notes;
|
|
||||||
if (hydration_level) body.hydration_level = Number(hydration_level);
|
|
||||||
if (sensitivity_level) body.sensitivity_level = Number(sensitivity_level);
|
|
||||||
if (barrier_state) body.barrier_state = barrier_state;
|
|
||||||
if (skin_type) body.skin_type = skin_type;
|
|
||||||
if (sebum_tzone) body.sebum_tzone = Number(sebum_tzone);
|
|
||||||
if (sebum_cheeks) body.sebum_cheeks = Number(sebum_cheeks);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createSkinSnapshot(body);
|
|
||||||
return { created: true };
|
|
||||||
} catch (e) {
|
|
||||||
return fail(500, { error: (e as Error).message });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
update: async ({ request }) => {
|
update: async ({ request }) => {
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const id = form.get('id') as string;
|
const id = form.get('id') as string;
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import FlashMessages from '$lib/components/FlashMessages.svelte';
|
||||||
|
import type { OverallSkinState } from '$lib/types';
|
||||||
import type { ActionData, PageData } from './$types';
|
import type { ActionData, PageData } from './$types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import { analyzeSkinPhotos } from '$lib/api';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
|
import { getOverallSkinStateLabel, getSkinConcernLabel } from '$lib/utils/skin-display';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import { Card, CardContent } from '$lib/components/ui/card';
|
||||||
import LabeledInputField from '$lib/components/forms/LabeledInputField.svelte';
|
import LabeledInputField from '$lib/components/forms/LabeledInputField.svelte';
|
||||||
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
||||||
import { Sparkles, Pencil, X } from 'lucide-svelte';
|
import { Pencil, X } from 'lucide-svelte';
|
||||||
|
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
|
|
@ -36,13 +39,6 @@
|
||||||
poor: 'state-pill state-pill--poor'
|
poor: 'state-pill state-pill--poor'
|
||||||
};
|
};
|
||||||
|
|
||||||
const stateLabels: Record<string, () => string> = {
|
|
||||||
excellent: m["skin_stateExcellent"],
|
|
||||||
good: m["skin_stateGood"],
|
|
||||||
fair: m["skin_stateFair"],
|
|
||||||
poor: m["skin_statePoor"]
|
|
||||||
};
|
|
||||||
|
|
||||||
const textureLabels: Record<string, () => string> = {
|
const textureLabels: Record<string, () => string> = {
|
||||||
smooth: m["skin_textureSmooth"],
|
smooth: m["skin_textureSmooth"],
|
||||||
rough: m["skin_textureRough"],
|
rough: m["skin_textureRough"],
|
||||||
|
|
@ -65,36 +61,6 @@
|
||||||
acne_prone: m["skin_typeAcneProne"]
|
acne_prone: m["skin_typeAcneProne"]
|
||||||
};
|
};
|
||||||
|
|
||||||
const concernLabels: Record<string, () => string> = {
|
|
||||||
acne: m["productForm_concernAcne"],
|
|
||||||
rosacea: m["productForm_concernRosacea"],
|
|
||||||
hyperpigmentation: m["productForm_concernHyperpigmentation"],
|
|
||||||
aging: m["productForm_concernAging"],
|
|
||||||
dehydration: m["productForm_concernDehydration"],
|
|
||||||
redness: m["productForm_concernRedness"],
|
|
||||||
damaged_barrier: m["productForm_concernDamagedBarrier"],
|
|
||||||
pore_visibility: m["productForm_concernPoreVisibility"],
|
|
||||||
uneven_texture: m["productForm_concernUnevenTexture"],
|
|
||||||
hair_growth: m["productForm_concernHairGrowth"],
|
|
||||||
sebum_excess: m["productForm_concernSebumExcess"]
|
|
||||||
};
|
|
||||||
|
|
||||||
let showForm = $state(false);
|
|
||||||
|
|
||||||
// Create form state (bound to inputs so AI can pre-fill)
|
|
||||||
let snapshotDate = $state(new Date().toISOString().slice(0, 10));
|
|
||||||
let overallState = $state('');
|
|
||||||
let texture = $state('');
|
|
||||||
let barrierState = $state('');
|
|
||||||
let skinType = $state('');
|
|
||||||
let hydrationLevel = $state('');
|
|
||||||
let sensitivityLevel = $state('');
|
|
||||||
let sebumTzone = $state('');
|
|
||||||
let sebumCheeks = $state('');
|
|
||||||
let activeConcerns = $state<string[]>([]);
|
|
||||||
let prioritiesRaw = $state('');
|
|
||||||
let notes = $state('');
|
|
||||||
|
|
||||||
// Edit state
|
// Edit state
|
||||||
let editingId = $state<string | null>(null);
|
let editingId = $state<string | null>(null);
|
||||||
let editSnapshotDate = $state('');
|
let editSnapshotDate = $state('');
|
||||||
|
|
@ -124,292 +90,63 @@
|
||||||
editActiveConcerns = [...(snap.active_concerns ?? [])];
|
editActiveConcerns = [...(snap.active_concerns ?? [])];
|
||||||
editPrioritiesRaw = snap.priorities?.join(', ') ?? '';
|
editPrioritiesRaw = snap.priorities?.join(', ') ?? '';
|
||||||
editNotes = snap.notes ?? '';
|
editNotes = snap.notes ?? '';
|
||||||
showForm = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI photo analysis state
|
const overallStateOptions = $derived(
|
||||||
let aiModalOpen = $state(false);
|
states.map((s) => ({ value: s, label: getOverallSkinStateLabel(s as OverallSkinState) }))
|
||||||
let selectedFiles = $state<File[]>([]);
|
);
|
||||||
let previewUrls = $state<string[]>([]);
|
|
||||||
let aiLoading = $state(false);
|
|
||||||
let aiError = $state('');
|
|
||||||
|
|
||||||
const overallStateOptions = $derived(states.map((s) => ({ value: s, label: stateLabels[s]?.() ?? s })));
|
|
||||||
const textureOptions = $derived(skinTextures.map((t) => ({ value: t, label: textureLabels[t]?.() ?? t })));
|
const textureOptions = $derived(skinTextures.map((t) => ({ value: t, label: textureLabels[t]?.() ?? t })));
|
||||||
const skinTypeOptions = $derived(skinTypes.map((st) => ({ value: st, label: skinTypeLabels[st]?.() ?? st })));
|
const skinTypeOptions = $derived(skinTypes.map((st) => ({ value: st, label: skinTypeLabels[st]?.() ?? st })));
|
||||||
const barrierOptions = $derived(barrierStates.map((b) => ({ value: b, label: barrierLabels[b]?.() ?? b })));
|
const barrierOptions = $derived(barrierStates.map((b) => ({ value: b, label: barrierLabels[b]?.() ?? b })));
|
||||||
const activeConcernOptions = $derived(
|
const activeConcernOptions = $derived(
|
||||||
activeConcernValues.map((value) => ({
|
activeConcernValues.map((value) => ({
|
||||||
value,
|
value,
|
||||||
label: concernLabels[value]?.() ?? value.replace(/_/g, ' ')
|
label: getSkinConcernLabel(value)
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
const sortedSnapshots = $derived(
|
const sortedSnapshots = $derived(
|
||||||
[...data.snapshots].sort((a, b) => b.snapshot_date.localeCompare(a.snapshot_date))
|
[...data.snapshots].sort((a, b) => b.snapshot_date.localeCompare(a.snapshot_date))
|
||||||
);
|
);
|
||||||
|
const latestSnapshot = $derived(sortedSnapshots[0] ?? null);
|
||||||
function handleFileSelect(e: Event) {
|
const flashMessages = $derived([
|
||||||
const input = e.target as HTMLInputElement;
|
...(form?.error ? [{ kind: 'error' as const, text: form.error }] : []),
|
||||||
const files = Array.from(input.files ?? []).slice(0, 3);
|
...(form?.updated ? [{ kind: 'success' as const, text: m['skin_snapshotUpdated']() }] : []),
|
||||||
selectedFiles = files;
|
...(form?.deleted ? [{ kind: 'success' as const, text: m['skin_snapshotDeleted']() }] : [])
|
||||||
previewUrls.forEach(URL.revokeObjectURL);
|
]);
|
||||||
previewUrls = files.map((f) => URL.createObjectURL(f));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function analyzePhotos() {
|
|
||||||
if (!selectedFiles.length) return;
|
|
||||||
aiLoading = true;
|
|
||||||
aiError = '';
|
|
||||||
try {
|
|
||||||
const r = await analyzeSkinPhotos(selectedFiles);
|
|
||||||
if (r.overall_state) overallState = r.overall_state;
|
|
||||||
if (r.texture) texture = r.texture;
|
|
||||||
if (r.skin_type) skinType = r.skin_type;
|
|
||||||
if (r.barrier_state) barrierState = r.barrier_state;
|
|
||||||
if (r.hydration_level != null) hydrationLevel = String(r.hydration_level);
|
|
||||||
if (r.sensitivity_level != null) sensitivityLevel = String(r.sensitivity_level);
|
|
||||||
if (r.sebum_tzone != null) sebumTzone = String(r.sebum_tzone);
|
|
||||||
if (r.sebum_cheeks != null) sebumCheeks = String(r.sebum_cheeks);
|
|
||||||
if (r.active_concerns?.length) activeConcerns = [...r.active_concerns];
|
|
||||||
if (r.priorities?.length) prioritiesRaw = r.priorities.join(', ');
|
|
||||||
if (r.notes) notes = r.notes;
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{m.skin_title()} — innercontext</title></svelte:head>
|
<svelte:head><title>{m.skin_title()} — innercontext</title></svelte:head>
|
||||||
<svelte:window onkeydown={handleModalKeydown} />
|
|
||||||
|
|
||||||
<div class="editorial-page space-y-4">
|
<div class="editorial-page space-y-4">
|
||||||
<section class="editorial-hero reveal-1">
|
<PageHeader
|
||||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
title={m.skin_title()}
|
||||||
<h2 class="editorial-title">{m.skin_title()}</h2>
|
kicker={m["nav_appSubtitle"]()}
|
||||||
<p class="editorial-subtitle">{m.skin_count({ count: data.snapshots.length })}</p>
|
subtitle={m.skin_count({ count: data.snapshots.length })}
|
||||||
<div class="editorial-toolbar">
|
>
|
||||||
{#if showForm}
|
{#snippet actions()}
|
||||||
<Button type="button" variant="outline" size="sm" onclick={openAiModal}>
|
<Button variant="outline" href="/skin/new">{m["skin_addNew"]()}</Button>
|
||||||
<Sparkles class="size-4" />
|
{/snippet}
|
||||||
{m["skin_aiAnalysisTitle"]()}
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
<Button variant="outline" onclick={() => (showForm = !showForm)}>
|
|
||||||
{showForm ? m.common_cancel() : m["skin_addNew"]()}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{#if form?.error}
|
<div class="hero-strip" aria-label={m.skin_title()}>
|
||||||
<div class="editorial-alert editorial-alert--error">{form.error}</div>
|
<div>
|
||||||
{/if}
|
<p class="hero-strip-label">{m.skin_date()}</p>
|
||||||
{#if form?.created}
|
<p class="hero-strip-value">{latestSnapshot?.snapshot_date ?? '—'}</p>
|
||||||
<div class="editorial-alert editorial-alert--success">{m["skin_snapshotAdded"]()}</div>
|
</div>
|
||||||
{/if}
|
<div>
|
||||||
{#if form?.updated}
|
<p class="hero-strip-label">{m["skin_overallState"]()}</p>
|
||||||
<div class="editorial-alert editorial-alert--success">{m["skin_snapshotUpdated"]()}</div>
|
<p class="hero-strip-value">
|
||||||
{/if}
|
{latestSnapshot?.overall_state ? getOverallSkinStateLabel(latestSnapshot.overall_state) : '—'}
|
||||||
{#if form?.deleted}
|
</p>
|
||||||
<div class="editorial-alert editorial-alert--success">{m["skin_snapshotDeleted"]()}</div>
|
</div>
|
||||||
{/if}
|
<div>
|
||||||
|
<p class="hero-strip-label">{m["skin_activeConcerns"]()}</p>
|
||||||
|
<p class="hero-strip-value">{latestSnapshot?.active_concerns.length ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
{#if showForm}
|
<FlashMessages messages={flashMessages} />
|
||||||
{#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["skin_aiAnalysisTitle"]()}</CardTitle>
|
|
||||||
<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>
|
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
{#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 border object-cover" />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if aiError}
|
|
||||||
<p class="text-sm text-destructive">{aiError}</p>
|
|
||||||
{/if}
|
|
||||||
<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>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- New snapshot form -->
|
|
||||||
<Card class="reveal-2">
|
|
||||||
<CardHeader><CardTitle>{m["skin_newSnapshotTitle"]()}</CardTitle></CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<LabeledInputField
|
|
||||||
id="snapshot_date"
|
|
||||||
name="snapshot_date"
|
|
||||||
label={m.skin_date()}
|
|
||||||
type="date"
|
|
||||||
required
|
|
||||||
bind:value={snapshotDate}
|
|
||||||
/>
|
|
||||||
<SimpleSelect
|
|
||||||
id="overall_state"
|
|
||||||
name="overall_state"
|
|
||||||
label={m["skin_overallState"]()}
|
|
||||||
options={overallStateOptions}
|
|
||||||
placeholder={m.common_select()}
|
|
||||||
bind:value={overallState}
|
|
||||||
/>
|
|
||||||
<SimpleSelect
|
|
||||||
id="texture"
|
|
||||||
name="texture"
|
|
||||||
label={m.skin_texture()}
|
|
||||||
options={textureOptions}
|
|
||||||
placeholder={m.common_select()}
|
|
||||||
bind:value={texture}
|
|
||||||
/>
|
|
||||||
<SimpleSelect
|
|
||||||
id="skin_type"
|
|
||||||
name="skin_type"
|
|
||||||
label={m["skin_skinType"]()}
|
|
||||||
options={skinTypeOptions}
|
|
||||||
placeholder={m.common_select()}
|
|
||||||
bind:value={skinType}
|
|
||||||
/>
|
|
||||||
<SimpleSelect
|
|
||||||
id="barrier_state"
|
|
||||||
name="barrier_state"
|
|
||||||
label={m["skin_barrierState"]()}
|
|
||||||
options={barrierOptions}
|
|
||||||
placeholder={m.common_select()}
|
|
||||||
bind:value={barrierState}
|
|
||||||
/>
|
|
||||||
<LabeledInputField
|
|
||||||
id="hydration_level"
|
|
||||||
name="hydration_level"
|
|
||||||
label={m.skin_hydration()}
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="5"
|
|
||||||
bind:value={hydrationLevel}
|
|
||||||
/>
|
|
||||||
<LabeledInputField
|
|
||||||
id="sensitivity_level"
|
|
||||||
name="sensitivity_level"
|
|
||||||
label={m.skin_sensitivity()}
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="5"
|
|
||||||
bind:value={sensitivityLevel}
|
|
||||||
/>
|
|
||||||
<LabeledInputField
|
|
||||||
id="sebum_tzone"
|
|
||||||
name="sebum_tzone"
|
|
||||||
label={m["skin_sebumTzone"]()}
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="5"
|
|
||||||
bind:value={sebumTzone}
|
|
||||||
/>
|
|
||||||
<LabeledInputField
|
|
||||||
id="sebum_cheeks"
|
|
||||||
name="sebum_cheeks"
|
|
||||||
label={m["skin_sebumCheeks"]()}
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="5"
|
|
||||||
bind:value={sebumCheeks}
|
|
||||||
/>
|
|
||||||
<div class="space-y-2 col-span-2">
|
|
||||||
<p class="text-sm font-medium">{m["skin_activeConcerns"]()}</p>
|
|
||||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
|
||||||
{#each activeConcernOptions as option (option.value)}
|
|
||||||
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="active_concerns"
|
|
||||||
value={option.value}
|
|
||||||
checked={activeConcerns.includes(option.value)}
|
|
||||||
onchange={() => {
|
|
||||||
if (activeConcerns.includes(option.value)) {
|
|
||||||
activeConcerns = activeConcerns.filter((c) => c !== option.value);
|
|
||||||
} else {
|
|
||||||
activeConcerns = [...activeConcerns, option.value];
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
class="rounded border-input"
|
|
||||||
/>
|
|
||||||
{option.label}
|
|
||||||
</label>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<LabeledInputField
|
|
||||||
id="priorities"
|
|
||||||
name="priorities"
|
|
||||||
label={m["skin_priorities"]()}
|
|
||||||
placeholder={m["skin_prioritiesPlaceholder"]()}
|
|
||||||
className="space-y-1 col-span-2"
|
|
||||||
bind:value={prioritiesRaw}
|
|
||||||
/>
|
|
||||||
<LabeledInputField
|
|
||||||
id="notes"
|
|
||||||
name="notes"
|
|
||||||
label={m.skin_notes()}
|
|
||||||
className="space-y-1 col-span-2"
|
|
||||||
bind:value={notes}
|
|
||||||
/>
|
|
||||||
<div class="col-span-2">
|
|
||||||
<Button type="submit">{m["skin_addSnapshot"]()}</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="space-y-4 reveal-2">
|
<div class="space-y-4 reveal-2">
|
||||||
{#each sortedSnapshots as snap (snap.id)}
|
{#each sortedSnapshots as snap (snap.id)}
|
||||||
|
|
@ -564,7 +301,7 @@
|
||||||
<div class="flex flex-wrap items-center gap-1.5">
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
{#if snap.overall_state}
|
{#if snap.overall_state}
|
||||||
<span class={statePills[snap.overall_state] ?? 'state-pill'}>
|
<span class={statePills[snap.overall_state] ?? 'state-pill'}>
|
||||||
{stateLabels[snap.overall_state]?.() ?? snap.overall_state}
|
{getOverallSkinStateLabel(snap.overall_state)}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if snap.texture}
|
{#if snap.texture}
|
||||||
|
|
@ -596,7 +333,7 @@
|
||||||
{#if snap.active_concerns.length}
|
{#if snap.active_concerns.length}
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
{#each snap.active_concerns as c (c)}
|
{#each snap.active_concerns as c (c)}
|
||||||
<Badge variant="secondary" class="text-xs">{concernLabels[c]?.() ?? c.replace(/_/g, ' ')}</Badge>
|
<Badge variant="secondary" class="text-xs">{getSkinConcernLabel(c)}</Badge>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
62
frontend/src/routes/skin/new/+page.server.ts
Normal file
62
frontend/src/routes/skin/new/+page.server.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { createSkinSnapshot } from '$lib/api';
|
||||||
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
return { today: new Date().toISOString().slice(0, 10) };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async ({ request }) => {
|
||||||
|
const form = await request.formData();
|
||||||
|
const snapshot_date = form.get('snapshot_date') as string;
|
||||||
|
const overall_state = form.get('overall_state') as string;
|
||||||
|
const texture = form.get('texture') as string;
|
||||||
|
const notes = form.get('notes') as string;
|
||||||
|
const hydration_level = form.get('hydration_level') as string;
|
||||||
|
const sensitivity_level = form.get('sensitivity_level') as string;
|
||||||
|
const barrier_state = form.get('barrier_state') as string;
|
||||||
|
const active_concerns_values = form
|
||||||
|
.getAll('active_concerns')
|
||||||
|
.map((value) => String(value).trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const priorities_raw = form.get('priorities') as string;
|
||||||
|
|
||||||
|
if (!snapshot_date) {
|
||||||
|
return fail(400, { error: 'Date is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorities =
|
||||||
|
priorities_raw
|
||||||
|
?.split(',')
|
||||||
|
.map((priority) => priority.trim())
|
||||||
|
.filter(Boolean) ?? [];
|
||||||
|
|
||||||
|
const skin_type = form.get('skin_type') as string;
|
||||||
|
const sebum_tzone = form.get('sebum_tzone') as string;
|
||||||
|
const sebum_cheeks = form.get('sebum_cheeks') as string;
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
snapshot_date,
|
||||||
|
active_concerns: active_concerns_values,
|
||||||
|
priorities
|
||||||
|
};
|
||||||
|
if (overall_state) body.overall_state = overall_state;
|
||||||
|
if (texture) body.texture = texture;
|
||||||
|
if (notes) body.notes = notes;
|
||||||
|
if (hydration_level) body.hydration_level = Number(hydration_level);
|
||||||
|
if (sensitivity_level) body.sensitivity_level = Number(sensitivity_level);
|
||||||
|
if (barrier_state) body.barrier_state = barrier_state;
|
||||||
|
if (skin_type) body.skin_type = skin_type;
|
||||||
|
if (sebum_tzone) body.sebum_tzone = Number(sebum_tzone);
|
||||||
|
if (sebum_cheeks) body.sebum_cheeks = Number(sebum_cheeks);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createSkinSnapshot(body);
|
||||||
|
} catch (error) {
|
||||||
|
return fail(500, { error: (error as Error).message });
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(303, '/skin');
|
||||||
|
}
|
||||||
|
};
|
||||||
267
frontend/src/routes/skin/new/+page.svelte
Normal file
267
frontend/src/routes/skin/new/+page.svelte
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { analyzeSkinPhotos } from '$lib/api';
|
||||||
|
import FlashMessages from '$lib/components/FlashMessages.svelte';
|
||||||
|
import LabeledInputField from '$lib/components/forms/LabeledInputField.svelte';
|
||||||
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
|
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import type { OverallSkinState } from '$lib/types';
|
||||||
|
import { getOverallSkinStateLabel, getSkinConcernLabel } from '$lib/utils/skin-display';
|
||||||
|
import type { ActionData, PageData } from './$types';
|
||||||
|
import { Sparkles, X } from 'lucide-svelte';
|
||||||
|
|
||||||
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
|
const states = ['excellent', 'good', 'fair', 'poor'];
|
||||||
|
const skinTextures = ['smooth', 'rough', 'flaky', 'bumpy'];
|
||||||
|
const barrierStates = ['intact', 'mildly_compromised', 'compromised'];
|
||||||
|
const skinTypes = ['dry', 'oily', 'combination', 'sensitive', 'normal', 'acne_prone'];
|
||||||
|
const activeConcernValues = [
|
||||||
|
'acne',
|
||||||
|
'rosacea',
|
||||||
|
'hyperpigmentation',
|
||||||
|
'aging',
|
||||||
|
'dehydration',
|
||||||
|
'redness',
|
||||||
|
'damaged_barrier',
|
||||||
|
'pore_visibility',
|
||||||
|
'uneven_texture',
|
||||||
|
'sebum_excess'
|
||||||
|
];
|
||||||
|
|
||||||
|
const textureLabels: Record<string, () => string> = {
|
||||||
|
smooth: m['skin_textureSmooth'],
|
||||||
|
rough: m['skin_textureRough'],
|
||||||
|
flaky: m['skin_textureFlaky'],
|
||||||
|
bumpy: m['skin_textureBumpy']
|
||||||
|
};
|
||||||
|
|
||||||
|
const barrierLabels: Record<string, () => string> = {
|
||||||
|
intact: m['skin_barrierIntact'],
|
||||||
|
mildly_compromised: m['skin_barrierMildly'],
|
||||||
|
compromised: m['skin_barrierCompromised']
|
||||||
|
};
|
||||||
|
|
||||||
|
const skinTypeLabels: Record<string, () => string> = {
|
||||||
|
dry: m['skin_typeDry'],
|
||||||
|
oily: m['skin_typeOily'],
|
||||||
|
combination: m['skin_typeCombination'],
|
||||||
|
sensitive: m['skin_typeSensitive'],
|
||||||
|
normal: m['skin_typeNormal'],
|
||||||
|
acne_prone: m['skin_typeAcneProne']
|
||||||
|
};
|
||||||
|
|
||||||
|
let snapshotDate = $state('');
|
||||||
|
let overallState = $state('');
|
||||||
|
let texture = $state('');
|
||||||
|
let barrierState = $state('');
|
||||||
|
let skinType = $state('');
|
||||||
|
let hydrationLevel = $state('');
|
||||||
|
let sensitivityLevel = $state('');
|
||||||
|
let sebumTzone = $state('');
|
||||||
|
let sebumCheeks = $state('');
|
||||||
|
let activeConcerns = $state<string[]>([]);
|
||||||
|
let prioritiesRaw = $state('');
|
||||||
|
let notes = $state('');
|
||||||
|
|
||||||
|
let aiModalOpen = $state(false);
|
||||||
|
let selectedFiles = $state<File[]>([]);
|
||||||
|
let previewUrls = $state<string[]>([]);
|
||||||
|
let aiLoading = $state(false);
|
||||||
|
let aiError = $state('');
|
||||||
|
|
||||||
|
const flashMessages = $derived(form?.error ? [{ kind: 'error' as const, text: form.error }] : []);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!snapshotDate) {
|
||||||
|
snapshotDate = data.today;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const overallStateOptions = $derived(
|
||||||
|
states.map((state) => ({ value: state, label: getOverallSkinStateLabel(state as OverallSkinState) }))
|
||||||
|
);
|
||||||
|
const textureOptions = $derived(skinTextures.map((entry) => ({ value: entry, label: textureLabels[entry]?.() ?? entry })));
|
||||||
|
const skinTypeOptions = $derived(skinTypes.map((entry) => ({ value: entry, label: skinTypeLabels[entry]?.() ?? entry })));
|
||||||
|
const barrierOptions = $derived(barrierStates.map((entry) => ({ value: entry, label: barrierLabels[entry]?.() ?? entry })));
|
||||||
|
const activeConcernOptions = $derived(
|
||||||
|
activeConcernValues.map((value) => ({ value, label: getSkinConcernLabel(value) }))
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleFileSelect(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const files = Array.from(input.files ?? []).slice(0, 3);
|
||||||
|
selectedFiles = files;
|
||||||
|
previewUrls.forEach(URL.revokeObjectURL);
|
||||||
|
previewUrls = files.map((file) => URL.createObjectURL(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function analyzePhotos() {
|
||||||
|
if (!selectedFiles.length) return;
|
||||||
|
aiLoading = true;
|
||||||
|
aiError = '';
|
||||||
|
try {
|
||||||
|
const result = await analyzeSkinPhotos(selectedFiles);
|
||||||
|
if (result.overall_state) overallState = result.overall_state;
|
||||||
|
if (result.texture) texture = result.texture;
|
||||||
|
if (result.skin_type) skinType = result.skin_type;
|
||||||
|
if (result.barrier_state) barrierState = result.barrier_state;
|
||||||
|
if (result.hydration_level != null) hydrationLevel = String(result.hydration_level);
|
||||||
|
if (result.sensitivity_level != null) sensitivityLevel = String(result.sensitivity_level);
|
||||||
|
if (result.sebum_tzone != null) sebumTzone = String(result.sebum_tzone);
|
||||||
|
if (result.sebum_cheeks != null) sebumCheeks = String(result.sebum_cheeks);
|
||||||
|
if (result.active_concerns?.length) activeConcerns = [...result.active_concerns];
|
||||||
|
if (result.priorities?.length) prioritiesRaw = result.priorities.join(', ');
|
||||||
|
if (result.notes) notes = result.notes;
|
||||||
|
aiModalOpen = false;
|
||||||
|
} catch (error) {
|
||||||
|
aiError = (error 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_newSnapshotTitle']()} — innercontext</title></svelte:head>
|
||||||
|
<svelte:window onkeydown={handleModalKeydown} />
|
||||||
|
|
||||||
|
<div class="editorial-page space-y-4">
|
||||||
|
<PageHeader
|
||||||
|
title={m['skin_newSnapshotTitle']()}
|
||||||
|
kicker={m['nav_appSubtitle']()}
|
||||||
|
backHref={resolve('/skin')}
|
||||||
|
backLabel={m.skin_title()}
|
||||||
|
subtitle={m['skin_newSubtitle']()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FlashMessages messages={flashMessages} />
|
||||||
|
|
||||||
|
{#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['skin_aiAnalysisTitle']()}</CardTitle>
|
||||||
|
<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>
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
{#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 border object-cover" />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if aiError}
|
||||||
|
<p class="text-sm text-destructive">{aiError}</p>
|
||||||
|
{/if}
|
||||||
|
<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>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Card class="reveal-2">
|
||||||
|
<CardHeader><CardTitle>{m['skin_newSnapshotTitle']()}</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form method="POST" class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<p class="col-span-2 text-sm leading-6 text-muted-foreground">{m['skin_newSectionIntro']()}</p>
|
||||||
|
<div class="col-span-2 flex flex-wrap items-center justify-between gap-2 rounded-xl border border-dashed border-border/80 bg-muted/20 px-3 py-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-sm font-medium">{m['skin_aiAnalysisTitle']()}</p>
|
||||||
|
<p class="text-sm text-muted-foreground">{m['skin_aiUploadText']()}</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" size="sm" onclick={openAiModal}>
|
||||||
|
<Sparkles class="size-4" />
|
||||||
|
{m['skin_analyzePhotos']()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LabeledInputField id="snapshot_date" name="snapshot_date" label={m.skin_date()} type="date" required bind:value={snapshotDate} />
|
||||||
|
<SimpleSelect id="overall_state" name="overall_state" label={m['skin_overallState']()} options={overallStateOptions} placeholder={m.common_select()} bind:value={overallState} />
|
||||||
|
<SimpleSelect id="texture" name="texture" label={m.skin_texture()} options={textureOptions} placeholder={m.common_select()} bind:value={texture} />
|
||||||
|
<SimpleSelect id="skin_type" name="skin_type" label={m['skin_skinType']()} options={skinTypeOptions} placeholder={m.common_select()} bind:value={skinType} />
|
||||||
|
<SimpleSelect id="barrier_state" name="barrier_state" label={m['skin_barrierState']()} options={barrierOptions} placeholder={m.common_select()} bind:value={barrierState} />
|
||||||
|
<LabeledInputField id="hydration_level" name="hydration_level" label={m.skin_hydration()} type="number" min="1" max="5" bind:value={hydrationLevel} />
|
||||||
|
<LabeledInputField id="sensitivity_level" name="sensitivity_level" label={m.skin_sensitivity()} type="number" min="1" max="5" bind:value={sensitivityLevel} />
|
||||||
|
<LabeledInputField id="sebum_tzone" name="sebum_tzone" label={m['skin_sebumTzone']()} type="number" min="1" max="5" bind:value={sebumTzone} />
|
||||||
|
<LabeledInputField id="sebum_cheeks" name="sebum_cheeks" label={m['skin_sebumCheeks']()} type="number" min="1" max="5" bind:value={sebumCheeks} />
|
||||||
|
|
||||||
|
<div class="col-span-2 space-y-2">
|
||||||
|
<p class="text-sm font-medium">{m['skin_activeConcerns']()}</p>
|
||||||
|
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
|
{#each activeConcernOptions as option (option.value)}
|
||||||
|
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="active_concerns"
|
||||||
|
value={option.value}
|
||||||
|
checked={activeConcerns.includes(option.value)}
|
||||||
|
onchange={() => {
|
||||||
|
if (activeConcerns.includes(option.value)) {
|
||||||
|
activeConcerns = activeConcerns.filter((entry) => entry !== option.value);
|
||||||
|
} else {
|
||||||
|
activeConcerns = [...activeConcerns, option.value];
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="rounded border-input"
|
||||||
|
/>
|
||||||
|
{option.label}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LabeledInputField id="priorities" name="priorities" label={m['skin_priorities']()} placeholder={m['skin_prioritiesPlaceholder']()} className="col-span-2 space-y-1" bind:value={prioritiesRaw} />
|
||||||
|
<LabeledInputField id="notes" name="notes" label={m.skin_notes()} className="col-span-2 space-y-1" bind:value={notes} />
|
||||||
|
|
||||||
|
<div class="col-span-2 flex items-center justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" href={resolve('/skin')}>{m.common_cancel()}</Button>
|
||||||
|
<Button type="submit">{m['skin_addSnapshot']()}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue