style(fitness/exercises): wrap muscle filter in card, widen layout

Align the muscle picker with the site card language (matches
/fitness/check-in and /fitness/stats) and unlock the full desktop
width via the 1400px container used by nutrition/check-in.

- Sidebar card layout at ≥900px (200/620 grid, sticky)
- Larger sidebar at ≥1180px (460/720) with figures uncapped
- Tablet tier (900–1179px) stacks figures vertically inside the card
- Below 900px the card sits on top of the content column
This commit is contained in:
2026-04-23 21:33:05 +02:00
parent c73363e93d
commit 504a6f410f
4 changed files with 210 additions and 200 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.47.2", "version": "1.47.3",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+60 -65
View File
@@ -7,8 +7,8 @@
* @typedef {{ groups: string[], label: { en: string, de: string } }} MuscleRegion * @typedef {{ groups: string[], label: { en: string, de: string } }} MuscleRegion
*/ */
/** @type {{ selectedGroups?: string[], lang?: string, split?: boolean }} */ /** @type {{ selectedGroups?: string[], lang?: string }} */
let { selectedGroups = $bindable([]), lang = 'en', split = false } = $props(); let { selectedGroups = $bindable([]), lang = 'en' } = $props();
const isEn = $derived(lang === 'en'); const isEn = $derived(lang === 'en');
@@ -78,7 +78,6 @@
/** Currently hovered region for tooltip */ /** Currently hovered region for tooltip */
/** @type {MuscleRegion | null} */ /** @type {MuscleRegion | null} */
let hovered = $state(null); let hovered = $state(null);
let hoveredSide = $state('front');
const hoveredLabel = $derived.by(() => { const hoveredLabel = $derived.by(() => {
if (!hovered) return null; if (!hovered) return null;
@@ -108,9 +107,8 @@
/** /**
* @param {HTMLDivElement | null} container * @param {HTMLDivElement | null} container
* @param {Record<string, MuscleRegion>} map * @param {Record<string, MuscleRegion>} map
* @param {string} side
*/ */
function setupEvents(container, map, side) { function setupEvents(container, map) {
if (!container) return; if (!container) return;
container.addEventListener('mouseover', (/** @type {Event} */ e) => { container.addEventListener('mouseover', (/** @type {Event} */ e) => {
@@ -118,7 +116,6 @@
const g = target?.closest('g[id]'); const g = target?.closest('g[id]');
if (g && map[g.id]) { if (g && map[g.id]) {
hovered = map[g.id]; hovered = map[g.id];
hoveredSide = side;
g.classList.add('highlighted'); g.classList.add('highlighted');
} }
}); });
@@ -143,66 +140,44 @@
} }
onMount(() => { onMount(() => {
setupEvents(frontEl, FRONT_MAP, 'front'); setupEvents(frontEl, FRONT_MAP);
setupEvents(backEl, BACK_MAP, 'back'); setupEvents(backEl, BACK_MAP);
}); });
</script> </script>
{#if split} <div class="muscle-filter">
<div class="muscle-filter-split"> <div class="body-figures">
<div class="split-left"> <div class="figure">
<div class="figure"> <span class="figure-label">{isEn ? 'Front' : 'Vorne'}</span>
<div class="svg-wrap" bind:this={frontEl}> <div class="svg-wrap" bind:this={frontEl}>
{@html frontSvg} {@html frontSvg}
</div>
</div> </div>
{#if hoveredLabel && hoveredSide === 'front'}
<div class="hover-label">{hoveredLabel}</div>
{/if}
</div> </div>
<div class="split-right"> <div class="figure">
<div class="figure"> <span class="figure-label">{isEn ? 'Back' : 'Hinten'}</span>
<div class="svg-wrap" bind:this={backEl}> <div class="svg-wrap" bind:this={backEl}>
{@html backSvg} {@html backSvg}
</div>
</div> </div>
{#if hoveredLabel && hoveredSide === 'back'}
<div class="hover-label">{hoveredLabel}</div>
{/if}
</div> </div>
</div> </div>
{:else}
<div class="muscle-filter">
<div class="body-figures">
<div class="figure">
<div class="svg-wrap" bind:this={frontEl}>
{@html frontSvg}
</div>
</div>
<div class="figure">
<div class="svg-wrap" bind:this={backEl}>
{@html backSvg}
</div>
</div>
</div>
{#if hoveredLabel} <div class="hover-label" aria-live="polite">
<div class="hover-label">{hoveredLabel}</div> {hoveredLabel ?? (isEn ? 'Tap a muscle to filter' : 'Muskel antippen zum Filtern')}
{/if}
</div> </div>
{/if} </div>
<style> <style>
.muscle-filter { .muscle-filter {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.5rem;
width: 100%;
} }
.body-figures { .body-figures {
display: flex; display: flex;
gap: 0.5rem; gap: 0.75rem;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
} }
@@ -211,12 +186,47 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.3rem;
flex: 1; flex: 1;
max-width: 150px; min-width: 0;
max-width: 180px;
}
/* Tablet sidebar: narrow column, stack figures vertically */
@media (min-width: 900px) and (max-width: 1179px) {
.body-figures {
flex-direction: column;
align-items: center;
gap: 0.6rem;
}
.figure {
flex: initial;
width: 100%;
max-width: 170px;
}
}
/* Wide sidebar: let figures grow with the card width instead of capping at 180px */
@media (min-width: 1180px) {
.body-figures {
gap: 1rem;
}
.figure {
max-width: none;
}
}
.figure-label {
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--color-text-tertiary);
} }
.svg-wrap { .svg-wrap {
width: 100%; width: 100%;
-webkit-tap-highlight-color: transparent;
} }
.svg-wrap :global(svg) { .svg-wrap :global(svg) {
@@ -245,25 +255,10 @@
} }
.hover-label { .hover-label {
font-size: 0.7rem; min-height: 1.1em;
font-size: 0.72rem;
font-weight: 600; font-weight: 600;
color: var(--color-text-primary); color: var(--color-text-secondary);
text-align: center; text-align: center;
} }
/* Split mode: two independent columns for parent to position */
.muscle-filter-split {
display: contents;
}
.split-left, .split-right {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
}
.split-left .figure, .split-right .figure {
max-width: none;
}
</style> </style>
+4 -1
View File
@@ -76,6 +76,9 @@
const isMeasureIndex = $derived( const isMeasureIndex = $derived(
/^\/fitness\/(check-in|erfassung)\/?$/.test($page.url.pathname) /^\/fitness\/(check-in|erfassung)\/?$/.test($page.url.pathname)
); );
const isExercisesIndex = $derived(
/^\/fitness\/(exercises|uebungen)\/?$/.test($page.url.pathname)
);
/** @param {number} secs */ /** @param {number} secs */
function formatElapsed(secs) { function formatElapsed(secs) {
const m = Math.floor(secs / 60); const m = Math.floor(secs / 60);
@@ -108,7 +111,7 @@
<UserHeader {user} /> <UserHeader {user} />
{/snippet} {/snippet}
<div class="fitness-content" style:--fitness-max-width={isNutritionPage || isMeasureIndex ? '1400px' : null}> <div class="fitness-content" style:--fitness-max-width={isNutritionPage || isMeasureIndex || isExercisesIndex ? '1400px' : null}>
{@render children()} {@render children()}
</div> </div>
</Header> </Header>
@@ -116,127 +116,124 @@
<svelte:head><title>{lang === 'en' ? 'Exercises' : 'Übungen'} - Bocken</title></svelte:head> <svelte:head><title>{lang === 'en' ? 'Exercises' : 'Übungen'} - Bocken</title></svelte:head>
<div class="exercises-page"> <div class="exercises-page">
<!-- Desktop: split front/back absolutely positioned outside content -->
<div class="desktop-filter">
<MuscleFilter bind:selectedGroups={muscleGroups} {lang} split />
</div>
<h1 class="sr-only">{t('exercises_title', lang)}</h1> <h1 class="sr-only">{t('exercises_title', lang)}</h1>
<!-- Mobile: inline, not split --> <aside class="muscle-card" aria-label={isEn ? 'Filter by muscle' : 'Nach Muskel filtern'}>
<div class="mobile-filter">
<MuscleFilter bind:selectedGroups={muscleGroups} {lang} /> <MuscleFilter bind:selectedGroups={muscleGroups} {lang} />
</div> </aside>
<div class="search-bar"> <div class="exercises-content">
<Search size={16} /> <div class="search-bar">
<input type="text" placeholder={t('search_exercises', lang)} bind:value={query} /> <Search size={16} />
</div> <input type="text" placeholder={t('search_exercises', lang)} bind:value={query} />
<div class="type-toggle" role="tablist" aria-label={isEn ? 'Exercise type filter' : 'Filter nach Übungsart'}>
<button
role="tab"
aria-selected={typeFilter === 'all'}
class="type-btn"
class:active={typeFilter === 'all'}
onclick={() => typeFilter = 'all'}
>
<Layers size={14} strokeWidth={2.2} />
<span>{t('type_any', lang)}</span>
</button>
<button
role="tab"
aria-selected={typeFilter === 'non-stretch'}
class="type-btn"
class:active={typeFilter === 'non-stretch'}
onclick={() => typeFilter = 'non-stretch'}
>
<BicepsFlexed size={14} strokeWidth={2.2} />
<span>{t('type_weights', lang)}</span>
</button>
<button
role="tab"
aria-selected={typeFilter === 'stretch'}
class="type-btn"
class:active={typeFilter === 'stretch'}
onclick={() => typeFilter = 'stretch'}
>
<PersonStanding size={14} strokeWidth={2.2} />
<span>{t('type_stretches', lang)}</span>
</button>
</div>
<section class="pill-group">
<div class="pill-group-header">
<span class="pill-group-label">{isEn ? 'Equipment' : 'Ausrüstung'}</span>
{#if equipmentFilters.length > 0}
<button class="mini-clear" onclick={() => equipmentFilters = []}>
{isEn ? 'clear' : 'löschen'}
</button>
{/if}
</div> </div>
<div class="pill-scroll">
{#each filterOptions.equipment as eq (eq)} <div class="type-toggle" role="tablist" aria-label={isEn ? 'Exercise type filter' : 'Filter nach Übungsart'}>
{@const active = equipmentFilters.includes(eq)} <button
{@const Icon = equipmentIcon(eq)} role="tab"
<button aria-selected={typeFilter === 'all'}
class="chip equipment-chip" class="type-btn"
class:active class:active={typeFilter === 'all'}
aria-pressed={active} onclick={() => typeFilter = 'all'}
onclick={() => toggleEquipment(eq)} >
> <Layers size={14} strokeWidth={2.2} />
<Icon size={14} strokeWidth={2.2} /> <span>{t('type_any', lang)}</span>
<span>{equipmentLabel(eq)}</span> </button>
</button> <button
role="tab"
aria-selected={typeFilter === 'non-stretch'}
class="type-btn"
class:active={typeFilter === 'non-stretch'}
onclick={() => typeFilter = 'non-stretch'}
>
<BicepsFlexed size={14} strokeWidth={2.2} />
<span>{t('type_weights', lang)}</span>
</button>
<button
role="tab"
aria-selected={typeFilter === 'stretch'}
class="type-btn"
class:active={typeFilter === 'stretch'}
onclick={() => typeFilter = 'stretch'}
>
<PersonStanding size={14} strokeWidth={2.2} />
<span>{t('type_stretches', lang)}</span>
</button>
</div>
<section class="pill-group">
<div class="pill-group-header">
<span class="pill-group-label">{isEn ? 'Equipment' : 'Ausrüstung'}</span>
{#if equipmentFilters.length > 0}
<button class="mini-clear" onclick={() => equipmentFilters = []}>
{isEn ? 'clear' : 'löschen'}
</button>
{/if}
</div>
<div class="pill-scroll">
{#each filterOptions.equipment as eq (eq)}
{@const active = equipmentFilters.includes(eq)}
{@const Icon = equipmentIcon(eq)}
<button
class="chip equipment-chip"
class:active
aria-pressed={active}
onclick={() => toggleEquipment(eq)}
>
<Icon size={14} strokeWidth={2.2} />
<span>{equipmentLabel(eq)}</span>
</button>
{/each}
</div>
</section>
<section class="pill-group">
<div class="pill-group-header">
<span class="pill-group-label">{isEn ? 'Muscle Group' : 'Muskelgruppe'}</span>
{#if muscleGroups.length > 0}
<button class="mini-clear" onclick={() => muscleGroups = []}>
{isEn ? 'clear' : 'löschen'}
</button>
{/if}
</div>
<div class="pill-scroll no-left-fade">
{#each orderedMuscleOptions as group (group)}
{@const active = muscleGroups.includes(group)}
<button
class="chip muscle-chip"
class:active
aria-pressed={active}
onclick={() => toggleMuscle(group)}
>{muscleLabel(group)}</button>
{/each}
</div>
</section>
<ul class="exercise-list">
{#each filtered as exercise (exercise.id)}
<li>
<a href="/fitness/{sl.exercises}/{exercise.id}" class="exercise-row">
<div class="exercise-info">
<span class="exercise-name">
{exercise.localName}
{#if isStretchType(exercise.exerciseType)}
<span class="stretch-badge">{t('stretch_pill', lang)}</span>
{/if}
</span>
<span class="exercise-meta">{exercise.localBodyPart} · {exercise.localEquipment}</span>
</div>
</a>
</li>
{/each} {/each}
</div> {#if filtered.length === 0}
</section> <li class="no-results">{t('no_exercises_match', lang)}</li>
<section class="pill-group">
<div class="pill-group-header">
<span class="pill-group-label">{isEn ? 'Muscle Group' : 'Muskelgruppe'}</span>
{#if muscleGroups.length > 0}
<button class="mini-clear" onclick={() => muscleGroups = []}>
{isEn ? 'clear' : 'löschen'}
</button>
{/if} {/if}
</div> </ul>
<div class="pill-scroll no-left-fade"> </div>
{#each orderedMuscleOptions as group (group)}
{@const active = muscleGroups.includes(group)}
<button
class="chip muscle-chip"
class:active
aria-pressed={active}
onclick={() => toggleMuscle(group)}
>{muscleLabel(group)}</button>
{/each}
</div>
</section>
<ul class="exercise-list">
{#each filtered as exercise (exercise.id)}
<li>
<a href="/fitness/{sl.exercises}/{exercise.id}" class="exercise-row">
<div class="exercise-info">
<span class="exercise-name">
{exercise.localName}
{#if isStretchType(exercise.exerciseType)}
<span class="stretch-badge">{t('stretch_pill', lang)}</span>
{/if}
</span>
<span class="exercise-meta">{exercise.localBodyPart} · {exercise.localEquipment}</span>
</div>
</a>
</li>
{/each}
{#if filtered.length === 0}
<li class="no-results">{t('no_exercises_match', lang)}</li>
{/if}
</ul>
</div> </div>
<style> <style>
/* Default (mobile + tablet): single column, card on top */
.exercises-page { .exercises-page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -245,34 +242,49 @@
margin: 0 auto; margin: 0 auto;
position: relative; position: relative;
} }
/* Mobile: show inline filter, hide desktop split */
.desktop-filter { .exercises-content {
display: none; display: flex;
flex-direction: column;
gap: 0.75rem;
min-width: 0;
} }
/* Desktop: front/back absolutely positioned outside content flow */ .muscle-card {
@media (min-width: 1024px) { background: var(--color-surface);
.mobile-filter { border: 1px solid var(--color-border);
display: none; border-radius: var(--radius-card);
padding: 0.85rem 1rem 0.75rem;
}
/* Tablet + wide: two-column sidebar layout (card fixed on the left) */
@media (min-width: 900px) {
.exercises-page {
display: grid;
grid-template-columns: 200px minmax(0, 620px);
gap: 1.5rem;
max-width: calc(200px + 1.5rem + 620px);
width: 100%;
align-items: start;
} }
.desktop-filter { .muscle-card {
display: contents; position: sticky;
}
.exercises-page :global(.split-left),
.exercises-page :global(.split-right) {
position: fixed;
top: calc(8.5rem + env(safe-area-inset-top, 0px)); top: calc(8.5rem + env(safe-area-inset-top, 0px));
width: clamp(140px, 14vw, 200px); padding: 1rem 0.9rem 0.85rem;
}
}
/* Wide: larger sidebar so the figure pair gets meaningful size */
@media (min-width: 1180px) {
.exercises-page {
grid-template-columns: 460px minmax(0, 720px);
gap: 2rem;
max-width: calc(460px + 2rem + 720px);
} }
.exercises-page :global(.split-left) { .muscle-card {
right: calc(50% + 310px + 1.5rem); padding: 1.1rem 1.2rem 0.95rem;
}
.exercises-page :global(.split-right) {
left: calc(50% + 310px + 1.5rem);
} }
} }
@@ -455,7 +467,7 @@
.exercise-row:hover { .exercise-row:hover {
background: var(--color-surface-hover); background: var(--color-surface-hover);
} }
.exercise-info { .exercise-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;