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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user