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",
"version": "1.47.2",
"version": "1.47.3",
"private": true,
"type": "module",
"scripts": {
+60 -65
View File
@@ -7,8 +7,8 @@
* @typedef {{ groups: string[], label: { en: string, de: string } }} MuscleRegion
*/
/** @type {{ selectedGroups?: string[], lang?: string, split?: boolean }} */
let { selectedGroups = $bindable([]), lang = 'en', split = false } = $props();
/** @type {{ selectedGroups?: string[], lang?: string }} */
let { selectedGroups = $bindable([]), lang = 'en' } = $props();
const isEn = $derived(lang === 'en');
@@ -78,7 +78,6 @@
/** Currently hovered region for tooltip */
/** @type {MuscleRegion | null} */
let hovered = $state(null);
let hoveredSide = $state('front');
const hoveredLabel = $derived.by(() => {
if (!hovered) return null;
@@ -108,9 +107,8 @@
/**
* @param {HTMLDivElement | null} container
* @param {Record<string, MuscleRegion>} map
* @param {string} side
*/
function setupEvents(container, map, side) {
function setupEvents(container, map) {
if (!container) return;
container.addEventListener('mouseover', (/** @type {Event} */ e) => {
@@ -118,7 +116,6 @@
const g = target?.closest('g[id]');
if (g && map[g.id]) {
hovered = map[g.id];
hoveredSide = side;
g.classList.add('highlighted');
}
});
@@ -143,66 +140,44 @@
}
onMount(() => {
setupEvents(frontEl, FRONT_MAP, 'front');
setupEvents(backEl, BACK_MAP, 'back');
setupEvents(frontEl, FRONT_MAP);
setupEvents(backEl, BACK_MAP);
});
</script>
{#if split}
<div class="muscle-filter-split">
<div class="split-left">
<div class="figure">
<div class="svg-wrap" bind:this={frontEl}>
{@html frontSvg}
</div>
<div class="muscle-filter">
<div class="body-figures">
<div class="figure">
<span class="figure-label">{isEn ? 'Front' : 'Vorne'}</span>
<div class="svg-wrap" bind:this={frontEl}>
{@html frontSvg}
</div>
{#if hoveredLabel && hoveredSide === 'front'}
<div class="hover-label">{hoveredLabel}</div>
{/if}
</div>
<div class="split-right">
<div class="figure">
<div class="svg-wrap" bind:this={backEl}>
{@html backSvg}
</div>
<div class="figure">
<span class="figure-label">{isEn ? 'Back' : 'Hinten'}</span>
<div class="svg-wrap" bind:this={backEl}>
{@html backSvg}
</div>
{#if hoveredLabel && hoveredSide === 'back'}
<div class="hover-label">{hoveredLabel}</div>
{/if}
</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">{hoveredLabel}</div>
{/if}
<div class="hover-label" aria-live="polite">
{hoveredLabel ?? (isEn ? 'Tap a muscle to filter' : 'Muskel antippen zum Filtern')}
</div>
{/if}
</div>
<style>
.muscle-filter {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
gap: 0.5rem;
width: 100%;
}
.body-figures {
display: flex;
gap: 0.5rem;
gap: 0.75rem;
justify-content: center;
width: 100%;
}
@@ -211,12 +186,47 @@
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
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 {
width: 100%;
-webkit-tap-highlight-color: transparent;
}
.svg-wrap :global(svg) {
@@ -245,25 +255,10 @@
}
.hover-label {
font-size: 0.7rem;
min-height: 1.1em;
font-size: 0.72rem;
font-weight: 600;
color: var(--color-text-primary);
color: var(--color-text-secondary);
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>
+4 -1
View File
@@ -76,6 +76,9 @@
const isMeasureIndex = $derived(
/^\/fitness\/(check-in|erfassung)\/?$/.test($page.url.pathname)
);
const isExercisesIndex = $derived(
/^\/fitness\/(exercises|uebungen)\/?$/.test($page.url.pathname)
);
/** @param {number} secs */
function formatElapsed(secs) {
const m = Math.floor(secs / 60);
@@ -108,7 +111,7 @@
<UserHeader {user} />
{/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()}
</div>
</Header>
@@ -116,127 +116,124 @@
<svelte:head><title>{lang === 'en' ? 'Exercises' : 'Übungen'} - Bocken</title></svelte:head>
<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>
<!-- Mobile: inline, not split -->
<div class="mobile-filter">
<aside class="muscle-card" aria-label={isEn ? 'Filter by muscle' : 'Nach Muskel filtern'}>
<MuscleFilter bind:selectedGroups={muscleGroups} {lang} />
</div>
</aside>
<div class="search-bar">
<Search size={16} />
<input type="text" placeholder={t('search_exercises', lang)} bind:value={query} />
</div>
<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 class="exercises-content">
<div class="search-bar">
<Search size={16} />
<input type="text" placeholder={t('search_exercises', lang)} bind:value={query} />
</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>
<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 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}
</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 filtered.length === 0}
<li class="no-results">{t('no_exercises_match', lang)}</li>
{/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}
{#if filtered.length === 0}
<li class="no-results">{t('no_exercises_match', lang)}</li>
{/if}
</ul>
</ul>
</div>
</div>
<style>
/* Default (mobile + tablet): single column, card on top */
.exercises-page {
display: flex;
flex-direction: column;
@@ -245,34 +242,49 @@
margin: 0 auto;
position: relative;
}
/* Mobile: show inline filter, hide desktop split */
.desktop-filter {
display: none;
.exercises-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-width: 0;
}
/* Desktop: front/back absolutely positioned outside content flow */
@media (min-width: 1024px) {
.mobile-filter {
display: none;
.muscle-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
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 {
display: contents;
}
.exercises-page :global(.split-left),
.exercises-page :global(.split-right) {
position: fixed;
.muscle-card {
position: sticky;
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) {
right: calc(50% + 310px + 1.5rem);
}
.exercises-page :global(.split-right) {
left: calc(50% + 310px + 1.5rem);
.muscle-card {
padding: 1.1rem 1.2rem 0.95rem;
}
}
@@ -455,7 +467,7 @@
.exercise-row:hover {
background: var(--color-surface-hover);
}
.exercise-info {
.exercise-info {
display: flex;
flex-direction: column;
min-width: 0;