feat(fitness): classify exercises by type and redesign filter UI

Distinguish stretches, strength, cardio, and plyometric exercises
with a curated `exerciseType` field that overrides mislabels in
ExerciseDB for the hand-curated stretch set. Surface the type as
pills on the detail page and as a filter toggle on the list page.

Replace the muscle-group and equipment dropdowns with horizontally
scrolling pill rows; equipment pills carry Lucide icons, and the
type toggle shows a flexed-bicep / person / layers glyph. Selected
muscle groups hoist to the front of the scroller.
This commit is contained in:
2026-04-13 08:59:03 +02:00
parent 4692bf9bf7
commit 0ef8ee38c5
6 changed files with 324 additions and 86 deletions
@@ -1,8 +1,7 @@
<script>
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { Search } from '@lucide/svelte';
import { getFilterOptionsAll, searchAllExercises } from '$lib/data/exercisedb';
import { Search, Cable, Cog, Dumbbell, PersonStanding, Shapes, Weight, BicepsFlexed, Layers } from '@lucide/svelte';
import { getFilterOptionsAll, searchAllExercises, isStretchType } from '$lib/data/exercisedb';
import { translateTerm } from '$lib/data/exercises';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
import { MUSCLE_GROUPS, MUSCLE_GROUP_DE } from '$lib/data/muscleMap';
@@ -17,23 +16,27 @@
let query = $state('');
let equipmentFilters = $state([]);
let muscleGroups = $state([]);
/** @type {'all' | 'stretch' | 'non-stretch'} */
let typeFilter = $state('all');
const filterOptions = getFilterOptionsAll();
/** All selectable muscle/body-part options for the dropdown */
/** All selectable muscle/body-part options (anatomical order) */
const allMuscleOptions = [...MUSCLE_GROUPS];
/** Muscle list with selected groups hoisted to the front, preserving anatomical order within each partition */
const orderedMuscleOptions = $derived.by(() => {
const selected = allMuscleOptions.filter(g => muscleGroups.includes(g));
const rest = allMuscleOptions.filter(g => !muscleGroups.includes(g));
return [...selected, ...rest];
});
/** Display label for a muscle group */
function muscleLabel(group) {
const raw = isEn ? group : (MUSCLE_GROUP_DE[group] ?? group);
return raw.charAt(0).toUpperCase() + raw.slice(1);
}
/** Options not yet selected, for the dropdown */
const availableOptions = $derived(
allMuscleOptions.filter(g => !muscleGroups.includes(g))
);
function addMuscle(group) {
if (group && !muscleGroups.includes(group)) {
muscleGroups = [...muscleGroups, group];
@@ -44,10 +47,6 @@
muscleGroups = muscleGroups.filter(g => g !== group);
}
const availableEquipment = $derived(
filterOptions.equipment.filter(e => !equipmentFilters.includes(e))
);
function addEquipment(eq) {
if (eq && !equipmentFilters.includes(eq)) {
equipmentFilters = [...equipmentFilters, eq];
@@ -63,10 +62,33 @@
return raw.charAt(0).toUpperCase() + raw.slice(1);
}
/** @param {string} eq lucide icon component for equipment type */
function equipmentIcon(eq) {
switch (eq) {
case 'barbell': return Weight;
case 'dumbbell': return Dumbbell;
case 'body weight': return PersonStanding;
case 'cable': return Cable;
case 'machine': return Cog;
default: return Shapes;
}
}
function toggleEquipment(eq) {
if (equipmentFilters.includes(eq)) removeEquipment(eq);
else addEquipment(eq);
}
function toggleMuscle(group) {
if (muscleGroups.includes(group)) removeMuscle(group);
else addMuscle(group);
}
const filtered = $derived(searchAllExercises({
search: query || undefined,
equipment: equipmentFilters.length ? equipmentFilters : undefined,
muscleGroups: muscleGroups.length ? muscleGroups : undefined,
stretchFilter: typeFilter === 'all' ? undefined : typeFilter,
lang
}));
</script>
@@ -91,47 +113,98 @@
<input type="text" placeholder={t('search_exercises', lang)} bind:value={query} />
</div>
<div class="filters">
<select onchange={(e) => { addMuscle(e.target.value); e.target.value = ''; }}>
<option value="">{isEn ? 'Muscle group' : 'Muskelgruppe'}</option>
{#each availableOptions as group}
<option value={group}>{muscleLabel(group)}</option>
{/each}
</select>
<select onchange={(e) => { addEquipment(e.target.value); e.target.value = ''; }}>
<option value="">{t('all_equipment', lang)}</option>
{#each availableEquipment as eq}
<option value={eq}>{equipmentLabel(eq)}</option>
{/each}
</select>
<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>
{#if muscleGroups.length > 0 || equipmentFilters.length > 0}
<div class="selected-pills">
{#each muscleGroups as group}
<button class="filter-pill muscle" onclick={() => removeMuscle(group)}>
{muscleLabel(group)}
<span class="pill-remove" aria-hidden="true">×</span>
<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>
{/each}
{#each equipmentFilters as eq}
<button class="filter-pill equipment" onclick={() => removeEquipment(eq)}>
{equipmentLabel(eq)}
<span class="pill-remove" aria-hidden="true">×</span>
</button>
{/each}
<button class="clear-filters" onclick={() => { muscleGroups = []; equipmentFilters = []; }}>
{isEn ? 'Clear all' : 'Alle löschen'}
</button>
{/if}
</div>
{/if}
<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}</span>
<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>
@@ -209,69 +282,145 @@
.search-bar input::placeholder {
color: var(--color-text-muted);
}
.filters {
/* Pill group filters (equipment + muscle) */
.pill-group {
display: flex;
gap: 0.5rem;
flex-direction: column;
gap: 0.35rem;
}
.filters select {
flex: 1;
padding: 0.4rem 0.5rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
color: inherit;
font-size: 0.8rem;
}
.selected-pills {
.pill-group-header {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
align-items: baseline;
justify-content: space-between;
padding-inline: 0.1rem;
}
.filter-pill {
.pill-group-label {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-text-tertiary);
}
.mini-clear {
all: unset;
-webkit-tap-highlight-color: transparent;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-text-secondary);
cursor: pointer;
}
.mini-clear:hover {
color: var(--color-primary);
}
.pill-scroll {
display: flex;
gap: 0.4rem;
overflow-x: auto;
scrollbar-width: none;
padding: 0.15rem 0.1rem 0.35rem;
mask-image: linear-gradient(to right, transparent 0, #000 0.6rem, #000 calc(100% - 0.6rem), transparent 100%);
}
.pill-scroll.no-left-fade {
mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 0.6rem), transparent 100%);
}
.pill-scroll::-webkit-scrollbar {
display: none;
}
.chip {
all: unset;
-webkit-tap-highlight-color: transparent;
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0.6rem;
border-radius: var(--radius-pill, 100px);
color: var(--color-primary-contrast);
font-size: 0.75rem;
gap: 0.35rem;
padding: 0.35rem 0.75rem;
border-radius: var(--radius-pill, 1000px);
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.01em;
text-transform: capitalize;
cursor: pointer;
transition: filter 0.1s, transform 0.1s;
white-space: nowrap;
border: 1px solid transparent;
transition: background var(--transition-fast, 100ms), color var(--transition-fast, 100ms), border-color var(--transition-fast, 100ms), transform var(--transition-fast, 100ms), box-shadow var(--transition-fast, 100ms);
}
.filter-pill:hover {
filter: brightness(1.1);
transform: scale(1.05);
.chip :global(svg) {
flex-shrink: 0;
}
.filter-pill:active {
transform: scale(0.95);
.chip:hover {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
transform: scale(1.04);
}
.filter-pill.muscle {
background: var(--lightblue);
color: black;
.chip:active {
transform: scale(0.96);
}
.filter-pill.equipment {
background: var(--blue);
color: white;
.chip.active {
background: var(--color-primary);
color: var(--color-text-on-primary);
border-color: var(--color-primary);
box-shadow: var(--shadow-sm);
}
.pill-remove {
font-size: 0.7rem;
font-weight: bold;
margin-left: 0.1rem;
.chip.equipment-chip :global(svg) {
opacity: 0.85;
}
.clear-filters {
.chip.equipment-chip.active :global(svg) {
opacity: 1;
}
.type-toggle {
display: flex;
gap: 0.25rem;
background: var(--color-bg-tertiary);
border-radius: var(--radius-pill, 100px);
padding: 0.2rem;
}
.type-btn {
all: unset;
-webkit-tap-highlight-color: transparent;
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
text-align: center;
padding: 0.35rem 0.6rem;
border-radius: var(--radius-pill, 100px);
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary);
cursor: pointer;
padding: 0.25rem 0.4rem;
transition: background 0.15s, color 0.15s;
}
.clear-filters:hover {
.type-btn :global(svg) {
flex-shrink: 0;
opacity: 0.8;
}
.type-btn.active :global(svg) {
opacity: 1;
}
.type-btn:hover {
color: var(--color-text-primary);
text-decoration: underline;
}
.type-btn.active {
background: var(--color-primary);
color: var(--color-text-on-primary);
}
.stretch-badge {
display: inline-block;
vertical-align: middle;
margin-left: 0.4rem;
padding: 0.1rem 0.45rem;
border-radius: var(--radius-pill, 100px);
background: rgba(180, 142, 173, 0.2);
color: var(--nord15);
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.exercise-list {
@@ -1,6 +1,25 @@
<script>
import { page } from '$app/stores';
import { getEnrichedExerciseById } from '$lib/data/exercisedb';
/** @param {string | undefined | null} type @param {'en'|'de'} lang */
function exerciseTypeInfo(type, lang) {
if (!type) return null;
switch (type) {
case 'STRETCHING':
case 'YOGA':
return { key: 'stretch', label: t('stretch_pill', lang) };
case 'STRENGTH':
case 'WEIGHTLIFTING':
return { key: 'strength', label: t('strength_pill', lang) };
case 'CARDIO':
return { key: 'cardio', label: t('cardio_pill', lang) };
case 'PLYOMETRICS':
return { key: 'plyo', label: t('plyo_pill', lang) };
default:
return null;
}
}
import { localizeExercise, translateTerm } from '$lib/data/exercises';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
import { ChevronRight } from '@lucide/svelte';
@@ -36,6 +55,7 @@
let activeTab = $state('about');
const exercise = $derived(data.exercise ?? getEnrichedExerciseById($page.params.id, lang));
const typeInfo = $derived(exerciseTypeInfo(exercise?.exerciseType, lang));
const similar = $derived(data.similar ?? []);
const history = $derived(data.history?.history ?? []);
const stats = $derived(data.stats ?? {});
@@ -133,6 +153,9 @@
<div class="about-main">
<!-- Tags -->
<div class="tags">
{#if typeInfo}
<span class="tag type-{typeInfo.key}">{typeInfo.label}</span>
{/if}
<span class="tag body-part">{exercise?.localBodyPart}</span>
<span class="tag equipment">{exercise?.localEquipment}</span>
<span class="tag target">{exercise?.localTarget}</span>
@@ -350,6 +373,17 @@
.body-part { background: rgba(94, 129, 172, 0.2); color: var(--nord9); }
.equipment { background: rgba(163, 190, 140, 0.2); color: var(--nord14); }
.target { background: rgba(208, 135, 112, 0.2); color: var(--nord12); }
.tag.type-stretch,
.tag.type-strength,
.tag.type-cardio,
.tag.type-plyo {
font-weight: 700;
letter-spacing: 0.04em;
}
.tag.type-stretch { background: rgba(180, 142, 173, 0.2); color: var(--nord15); }
.tag.type-strength { background: rgba(94, 129, 172, 0.2); color: var(--nord10); }
.tag.type-cardio { background: rgba(191, 97, 106, 0.2); color: var(--nord11); }
.tag.type-plyo { background: rgba(235, 203, 139, 0.22); color: var(--nord13); }
/* About layout — two-column on wide screens */
.about-layout {