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:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.47.2",
|
||||
"version": "1.47.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user