feat: custom meal detail screen, favorites tab, enhanced food search detail
- Replace inline amount row with full detail screen for custom meals showing calorie headline, macro rings, macro breakdown, and ingredients - Add Favorites tab (with filter) between Search and Custom Meals tabs - Search tab no longer prepends unmatched favorites - Enhance FoodSearch selected view with macro rings and nutrient breakdown - Add filter input to custom meals tab - Document --color-primary/--color-text-on-primary in CLAUDE.md
This commit is contained in:
@@ -51,6 +51,12 @@ After completing the code, ask the user if they want a playground link. Only cal
|
||||
- **NEVER** write `@media (prefers-color-scheme: dark)` or `:global(:root[data-theme="dark"])` override blocks — semantic variables handle both themes automatically
|
||||
- **NEVER** use `var(--font-default-dark)` or `var(--accent-dark)` — these are legacy
|
||||
|
||||
### Primary interactive elements
|
||||
- Background: `var(--color-primary)` (nord10 light / nord8 dark)
|
||||
- Hover: `var(--color-primary-hover)`
|
||||
- Active: `var(--color-primary-active)`
|
||||
- Text on primary bg: `var(--color-text-on-primary)`
|
||||
|
||||
### Accent colors (OK to use directly, they work in both themes)
|
||||
- `var(--blue)`, `var(--red)`, `var(--green)`, `var(--orange)` — named accent colors
|
||||
- `var(--nord10)`, `var(--nord11)`, `var(--nord12)`, `var(--nord14)` — OK for hover states of accent-colored buttons only
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.16.0",
|
||||
"version": "1.17.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* showDetailLinks?: boolean,
|
||||
* autofocus?: boolean,
|
||||
* confirmLabel?: string,
|
||||
* initialResults?: any[],
|
||||
* }}
|
||||
*/
|
||||
let {
|
||||
@@ -21,6 +22,7 @@
|
||||
showDetailLinks = true,
|
||||
autofocus = false,
|
||||
confirmLabel = undefined,
|
||||
initialResults = undefined,
|
||||
} = $props();
|
||||
|
||||
const lang = $derived(detectFitnessLang($page.url.pathname));
|
||||
@@ -28,11 +30,27 @@
|
||||
const isEn = $derived(lang === 'en');
|
||||
const btnLabel = $derived(confirmLabel ?? t('log_food', lang));
|
||||
|
||||
// SVG ring constants
|
||||
const RADIUS = 28;
|
||||
const ARC_DEGREES = 300;
|
||||
const ARC_LENGTH = (ARC_DEGREES / 360) * 2 * Math.PI * RADIUS;
|
||||
const ARC_ROTATE = 120;
|
||||
function strokeOffset(percent) {
|
||||
return ARC_LENGTH - (Math.min(percent, 100) / 100) * ARC_LENGTH;
|
||||
}
|
||||
|
||||
// --- Search state ---
|
||||
let query = $state('');
|
||||
let results = $state([]);
|
||||
let results = $state(initialResults ?? []);
|
||||
let loading = $state(false);
|
||||
let timeout = $state(null);
|
||||
const isPrefilledMode = $derived(initialResults != null);
|
||||
let filterQuery = $state('');
|
||||
const displayResults = $derived(
|
||||
isPrefilledMode && filterQuery
|
||||
? results.filter(r => r.name.toLowerCase().includes(filterQuery.toLowerCase()))
|
||||
: results
|
||||
);
|
||||
|
||||
// --- Selection state ---
|
||||
let selected = $state(null);
|
||||
@@ -92,6 +110,37 @@
|
||||
return qty;
|
||||
});
|
||||
|
||||
/** Scaled nutrient values for the preview */
|
||||
const previewNutrients = $derived.by(() => {
|
||||
if (!selected?.per100g || !previewGrams) return null;
|
||||
const s = previewGrams / 100;
|
||||
const n = selected.per100g;
|
||||
return {
|
||||
calories: Math.round((n.calories ?? 0) * s),
|
||||
protein: (n.protein ?? 0) * s,
|
||||
fat: (n.fat ?? 0) * s,
|
||||
carbs: (n.carbs ?? 0) * s,
|
||||
saturatedFat: (n.saturatedFat ?? 0) * s,
|
||||
sugars: (n.sugars ?? 0) * s,
|
||||
fiber: (n.fiber ?? 0) * s,
|
||||
};
|
||||
});
|
||||
|
||||
const macroPercent = $derived.by(() => {
|
||||
if (!selected?.per100g) return { protein: 0, fat: 0, carbs: 0 };
|
||||
const n = selected.per100g;
|
||||
const proteinCal = (n.protein ?? 0) * 4;
|
||||
const fatCal = (n.fat ?? 0) * 9;
|
||||
const carbsCal = (n.carbs ?? 0) * 4;
|
||||
const total = proteinCal + fatCal + carbsCal;
|
||||
if (total === 0) return { protein: 0, fat: 0, carbs: 0 };
|
||||
return {
|
||||
protein: Math.round(proteinCal / total * 100),
|
||||
fat: Math.round(fatCal / total * 100),
|
||||
carbs: 100 - Math.round(proteinCal / total * 100) - Math.round(fatCal / total * 100),
|
||||
};
|
||||
});
|
||||
|
||||
function confirm() {
|
||||
if (!selected) return;
|
||||
const grams = resolveGrams();
|
||||
@@ -354,36 +403,47 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else if !selected}
|
||||
<div class="fs-search-row">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
class="fs-search-input"
|
||||
placeholder={t('search_food', lang)}
|
||||
bind:value={query}
|
||||
oninput={doSearch}
|
||||
autofocus={autofocus}
|
||||
/>
|
||||
{#if query}
|
||||
<button class="fs-clear-btn" onclick={() => { query = ''; results = []; }} aria-label="Clear">
|
||||
<X size={16} />
|
||||
</button>
|
||||
{#if isPrefilledMode}
|
||||
{#if results.length > 3}
|
||||
<input
|
||||
type="text"
|
||||
class="fs-filter-input"
|
||||
placeholder={isEn ? 'Filter…' : 'Filtern…'}
|
||||
bind:value={filterQuery}
|
||||
/>
|
||||
{/if}
|
||||
{#if browser}
|
||||
<button class="fs-barcode-btn" onclick={startScan} aria-label={isEn ? 'Scan barcode' : 'Barcode scannen'}>
|
||||
<ScanBarcode size={20} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="fs-search-row">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
class="fs-search-input"
|
||||
placeholder={t('search_food', lang)}
|
||||
bind:value={query}
|
||||
oninput={doSearch}
|
||||
autofocus={autofocus}
|
||||
/>
|
||||
{#if query}
|
||||
<button class="fs-clear-btn" onclick={() => { query = ''; results = []; }} aria-label="Clear">
|
||||
<X size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if browser}
|
||||
<button class="fs-barcode-btn" onclick={startScan} aria-label={isEn ? 'Scan barcode' : 'Barcode scannen'}>
|
||||
<ScanBarcode size={20} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if scanError}
|
||||
<p class="fs-scan-error">{scanError}</p>
|
||||
{/if}
|
||||
{#if loading}
|
||||
<p class="fs-status">{t('loading', lang)}</p>
|
||||
{/if}
|
||||
{#if results.length > 0}
|
||||
{#if displayResults.length > 0}
|
||||
<div class="fs-results">
|
||||
{#each results as item}
|
||||
{#each displayResults as item}
|
||||
<div class="fs-result-row">
|
||||
{#if showFavorites}
|
||||
<button class="fs-fav" class:is-fav={item.favorited} onclick={() => toggleFavorite(item)} aria-label="Toggle favorite">
|
||||
@@ -414,7 +474,7 @@
|
||||
<button class="fs-btn-cancel" onclick={oncancel}>{t('cancel', lang)}</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Selected food — amount & portion -->
|
||||
<!-- Selected food — detail & amount -->
|
||||
<div class="fs-selected">
|
||||
<div class="fs-selected-header">
|
||||
<span class="fs-selected-name">
|
||||
@@ -425,6 +485,8 @@
|
||||
<span class="fs-selected-brands">{selected.brands}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Amount selector -->
|
||||
<div class="fs-amount-row">
|
||||
<input
|
||||
type="number"
|
||||
@@ -451,17 +513,70 @@
|
||||
<span class="fs-unit-label">g</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if previewGrams > 0}
|
||||
<div class="fs-preview">
|
||||
{#if portionIdx >= 0}
|
||||
<span class="fs-preview-grams">{previewGrams}g</span>
|
||||
{/if}
|
||||
<span class="fs-preview-cal">{Math.round((selected.per100g?.calories ?? 0) * previewGrams / 100)} <small>kcal</small></span>
|
||||
<span class="fs-preview-p">{fmt((selected.per100g?.protein ?? 0) * previewGrams / 100)}g P</span>
|
||||
<span class="fs-preview-f">{fmt((selected.per100g?.fat ?? 0) * previewGrams / 100)}g F</span>
|
||||
<span class="fs-preview-c">{fmt((selected.per100g?.carbs ?? 0) * previewGrams / 100)}g C</span>
|
||||
{#if portionIdx >= 0 && previewGrams > 0}
|
||||
<span class="fs-detail-hint">= {previewGrams}g</span>
|
||||
{/if}
|
||||
|
||||
{#if previewNutrients}
|
||||
<!-- Calorie headline -->
|
||||
<div class="fs-detail-cal">
|
||||
<span class="fs-detail-cal-num">{previewNutrients.calories}</span>
|
||||
<span class="fs-detail-cal-unit">kcal</span>
|
||||
</div>
|
||||
|
||||
<!-- Macro rings -->
|
||||
<div class="fs-detail-macros">
|
||||
{#each [
|
||||
{ pct: macroPercent.protein, label: isEn ? 'Protein' : 'Eiweiß', cls: 'fs-ring-protein', grams: previewNutrients.protein },
|
||||
{ pct: macroPercent.fat, label: isEn ? 'Fat' : 'Fett', cls: 'fs-ring-fat', grams: previewNutrients.fat },
|
||||
{ pct: macroPercent.carbs, label: isEn ? 'Carbs' : 'Kohlenh.', cls: 'fs-ring-carbs', grams: previewNutrients.carbs },
|
||||
] as macro (macro.cls)}
|
||||
<div class="fs-detail-macro">
|
||||
<svg width="72" height="72" viewBox="0 0 70 70">
|
||||
<circle class="fs-ring-bg" cx="35" cy="35" r={RADIUS}
|
||||
stroke-dasharray="{ARC_LENGTH} {2 * Math.PI * RADIUS}"
|
||||
transform="rotate({ARC_ROTATE} 35 35)" />
|
||||
<circle class="fs-ring-fill {macro.cls}" cx="35" cy="35" r={RADIUS}
|
||||
stroke-dasharray="{ARC_LENGTH} {2 * Math.PI * RADIUS}"
|
||||
stroke-dashoffset={strokeOffset(macro.pct)}
|
||||
transform="rotate({ARC_ROTATE} 35 35)" />
|
||||
<text class="fs-ring-text" x="35" y="35">{macro.pct}%</text>
|
||||
</svg>
|
||||
<span class="fs-detail-macro-label">{macro.label}</span>
|
||||
<span class="fs-detail-macro-val">{fmt(macro.grams)}g</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Macro detail rows -->
|
||||
<div class="fs-detail-rows">
|
||||
<div class="fs-detail-row">
|
||||
<span>{isEn ? 'Protein' : 'Eiweiß'}</span>
|
||||
<span>{fmt(previewNutrients.protein)} g</span>
|
||||
</div>
|
||||
<div class="fs-detail-row">
|
||||
<span>{isEn ? 'Fat' : 'Fett'}</span>
|
||||
<span>{fmt(previewNutrients.fat)} g</span>
|
||||
</div>
|
||||
<div class="fs-detail-row sub">
|
||||
<span>{isEn ? 'Saturated Fat' : 'Ges. Fettsäuren'}</span>
|
||||
<span>{fmt(previewNutrients.saturatedFat)} g</span>
|
||||
</div>
|
||||
<div class="fs-detail-row">
|
||||
<span>{isEn ? 'Carbohydrates' : 'Kohlenhydrate'}</span>
|
||||
<span>{fmt(previewNutrients.carbs)} g</span>
|
||||
</div>
|
||||
<div class="fs-detail-row sub">
|
||||
<span>{isEn ? 'Sugars' : 'Zucker'}</span>
|
||||
<span>{fmt(previewNutrients.sugars)} g</span>
|
||||
</div>
|
||||
<div class="fs-detail-row">
|
||||
<span>{isEn ? 'Fiber' : 'Ballaststoffe'}</span>
|
||||
<span>{fmt(previewNutrients.fiber)} g</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="fs-actions">
|
||||
<button class="fs-btn-cancel" onclick={() => { selected = null; }}>{t('cancel', lang)}</button>
|
||||
<button class="fs-btn-confirm" onclick={confirm}>{btnLabel}</button>
|
||||
@@ -488,6 +603,23 @@
|
||||
transition: border-color 0.15s;
|
||||
min-width: 0;
|
||||
}
|
||||
.fs-filter-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.65rem;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9rem;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.15s;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.fs-filter-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.fs-search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
@@ -786,24 +918,97 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* ── Preview ── */
|
||||
.fs-preview {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 8px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.fs-preview-grams {
|
||||
/* ── Detail view ── */
|
||||
.fs-detail-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.fs-preview-cal { font-weight: 700; color: var(--color-text-primary); }
|
||||
.fs-preview-cal small { font-weight: 500; color: var(--color-text-secondary); }
|
||||
.fs-preview-p { color: var(--nord14); font-weight: 600; }
|
||||
.fs-preview-f { color: var(--nord12); font-weight: 600; }
|
||||
.fs-preview-c { color: var(--nord9); font-weight: 600; }
|
||||
.fs-detail-cal {
|
||||
text-align: center;
|
||||
margin: 0.25rem 0 0.25rem;
|
||||
}
|
||||
.fs-detail-cal-num {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 800;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
.fs-detail-cal-unit {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
margin-left: 0.2rem;
|
||||
}
|
||||
.fs-detail-macros {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
}
|
||||
.fs-detail-macro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
flex: 1;
|
||||
}
|
||||
.fs-detail-macro-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
.fs-detail-macro-val {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.fs-ring-bg {
|
||||
fill: none;
|
||||
stroke: var(--color-border);
|
||||
stroke-width: 5;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.fs-ring-fill {
|
||||
fill: none;
|
||||
stroke-width: 5;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 0.4s ease;
|
||||
}
|
||||
.fs-ring-text {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
fill: currentColor;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
}
|
||||
.fs-ring-protein { stroke: var(--nord14); }
|
||||
.fs-ring-fat { stroke: var(--nord12); }
|
||||
.fs-ring-carbs { stroke: var(--nord9); }
|
||||
.fs-detail-rows {
|
||||
background: var(--color-surface);
|
||||
border-radius: 10px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.fs-detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.3rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.fs-detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.fs-detail-row.sub span:first-child {
|
||||
padding-left: 0.75rem;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.fs-detail-row span:last-child {
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
.fs-actions {
|
||||
@@ -814,12 +1019,13 @@
|
||||
.fs-btn-cancel {
|
||||
padding: 0.5rem 1.1rem;
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.fs-btn-cancel:hover {
|
||||
@@ -827,17 +1033,18 @@
|
||||
}
|
||||
.fs-btn-confirm {
|
||||
padding: 0.5rem 1.1rem;
|
||||
background: var(--nord8);
|
||||
color: white;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-on-primary);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
flex: 2;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
.fs-btn-confirm:hover {
|
||||
background: var(--nord10);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.fs-btn-confirm:active {
|
||||
transform: scale(0.97);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { ChevronLeft, ChevronRight, Plus, Trash2, ChevronDown, Settings, Coffee, Sun, Moon, Cookie, Utensils, Info, UtensilsCrossed, AlertTriangle, Check, GlassWater, Pencil, Heart, Clock } from '@lucide/svelte';
|
||||
import { ChevronLeft, ChevronRight, Plus, Trash2, ChevronDown, Settings, Coffee, Sun, Moon, Cookie, Utensils, Info, UtensilsCrossed, AlertTriangle, Check, GlassWater, Pencil, Heart, Clock, Search } from '@lucide/svelte';
|
||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||
import AddButton from '$lib/components/AddButton.svelte';
|
||||
import FoodSearch from '$lib/components/fitness/FoodSearch.svelte';
|
||||
@@ -489,7 +489,7 @@
|
||||
|
||||
// --- Inline add food ---
|
||||
let addingMeal = $state(null);
|
||||
let inlineTab = $state('search'); // 'search' | 'meals'
|
||||
let inlineTab = $state('search'); // 'search' | 'favorites' | 'meals'
|
||||
|
||||
// --- FAB modal (route-based via ?add param) ---
|
||||
const showFabModal = $derived($page.url.searchParams.has('add'));
|
||||
@@ -515,6 +515,7 @@
|
||||
});
|
||||
|
||||
function closeFabModal() {
|
||||
selectedCmMeal = null;
|
||||
goto(`/fitness/${s.nutrition}`, { replaceState: true, keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
@@ -544,10 +545,107 @@
|
||||
}
|
||||
|
||||
// --- Custom meals in FAB ---
|
||||
let fabTab = $state('search'); // 'search' | 'meals'
|
||||
let fabTab = $state('search'); // 'search' | 'favorites' | 'meals'
|
||||
|
||||
// --- Favorites tab ---
|
||||
let favTabItems = $state([]); // enriched with per100g
|
||||
let favTabLoaded = $state(false);
|
||||
|
||||
async function loadFavTab(force = false) {
|
||||
if (favTabLoaded && !force) return;
|
||||
const favs = quickFavorites;
|
||||
// Fetch per100g for each favorite in parallel
|
||||
const enriched = await Promise.all(favs.map(async (fav) => {
|
||||
try {
|
||||
const res = await fetch(`/api/nutrition/lookup?source=${fav.source}&id=${encodeURIComponent(fav.sourceId)}`);
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
return {
|
||||
name: fav.name,
|
||||
source: fav.source,
|
||||
id: fav.sourceId,
|
||||
per100g: d.per100g,
|
||||
portions: d.portions,
|
||||
calories: Math.round(d.per100g?.calories ?? 0),
|
||||
favorited: true,
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}));
|
||||
favTabItems = enriched.filter(Boolean);
|
||||
favTabLoaded = true;
|
||||
}
|
||||
let customMeals = $state([]);
|
||||
let customMealsLoaded = $state(false);
|
||||
|
||||
// Custom meal filter
|
||||
let cmFilter = $state('');
|
||||
const filteredCustomMeals = $derived(
|
||||
cmFilter
|
||||
? customMeals.filter(cm => cm.name.toLowerCase().includes(cmFilter.toLowerCase()))
|
||||
: customMeals
|
||||
);
|
||||
|
||||
// Custom meal detail screen (replaces meal list when a meal is selected)
|
||||
let selectedCmMeal = $state(null);
|
||||
let cmAmountMode = $state('multiplier'); // 'multiplier' | 'grams'
|
||||
let cmAmountVal = $state(1.0);
|
||||
|
||||
function selectCmMeal(meal) {
|
||||
selectedCmMeal = meal;
|
||||
cmAmountMode = 'multiplier';
|
||||
cmAmountVal = 1.0;
|
||||
}
|
||||
|
||||
function deselectCmMeal() {
|
||||
selectedCmMeal = null;
|
||||
}
|
||||
|
||||
function cmResolvedGrams(meal) {
|
||||
const base = mealTotalGrams(meal);
|
||||
if (cmAmountMode === 'grams') return cmAmountVal;
|
||||
return base * cmAmountVal;
|
||||
}
|
||||
|
||||
/** Preview macros scaled to the selected amount */
|
||||
function cmPreview(meal) {
|
||||
const { per100g, totalGrams } = aggregateMealPer100g(meal);
|
||||
const grams = cmResolvedGrams(meal);
|
||||
const s = grams / 100;
|
||||
return {
|
||||
calories: Math.round(per100g.calories * s),
|
||||
protein: per100g.protein * s,
|
||||
fat: per100g.fat * s,
|
||||
carbs: per100g.carbs * s,
|
||||
fiber: per100g.fiber * s,
|
||||
sugars: per100g.sugars * s,
|
||||
saturatedFat: per100g.saturatedFat * s,
|
||||
grams,
|
||||
totalGrams,
|
||||
};
|
||||
}
|
||||
|
||||
function cmMacroPercent(meal) {
|
||||
const { per100g } = aggregateMealPer100g(meal);
|
||||
const proteinCal = per100g.protein * 4;
|
||||
const fatCal = per100g.fat * 9;
|
||||
const carbsCal = per100g.carbs * 4;
|
||||
const total = proteinCal + fatCal + carbsCal;
|
||||
if (total === 0) return { protein: 0, fat: 0, carbs: 0 };
|
||||
return {
|
||||
protein: Math.round(proteinCal / total * 100),
|
||||
fat: Math.round(fatCal / total * 100),
|
||||
carbs: 100 - Math.round(proteinCal / total * 100) - Math.round(fatCal / total * 100),
|
||||
};
|
||||
}
|
||||
|
||||
function fmtNutrient(v) {
|
||||
if (v == null || isNaN(v)) return '0';
|
||||
if (v >= 100) return Math.round(v).toString();
|
||||
return v.toFixed(1);
|
||||
}
|
||||
|
||||
async function loadCustomMeals() {
|
||||
if (customMealsLoaded) return;
|
||||
try {
|
||||
@@ -560,36 +658,45 @@
|
||||
customMealsLoaded = true;
|
||||
}
|
||||
|
||||
const NUTRIENT_KEYS = ['calories', 'protein', 'fat', 'saturatedFat', 'carbs', 'fiber', 'sugars',
|
||||
'calcium', 'iron', 'magnesium', 'phosphorus', 'potassium', 'sodium', 'zinc',
|
||||
'vitaminA', 'vitaminC', 'vitaminD', 'vitaminE', 'vitaminK',
|
||||
'thiamin', 'riboflavin', 'niacin', 'vitaminB6', 'vitaminB12', 'folate', 'cholesterol',
|
||||
'isoleucine', 'leucine', 'lysine', 'methionine', 'phenylalanine', 'threonine',
|
||||
'tryptophan', 'valine', 'histidine', 'alanine', 'arginine', 'asparticAcid',
|
||||
'cysteine', 'glutamicAcid', 'glycine', 'proline', 'serine', 'tyrosine'];
|
||||
|
||||
function mealTotalGrams(meal) {
|
||||
return meal.ingredients.reduce((sum, ing) => sum + ing.amountGrams, 0);
|
||||
}
|
||||
|
||||
function mealTotalCal(meal) {
|
||||
return meal.ingredients.reduce((sum, ing) => sum + (ing.per100g?.calories ?? 0) * ing.amountGrams / 100, 0);
|
||||
}
|
||||
|
||||
async function logCustomMeal(meal) {
|
||||
try {
|
||||
// Aggregate all ingredients into a single per100g snapshot
|
||||
const totals = {};
|
||||
const nutrientKeys = ['calories', 'protein', 'fat', 'saturatedFat', 'carbs', 'fiber', 'sugars',
|
||||
'calcium', 'iron', 'magnesium', 'phosphorus', 'potassium', 'sodium', 'zinc',
|
||||
'vitaminA', 'vitaminC', 'vitaminD', 'vitaminE', 'vitaminK',
|
||||
'thiamin', 'riboflavin', 'niacin', 'vitaminB6', 'vitaminB12', 'folate', 'cholesterol',
|
||||
'isoleucine', 'leucine', 'lysine', 'methionine', 'phenylalanine', 'threonine',
|
||||
'tryptophan', 'valine', 'histidine', 'alanine', 'arginine', 'asparticAcid',
|
||||
'cysteine', 'glutamicAcid', 'glycine', 'proline', 'serine', 'tyrosine'];
|
||||
for (const k of nutrientKeys) totals[k] = 0;
|
||||
let totalGrams = 0;
|
||||
for (const ing of meal.ingredients) {
|
||||
const r = ing.amountGrams / 100;
|
||||
totalGrams += ing.amountGrams;
|
||||
for (const k of nutrientKeys) totals[k] += (ing.per100g?.[k] ?? 0) * r;
|
||||
}
|
||||
// Convert absolute totals back to per-100g
|
||||
const per100g = {};
|
||||
const scale = totalGrams > 0 ? 100 / totalGrams : 0;
|
||||
for (const k of nutrientKeys) per100g[k] = totals[k] * scale;
|
||||
function aggregateMealPer100g(meal) {
|
||||
const totals = {};
|
||||
for (const k of NUTRIENT_KEYS) totals[k] = 0;
|
||||
let totalGrams = 0;
|
||||
for (const ing of meal.ingredients) {
|
||||
const r = ing.amountGrams / 100;
|
||||
totalGrams += ing.amountGrams;
|
||||
for (const k of NUTRIENT_KEYS) totals[k] += (ing.per100g?.[k] ?? 0) * r;
|
||||
}
|
||||
const per100g = {};
|
||||
const scale = totalGrams > 0 ? 100 / totalGrams : 0;
|
||||
for (const k of NUTRIENT_KEYS) per100g[k] = totals[k] * scale;
|
||||
const liquidMl = meal.ingredients
|
||||
.filter(isLiquidIngredient)
|
||||
.reduce((sum, ing) => sum + ing.amountGrams, 0);
|
||||
return { per100g, totalGrams, liquidMl };
|
||||
}
|
||||
|
||||
const liquidMl = meal.ingredients
|
||||
.filter(isLiquidIngredient)
|
||||
.reduce((sum, ing) => sum + ing.amountGrams, 0);
|
||||
async function logCustomMeal(meal, amountGrams = null) {
|
||||
try {
|
||||
const { per100g, totalGrams, liquidMl } = aggregateMealPer100g(meal);
|
||||
const logGrams = amountGrams ?? totalGrams;
|
||||
const liquidScale = totalGrams > 0 ? logGrams / totalGrams : 0;
|
||||
|
||||
await fetch('/api/fitness/food-log', {
|
||||
method: 'POST',
|
||||
@@ -600,13 +707,14 @@
|
||||
name: meal.name,
|
||||
source: 'custom',
|
||||
sourceId: meal._id,
|
||||
amountGrams: totalGrams,
|
||||
amountGrams: logGrams,
|
||||
per100g,
|
||||
...(liquidMl > 0 && { liquidMl }),
|
||||
...(liquidMl > 0 && { liquidMl: liquidMl * liquidScale }),
|
||||
})
|
||||
});
|
||||
|
||||
await goto(`/fitness/${s.nutrition}?date=${currentDate}`, { replaceState: true, noScroll: true });
|
||||
selectedCmMeal = null;
|
||||
closeFabModal();
|
||||
toast.success(isEn ? `Logged "${meal.name}"` : `"${meal.name}" eingetragen`);
|
||||
} catch {
|
||||
@@ -617,38 +725,23 @@
|
||||
function startAdd(meal) {
|
||||
addingMeal = meal;
|
||||
inlineTab = 'search';
|
||||
selectedCmMeal = null;
|
||||
cmFilter = '';
|
||||
loadCustomMeals();
|
||||
}
|
||||
|
||||
function cancelAdd() {
|
||||
addingMeal = null;
|
||||
selectedCmMeal = null;
|
||||
cmFilter = '';
|
||||
}
|
||||
|
||||
async function inlineLogCustomMeal(meal) {
|
||||
async function inlineLogCustomMeal(meal, amountGrams = null) {
|
||||
if (!addingMeal) return;
|
||||
try {
|
||||
const totals = {};
|
||||
const nutrientKeys = ['calories', 'protein', 'fat', 'saturatedFat', 'carbs', 'fiber', 'sugars',
|
||||
'calcium', 'iron', 'magnesium', 'phosphorus', 'potassium', 'sodium', 'zinc',
|
||||
'vitaminA', 'vitaminC', 'vitaminD', 'vitaminE', 'vitaminK',
|
||||
'thiamin', 'riboflavin', 'niacin', 'vitaminB6', 'vitaminB12', 'folate', 'cholesterol',
|
||||
'isoleucine', 'leucine', 'lysine', 'methionine', 'phenylalanine', 'threonine',
|
||||
'tryptophan', 'valine', 'histidine', 'alanine', 'arginine', 'asparticAcid',
|
||||
'cysteine', 'glutamicAcid', 'glycine', 'proline', 'serine', 'tyrosine'];
|
||||
for (const k of nutrientKeys) totals[k] = 0;
|
||||
let totalGrams = 0;
|
||||
for (const ing of meal.ingredients) {
|
||||
const r = ing.amountGrams / 100;
|
||||
totalGrams += ing.amountGrams;
|
||||
for (const k of nutrientKeys) totals[k] += (ing.per100g?.[k] ?? 0) * r;
|
||||
}
|
||||
const per100g = {};
|
||||
const scale = totalGrams > 0 ? 100 / totalGrams : 0;
|
||||
for (const k of nutrientKeys) per100g[k] = totals[k] * scale;
|
||||
|
||||
const liquidMl = meal.ingredients
|
||||
.filter(isLiquidIngredient)
|
||||
.reduce((sum, ing) => sum + ing.amountGrams, 0);
|
||||
const { per100g, totalGrams, liquidMl } = aggregateMealPer100g(meal);
|
||||
const logGrams = amountGrams ?? totalGrams;
|
||||
const liquidScale = totalGrams > 0 ? logGrams / totalGrams : 0;
|
||||
|
||||
await fetch('/api/fitness/food-log', {
|
||||
method: 'POST',
|
||||
@@ -659,13 +752,14 @@
|
||||
name: meal.name,
|
||||
source: 'custom',
|
||||
sourceId: meal._id,
|
||||
amountGrams: totalGrams,
|
||||
amountGrams: logGrams,
|
||||
per100g,
|
||||
...(liquidMl > 0 && { liquidMl }),
|
||||
...(liquidMl > 0 && { liquidMl: liquidMl * liquidScale }),
|
||||
})
|
||||
});
|
||||
|
||||
await goto(`/fitness/${s.nutrition}?date=${currentDate}`, { replaceState: true, noScroll: true });
|
||||
selectedCmMeal = null;
|
||||
cancelAdd();
|
||||
toast.success(isEn ? `Logged "${meal.name}"` : `"${meal.name}" eingetragen`);
|
||||
} catch {
|
||||
@@ -897,6 +991,150 @@
|
||||
<title>{t('nutrition_title', lang)} — Fitness</title>
|
||||
</svelte:head>
|
||||
|
||||
{#snippet cmDetailScreen(meal, logFn)}
|
||||
{@const preview = cmPreview(meal)}
|
||||
{@const mp = cmMacroPercent(meal)}
|
||||
<div class="cm-detail">
|
||||
<div class="cm-detail-header">
|
||||
<span class="cm-detail-name">{meal.name}</span>
|
||||
<span class="cm-detail-sub">{meal.ingredients.length} {t('ingredients', lang)} · {Math.round(preview.totalGrams)}g {isEn ? 'base' : 'Basis'}</span>
|
||||
</div>
|
||||
|
||||
<!-- Amount selector -->
|
||||
<div class="cm-detail-amount">
|
||||
<input type="number" class="cm-detail-amount-input"
|
||||
value={cmAmountVal}
|
||||
oninput={e => { cmAmountVal = Number(e.currentTarget.value); }}
|
||||
min={cmAmountMode === 'grams' ? 1 : 0.1}
|
||||
step={cmAmountMode === 'grams' ? 1 : 0.1} />
|
||||
<select class="cm-detail-amount-unit" value={cmAmountMode} onchange={e => {
|
||||
cmAmountMode = e.currentTarget.value;
|
||||
cmAmountVal = cmAmountMode === 'multiplier' ? 1.0 : Math.round(mealTotalGrams(meal));
|
||||
}}>
|
||||
<option value="multiplier">× ({isEn ? 'servings' : 'Portionen'})</option>
|
||||
<option value="grams">g</option>
|
||||
</select>
|
||||
</div>
|
||||
{#if cmAmountMode === 'multiplier'}
|
||||
<span class="cm-detail-hint">= {Math.round(preview.grams)}g</span>
|
||||
{/if}
|
||||
|
||||
<!-- Calorie headline -->
|
||||
<div class="cm-detail-cal">
|
||||
<span class="cm-detail-cal-num">{preview.calories}</span>
|
||||
<span class="cm-detail-cal-unit">kcal</span>
|
||||
</div>
|
||||
|
||||
<!-- Macro rings -->
|
||||
<div class="cm-detail-macros">
|
||||
{#each [
|
||||
{ pct: mp.protein, label: isEn ? 'Protein' : 'Eiweiß', cls: 'ring-protein', grams: preview.protein },
|
||||
{ pct: mp.fat, label: isEn ? 'Fat' : 'Fett', cls: 'ring-fat', grams: preview.fat },
|
||||
{ pct: mp.carbs, label: isEn ? 'Carbs' : 'Kohlenh.', cls: 'ring-carbs', grams: preview.carbs },
|
||||
] as macro}
|
||||
<div class="cm-detail-macro">
|
||||
<svg width="72" height="72" viewBox="0 0 70 70">
|
||||
<circle class="ring-bg" cx="35" cy="35" r={RADIUS}
|
||||
stroke-dasharray="{ARC_LENGTH} {2 * Math.PI * RADIUS}"
|
||||
transform="rotate({ARC_ROTATE} 35 35)" />
|
||||
<circle class="ring-fill {macro.cls}" cx="35" cy="35" r={RADIUS}
|
||||
stroke-dasharray="{ARC_LENGTH} {2 * Math.PI * RADIUS}"
|
||||
stroke-dashoffset={strokeOffset(macro.pct)}
|
||||
transform="rotate({ARC_ROTATE} 35 35)" />
|
||||
<text class="ring-text" x="35" y="35">{macro.pct}%</text>
|
||||
</svg>
|
||||
<span class="cm-detail-macro-label">{macro.label}</span>
|
||||
<span class="cm-detail-macro-val">{fmtNutrient(macro.grams)}g</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Macro detail rows -->
|
||||
<div class="cm-detail-rows">
|
||||
<div class="cm-detail-row">
|
||||
<span>{isEn ? 'Protein' : 'Eiweiß'}</span>
|
||||
<span>{fmtNutrient(preview.protein)} g</span>
|
||||
</div>
|
||||
<div class="cm-detail-row">
|
||||
<span>{isEn ? 'Fat' : 'Fett'}</span>
|
||||
<span>{fmtNutrient(preview.fat)} g</span>
|
||||
</div>
|
||||
<div class="cm-detail-row sub">
|
||||
<span>{isEn ? 'Saturated Fat' : 'Ges. Fettsäuren'}</span>
|
||||
<span>{fmtNutrient(preview.saturatedFat)} g</span>
|
||||
</div>
|
||||
<div class="cm-detail-row">
|
||||
<span>{isEn ? 'Carbohydrates' : 'Kohlenhydrate'}</span>
|
||||
<span>{fmtNutrient(preview.carbs)} g</span>
|
||||
</div>
|
||||
<div class="cm-detail-row sub">
|
||||
<span>{isEn ? 'Sugars' : 'Zucker'}</span>
|
||||
<span>{fmtNutrient(preview.sugars)} g</span>
|
||||
</div>
|
||||
<div class="cm-detail-row">
|
||||
<span>{isEn ? 'Fiber' : 'Ballaststoffe'}</span>
|
||||
<span>{fmtNutrient(preview.fiber)} g</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ingredients list -->
|
||||
<details class="cm-detail-ingredients">
|
||||
<summary>{isEn ? 'Ingredients' : 'Zutaten'} ({meal.ingredients.length})</summary>
|
||||
<ul>
|
||||
{#each meal.ingredients as ing}
|
||||
<li>{ing.name} — {ing.amountGrams}g</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="cm-detail-actions">
|
||||
<button class="cm-detail-btn-cancel" onclick={deselectCmMeal}>{t('cancel', lang)}</button>
|
||||
<button class="cm-detail-btn-confirm" onclick={() => logFn(meal, cmResolvedGrams(meal))}>{t('log_meal', lang)}</button>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet favoritesTab(logFn)}
|
||||
<div class="fav-tab-list">
|
||||
{#if !favTabLoaded}
|
||||
<p class="meals-empty">{t('loading', lang)}</p>
|
||||
{:else if favTabItems.length === 0}
|
||||
<p class="meals-empty">{isEn ? 'No favorites yet. Tap the heart on foods to add them here.' : 'Noch keine Favoriten. Tippe auf das Herz bei Lebensmitteln.'}</p>
|
||||
{:else}
|
||||
<FoodSearch onselect={logFn} showDetailLinks={false} showFavorites={false} initialResults={favTabItems} />
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet customMealsTab(logFn)}
|
||||
{#if selectedCmMeal}
|
||||
{@render cmDetailScreen(selectedCmMeal, logFn)}
|
||||
{:else}
|
||||
<div class="custom-meals-list">
|
||||
{#if customMeals.length > 3}
|
||||
<input type="text" class="cm-filter-input" placeholder={isEn ? 'Filter meals…' : 'Mahlzeiten filtern…'}
|
||||
bind:value={cmFilter} />
|
||||
{/if}
|
||||
{#if customMeals.length === 0}
|
||||
<p class="meals-empty">{t('no_custom_meals', lang)}</p>
|
||||
{/if}
|
||||
{#each filteredCustomMeals as cm}
|
||||
<div class="custom-meal-card" role="button" tabindex="0" onclick={() => selectCmMeal(cm)} onkeydown={e => e.key === 'Enter' && selectCmMeal(cm)}>
|
||||
<div class="custom-meal-info">
|
||||
<span class="custom-meal-name">{cm.name}</span>
|
||||
<span class="custom-meal-detail">{cm.ingredients.length} {t('ingredients', lang)} · {fmtCal(mealTotalCal(cm))} kcal · {Math.round(mealTotalGrams(cm))}g</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<a class="manage-meals-link" href="/fitness/{s.nutrition}/meals">
|
||||
<Settings size={13} />
|
||||
{isEn ? 'Manage meals' : 'Mahlzeiten verwalten'}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet microPanel()}
|
||||
<div class="micro-details" class:micro-hidden={!showMicros}>
|
||||
{#each microSections as section}
|
||||
@@ -1431,8 +1669,13 @@
|
||||
<div class="add-food-form-header">
|
||||
<div class="fab-tabs">
|
||||
<button class="fab-tab" class:active={inlineTab === 'search'} onclick={() => inlineTab = 'search'}>
|
||||
<Search size={13} />
|
||||
{t('search_food', lang).replace('…', '')}
|
||||
</button>
|
||||
<button class="fab-tab" class:active={inlineTab === 'favorites'} onclick={() => { inlineTab = 'favorites'; loadFavTab(); }}>
|
||||
<Heart size={13} />
|
||||
{isEn ? 'Favorites' : 'Favoriten'}
|
||||
</button>
|
||||
<button class="fab-tab" class:active={inlineTab === 'meals'} onclick={() => { inlineTab = 'meals'; loadCustomMeals(); }}>
|
||||
<UtensilsCrossed size={13} />
|
||||
{t('custom_meals', lang)}
|
||||
@@ -1442,26 +1685,11 @@
|
||||
</div>
|
||||
|
||||
{#if inlineTab === 'search'}
|
||||
<FoodSearch onselect={inlineLogFood} showDetailLinks={false} autofocus={true} />
|
||||
<FoodSearch onselect={inlineLogFood} showDetailLinks={false} showFavorites={false} autofocus={true} />
|
||||
{:else if inlineTab === 'favorites'}
|
||||
{@render favoritesTab(inlineLogFood)}
|
||||
{:else}
|
||||
<div class="custom-meals-list">
|
||||
{#if customMeals.length === 0}
|
||||
<p class="meals-empty">{t('no_custom_meals', lang)}</p>
|
||||
{/if}
|
||||
{#each customMeals as cm}
|
||||
<div class="custom-meal-card">
|
||||
<div class="custom-meal-info">
|
||||
<span class="custom-meal-name">{cm.name}</span>
|
||||
<span class="custom-meal-detail">{cm.ingredients.length} {t('ingredients', lang)} · {fmtCal(mealTotalCal(cm))} kcal</span>
|
||||
</div>
|
||||
<button class="btn-primary btn-sm" onclick={() => inlineLogCustomMeal(cm)}>{t('log_meal', lang)}</button>
|
||||
</div>
|
||||
{/each}
|
||||
<a class="manage-meals-link" href="/fitness/{s.nutrition}/meals">
|
||||
<Settings size={13} />
|
||||
{isEn ? 'Manage meals' : 'Mahlzeiten verwalten'}
|
||||
</a>
|
||||
</div>
|
||||
{@render customMealsTab(inlineLogCustomMeal)}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -1573,11 +1801,16 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Tabs: Search / Custom Meals -->
|
||||
<!-- Tabs: Search / Favorites / Custom Meals -->
|
||||
<div class="fab-tabs">
|
||||
<button class="fab-tab" class:active={fabTab === 'search'} onclick={() => fabTab = 'search'}>
|
||||
<Search size={13} />
|
||||
{t('search_food', lang).replace('…', '')}
|
||||
</button>
|
||||
<button class="fab-tab" class:active={fabTab === 'favorites'} onclick={() => { fabTab = 'favorites'; loadFavTab(); }}>
|
||||
<Heart size={13} />
|
||||
{isEn ? 'Favorites' : 'Favoriten'}
|
||||
</button>
|
||||
<button class="fab-tab" class:active={fabTab === 'meals'} onclick={() => { fabTab = 'meals'; loadCustomMeals(); }}>
|
||||
<UtensilsCrossed size={13} />
|
||||
{t('custom_meals', lang)}
|
||||
@@ -1585,27 +1818,11 @@
|
||||
</div>
|
||||
|
||||
{#if fabTab === 'search'}
|
||||
<FoodSearch onselect={fabLogFood} autofocus={true} />
|
||||
<FoodSearch onselect={fabLogFood} showFavorites={false} autofocus={true} />
|
||||
{:else if fabTab === 'favorites'}
|
||||
{@render favoritesTab(fabLogFood)}
|
||||
{:else}
|
||||
<!-- Custom Meals tab -->
|
||||
<div class="custom-meals-list">
|
||||
{#if customMeals.length === 0}
|
||||
<p class="meals-empty">{t('no_custom_meals', lang)}</p>
|
||||
{/if}
|
||||
{#each customMeals as meal}
|
||||
<div class="custom-meal-card">
|
||||
<div class="custom-meal-info">
|
||||
<span class="custom-meal-name">{meal.name}</span>
|
||||
<span class="custom-meal-detail">{meal.ingredients.length} {t('ingredients', lang)} · {fmtCal(mealTotalCal(meal))} kcal</span>
|
||||
</div>
|
||||
<button class="btn-primary btn-sm" onclick={() => logCustomMeal(meal)}>{t('log_meal', lang)}</button>
|
||||
</div>
|
||||
{/each}
|
||||
<a class="manage-meals-link" href="/fitness/{s.nutrition}/meals">
|
||||
<Settings size={13} />
|
||||
{isEn ? 'Manage meals' : 'Mahlzeiten verwalten'}
|
||||
</a>
|
||||
</div>
|
||||
{@render customMealsTab(logCustomMeal)}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1982,7 +2199,17 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
/* (macro rings replaced by macro bars) */
|
||||
/* Macro rings (custom meal detail + food detail) */
|
||||
.ring-protein { stroke: var(--nord14); }
|
||||
.ring-fat { stroke: var(--nord12); }
|
||||
.ring-carbs { stroke: var(--nord9); }
|
||||
.ring-text {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
fill: currentColor;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
}
|
||||
|
||||
/* ── Micro Details ── */
|
||||
.micro-inline {
|
||||
@@ -3136,12 +3363,29 @@
|
||||
padding: 1rem 0;
|
||||
margin: 0;
|
||||
}
|
||||
.cm-filter-input {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.65rem;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9rem;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.15s;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.cm-filter-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.custom-meal-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
cursor: pointer;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -3164,6 +3408,182 @@
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.custom-meal-info[role="button"] {
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.custom-meal-info[role="button"]:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
/* Custom meal detail screen */
|
||||
.cm-detail {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.cm-detail-header {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.cm-detail-name {
|
||||
display: block;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.cm-detail-sub {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.cm-detail-amount {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.cm-detail-amount-input {
|
||||
width: 5rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9rem;
|
||||
text-align: right;
|
||||
}
|
||||
.cm-detail-amount-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--nord8);
|
||||
}
|
||||
.cm-detail-amount-unit {
|
||||
flex: 1;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.cm-detail-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-tertiary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.cm-detail-cal {
|
||||
text-align: center;
|
||||
margin: 0.75rem 0 0.5rem;
|
||||
}
|
||||
.cm-detail-cal-num {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 800;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
.cm-detail-cal-unit {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
margin-left: 0.2rem;
|
||||
}
|
||||
.cm-detail-macros {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin: 0.5rem 0 0.75rem;
|
||||
}
|
||||
.cm-detail-macro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
flex: 1;
|
||||
}
|
||||
.cm-detail-macro-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
.cm-detail-macro-val {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.cm-detail-rows {
|
||||
background: var(--color-surface);
|
||||
border-radius: 10px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.cm-detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.3rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.cm-detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.cm-detail-row.sub span:first-child {
|
||||
padding-left: 0.75rem;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.cm-detail-row span:last-child {
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.cm-detail-ingredients {
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.cm-detail-ingredients summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
.cm-detail-ingredients ul {
|
||||
margin: 0.25rem 0 0;
|
||||
padding-left: 1.2rem;
|
||||
list-style: disc;
|
||||
}
|
||||
.cm-detail-ingredients li {
|
||||
padding: 0.15rem 0;
|
||||
}
|
||||
.cm-detail-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.cm-detail-btn-cancel {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.cm-detail-btn-cancel:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
.cm-detail-btn-confirm {
|
||||
flex: 2;
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-on-primary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.cm-detail-btn-confirm:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 0.3rem 0.65rem;
|
||||
font-size: 0.72rem;
|
||||
|
||||
Reference in New Issue
Block a user