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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.29.0",
|
"version": "1.30.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Merges the static exercises.ts catalog with ExerciseDB v2 data
|
* Merges the static exercises.ts catalog with ExerciseDB v2 data
|
||||||
* to provide a unified, enriched exercise set.
|
* to provide a unified, enriched exercise set.
|
||||||
*/
|
*/
|
||||||
import type { Exercise, LocalizedExercise, MetricField } from './exercises';
|
import type { Exercise, ExerciseType, LocalizedExercise, MetricField } from './exercises';
|
||||||
import { localizeExercise, translateTerm, getExerciseMetrics, METRIC_PRESETS } from './exercises';
|
import { localizeExercise, translateTerm, getExerciseMetrics, METRIC_PRESETS } from './exercises';
|
||||||
import { exerciseDbMap, slugToExerciseDbId } from './exercisedb-map';
|
import { exerciseDbMap, slugToExerciseDbId } from './exercisedb-map';
|
||||||
import { edbMuscleToSimple, edbMusclesToGroups, edbBodyPartToSimple, edbEquipmentToSimple } from './muscleMap';
|
import { edbMuscleToSimple, edbMusclesToGroups, edbBodyPartToSimple, edbEquipmentToSimple } from './muscleMap';
|
||||||
@@ -39,6 +39,7 @@ export interface EnrichedExercise extends Exercise {
|
|||||||
secondaryMusclesDetailed: string[];
|
secondaryMusclesDetailed: string[];
|
||||||
heroImage: string | null;
|
heroImage: string | null;
|
||||||
videoUrl: string | null;
|
videoUrl: string | null;
|
||||||
|
exerciseType?: ExerciseType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalizedEnrichedExercise extends LocalizedExercise {
|
export interface LocalizedEnrichedExercise extends LocalizedExercise {
|
||||||
@@ -50,6 +51,12 @@ export interface LocalizedEnrichedExercise extends LocalizedExercise {
|
|||||||
secondaryMusclesDetailed: string[];
|
secondaryMusclesDetailed: string[];
|
||||||
heroImage: string | null;
|
heroImage: string | null;
|
||||||
videoUrl: string | null;
|
videoUrl: string | null;
|
||||||
|
exerciseType?: ExerciseType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if exercise is a stretch or yoga-style flexibility pose */
|
||||||
|
export function isStretchType(t: ExerciseType | string | null | undefined): boolean {
|
||||||
|
return t === 'STRETCHING' || t === 'YOGA';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build static exercise lookup
|
// Build static exercise lookup
|
||||||
@@ -97,6 +104,9 @@ function edbToEnriched(edb: EdbRawExercise, slug: string, staticEx?: Exercise):
|
|||||||
secondaryMusclesDetailed: edb.secondaryMuscles ?? [],
|
secondaryMusclesDetailed: edb.secondaryMuscles ?? [],
|
||||||
heroImage: `/fitness/exercises/${edb.exerciseId}/720p.jpg`,
|
heroImage: `/fitness/exercises/${edb.exerciseId}/720p.jpg`,
|
||||||
videoUrl: `/fitness/exercises/${edb.exerciseId}/video.mp4`,
|
videoUrl: `/fitness/exercises/${edb.exerciseId}/video.mp4`,
|
||||||
|
// static classification wins: our curated STATIC_STRETCH_IDS is more accurate than EDB
|
||||||
|
// (EDB often tags hand-curated stretches as "STRENGTH")
|
||||||
|
exerciseType: base.exerciseType ?? (edb.exerciseType as ExerciseType | undefined),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +134,7 @@ for (const ex of staticExercises) {
|
|||||||
secondaryMusclesDetailed: [],
|
secondaryMusclesDetailed: [],
|
||||||
heroImage: ex.imageUrl ?? null,
|
heroImage: ex.imageUrl ?? null,
|
||||||
videoUrl: null,
|
videoUrl: null,
|
||||||
|
exerciseType: ex.exerciseType,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,6 +155,7 @@ export function localizeEnriched(e: EnrichedExercise, lang: 'en' | 'de'): Locali
|
|||||||
secondaryMusclesDetailed: e.secondaryMusclesDetailed,
|
secondaryMusclesDetailed: e.secondaryMusclesDetailed,
|
||||||
heroImage: e.heroImage,
|
heroImage: e.heroImage,
|
||||||
videoUrl: e.videoUrl,
|
videoUrl: e.videoUrl,
|
||||||
|
exerciseType: e.exerciseType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +199,8 @@ export function searchAllExercises(opts: {
|
|||||||
equipment?: string | string[];
|
equipment?: string | string[];
|
||||||
target?: string;
|
target?: string;
|
||||||
muscleGroups?: string[];
|
muscleGroups?: string[];
|
||||||
|
/** 'stretch' keeps only stretches/yoga; 'non-stretch' excludes them */
|
||||||
|
stretchFilter?: 'stretch' | 'non-stretch';
|
||||||
lang?: 'en' | 'de';
|
lang?: 'en' | 'de';
|
||||||
}): LocalizedEnrichedExercise[] {
|
}): LocalizedEnrichedExercise[] {
|
||||||
const lang = opts.lang ?? 'en';
|
const lang = opts.lang ?? 'en';
|
||||||
@@ -202,6 +216,11 @@ export function searchAllExercises(opts: {
|
|||||||
if (opts.target) {
|
if (opts.target) {
|
||||||
results = results.filter(e => e.target === opts.target);
|
results = results.filter(e => e.target === opts.target);
|
||||||
}
|
}
|
||||||
|
if (opts.stretchFilter === 'stretch') {
|
||||||
|
results = results.filter(e => isStretchType(e.exerciseType));
|
||||||
|
} else if (opts.stretchFilter === 'non-stretch') {
|
||||||
|
results = results.filter(e => !isStretchType(e.exerciseType));
|
||||||
|
}
|
||||||
if (opts.muscleGroups?.length) {
|
if (opts.muscleGroups?.length) {
|
||||||
const groups = new Set(opts.muscleGroups);
|
const groups = new Set(opts.muscleGroups);
|
||||||
results = results.filter(e => {
|
results = results.filter(e => {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export const METRIC_PRESETS = {
|
|||||||
timed: ['duration', 'reps'] as MetricField[]
|
timed: ['duration', 'reps'] as MetricField[]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ExerciseType = 'STRENGTH' | 'CARDIO' | 'PLYOMETRICS' | 'YOGA' | 'STRETCHING' | 'WEIGHTLIFTING';
|
||||||
|
|
||||||
export interface Exercise {
|
export interface Exercise {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -25,6 +27,7 @@ export interface Exercise {
|
|||||||
metrics?: MetricField[];
|
metrics?: MetricField[];
|
||||||
bilateral?: boolean; // true = weight entered is per hand, actual load is 2×
|
bilateral?: boolean; // true = weight entered is per hand, actual load is 2×
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
|
exerciseType?: ExerciseType;
|
||||||
de?: { name: string; instructions: string[] };
|
de?: { name: string; instructions: string[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2107,6 +2110,32 @@ export const exercises: Exercise[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** IDs of static exercises classified as stretches (incl. yoga-style flexibility poses) */
|
||||||
|
const STATIC_STRETCH_IDS = new Set<string>([
|
||||||
|
'neck-circle-stretch',
|
||||||
|
'side-push-neck-stretch',
|
||||||
|
'seated-shoulder-flexor-stretch-bent-knee',
|
||||||
|
'shoulder-stretch-behind-back',
|
||||||
|
'elbows-back-stretch',
|
||||||
|
'back-pec-stretch',
|
||||||
|
'cow-stretch',
|
||||||
|
'thoracic-bridge',
|
||||||
|
'butterfly-yoga-pose',
|
||||||
|
'seated-single-leg-hamstring-stretch',
|
||||||
|
'kneeling-toe-up-hamstring-stretch',
|
||||||
|
'side-lunge-stretch',
|
||||||
|
'lying-lower-back-stretch',
|
||||||
|
'calf-stretch-wall',
|
||||||
|
'elbow-flexor-stretch',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Apply exerciseType classification post-hoc so we don't need to touch each entry
|
||||||
|
for (const ex of exercises) {
|
||||||
|
if (!ex.exerciseType && STATIC_STRETCH_IDS.has(ex.id)) {
|
||||||
|
ex.exerciseType = ex.id === 'butterfly-yoga-pose' ? 'YOGA' : 'STRETCHING';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Lookup map for O(1) access by ID
|
// Lookup map for O(1) access by ID
|
||||||
export const exerciseMap = new Map<string, Exercise>(exercises.map((e) => [e.id, e]));
|
export const exerciseMap = new Map<string, Exercise>(exercises.map((e) => [e.id, e]));
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,13 @@ const translations: Translations = {
|
|||||||
all_body_parts: { en: 'All body parts', de: 'Alle Körperteile' },
|
all_body_parts: { en: 'All body parts', de: 'Alle Körperteile' },
|
||||||
all_equipment: { en: 'All equipment', de: 'Alle Geräte' },
|
all_equipment: { en: 'All equipment', de: 'Alle Geräte' },
|
||||||
no_exercises_match: { en: 'No exercises match your search.', de: 'Keine Übungen gefunden.' },
|
no_exercises_match: { en: 'No exercises match your search.', de: 'Keine Übungen gefunden.' },
|
||||||
|
type_any: { en: 'Any type', de: 'Alle Arten' },
|
||||||
|
type_weights: { en: 'Strength', de: 'Kraft' },
|
||||||
|
type_stretches: { en: 'Stretches', de: 'Dehnen' },
|
||||||
|
stretch_pill: { en: 'Stretch', de: 'Dehnung' },
|
||||||
|
strength_pill: { en: 'Strength', de: 'Kraft' },
|
||||||
|
cardio_pill: { en: 'Cardio', de: 'Cardio' },
|
||||||
|
plyo_pill: { en: 'Plyo', de: 'Plyo' },
|
||||||
|
|
||||||
// Exercise detail
|
// Exercise detail
|
||||||
about: { en: 'ABOUT', de: 'INFO' },
|
about: { en: 'ABOUT', de: 'INFO' },
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { Search } from '@lucide/svelte';
|
import { Search, Cable, Cog, Dumbbell, PersonStanding, Shapes, Weight, BicepsFlexed, Layers } from '@lucide/svelte';
|
||||||
import { getFilterOptionsAll, searchAllExercises } from '$lib/data/exercisedb';
|
import { getFilterOptionsAll, searchAllExercises, isStretchType } from '$lib/data/exercisedb';
|
||||||
import { translateTerm } from '$lib/data/exercises';
|
import { translateTerm } from '$lib/data/exercises';
|
||||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||||
import { MUSCLE_GROUPS, MUSCLE_GROUP_DE } from '$lib/data/muscleMap';
|
import { MUSCLE_GROUPS, MUSCLE_GROUP_DE } from '$lib/data/muscleMap';
|
||||||
@@ -17,23 +16,27 @@
|
|||||||
let query = $state('');
|
let query = $state('');
|
||||||
let equipmentFilters = $state([]);
|
let equipmentFilters = $state([]);
|
||||||
let muscleGroups = $state([]);
|
let muscleGroups = $state([]);
|
||||||
|
/** @type {'all' | 'stretch' | 'non-stretch'} */
|
||||||
|
let typeFilter = $state('all');
|
||||||
|
|
||||||
const filterOptions = getFilterOptionsAll();
|
const filterOptions = getFilterOptionsAll();
|
||||||
|
|
||||||
/** All selectable muscle/body-part options for the dropdown */
|
/** All selectable muscle/body-part options (anatomical order) */
|
||||||
const allMuscleOptions = [...MUSCLE_GROUPS];
|
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 */
|
/** Display label for a muscle group */
|
||||||
function muscleLabel(group) {
|
function muscleLabel(group) {
|
||||||
const raw = isEn ? group : (MUSCLE_GROUP_DE[group] ?? group);
|
const raw = isEn ? group : (MUSCLE_GROUP_DE[group] ?? group);
|
||||||
return raw.charAt(0).toUpperCase() + raw.slice(1);
|
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) {
|
function addMuscle(group) {
|
||||||
if (group && !muscleGroups.includes(group)) {
|
if (group && !muscleGroups.includes(group)) {
|
||||||
muscleGroups = [...muscleGroups, group];
|
muscleGroups = [...muscleGroups, group];
|
||||||
@@ -44,10 +47,6 @@
|
|||||||
muscleGroups = muscleGroups.filter(g => g !== group);
|
muscleGroups = muscleGroups.filter(g => g !== group);
|
||||||
}
|
}
|
||||||
|
|
||||||
const availableEquipment = $derived(
|
|
||||||
filterOptions.equipment.filter(e => !equipmentFilters.includes(e))
|
|
||||||
);
|
|
||||||
|
|
||||||
function addEquipment(eq) {
|
function addEquipment(eq) {
|
||||||
if (eq && !equipmentFilters.includes(eq)) {
|
if (eq && !equipmentFilters.includes(eq)) {
|
||||||
equipmentFilters = [...equipmentFilters, eq];
|
equipmentFilters = [...equipmentFilters, eq];
|
||||||
@@ -63,10 +62,33 @@
|
|||||||
return raw.charAt(0).toUpperCase() + raw.slice(1);
|
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({
|
const filtered = $derived(searchAllExercises({
|
||||||
search: query || undefined,
|
search: query || undefined,
|
||||||
equipment: equipmentFilters.length ? equipmentFilters : undefined,
|
equipment: equipmentFilters.length ? equipmentFilters : undefined,
|
||||||
muscleGroups: muscleGroups.length ? muscleGroups : undefined,
|
muscleGroups: muscleGroups.length ? muscleGroups : undefined,
|
||||||
|
stretchFilter: typeFilter === 'all' ? undefined : typeFilter,
|
||||||
lang
|
lang
|
||||||
}));
|
}));
|
||||||
</script>
|
</script>
|
||||||
@@ -91,47 +113,98 @@
|
|||||||
<input type="text" placeholder={t('search_exercises', lang)} bind:value={query} />
|
<input type="text" placeholder={t('search_exercises', lang)} bind:value={query} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filters">
|
<div class="type-toggle" role="tablist" aria-label={isEn ? 'Exercise type filter' : 'Filter nach Übungsart'}>
|
||||||
<select onchange={(e) => { addMuscle(e.target.value); e.target.value = ''; }}>
|
<button
|
||||||
<option value="">{isEn ? 'Muscle group' : 'Muskelgruppe'}</option>
|
role="tab"
|
||||||
{#each availableOptions as group}
|
aria-selected={typeFilter === 'all'}
|
||||||
<option value={group}>{muscleLabel(group)}</option>
|
class="type-btn"
|
||||||
{/each}
|
class:active={typeFilter === 'all'}
|
||||||
</select>
|
onclick={() => typeFilter = 'all'}
|
||||||
<select onchange={(e) => { addEquipment(e.target.value); e.target.value = ''; }}>
|
>
|
||||||
<option value="">{t('all_equipment', lang)}</option>
|
<Layers size={14} strokeWidth={2.2} />
|
||||||
{#each availableEquipment as eq}
|
<span>{t('type_any', lang)}</span>
|
||||||
<option value={eq}>{equipmentLabel(eq)}</option>
|
</button>
|
||||||
{/each}
|
<button
|
||||||
</select>
|
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>
|
</div>
|
||||||
|
|
||||||
{#if muscleGroups.length > 0 || equipmentFilters.length > 0}
|
<section class="pill-group">
|
||||||
<div class="selected-pills">
|
<div class="pill-group-header">
|
||||||
{#each muscleGroups as group}
|
<span class="pill-group-label">{isEn ? 'Equipment' : 'Ausrüstung'}</span>
|
||||||
<button class="filter-pill muscle" onclick={() => removeMuscle(group)}>
|
{#if equipmentFilters.length > 0}
|
||||||
{muscleLabel(group)}
|
<button class="mini-clear" onclick={() => equipmentFilters = []}>
|
||||||
<span class="pill-remove" aria-hidden="true">×</span>
|
{isEn ? 'clear' : 'löschen'}
|
||||||
</button>
|
</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>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/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">
|
<ul class="exercise-list">
|
||||||
{#each filtered as exercise (exercise.id)}
|
{#each filtered as exercise (exercise.id)}
|
||||||
<li>
|
<li>
|
||||||
<a href="/fitness/{sl.exercises}/{exercise.id}" class="exercise-row">
|
<a href="/fitness/{sl.exercises}/{exercise.id}" class="exercise-row">
|
||||||
<div class="exercise-info">
|
<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>
|
<span class="exercise-meta">{exercise.localBodyPart} · {exercise.localEquipment}</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -209,69 +282,145 @@
|
|||||||
.search-bar input::placeholder {
|
.search-bar input::placeholder {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
.filters {
|
/* Pill group filters (equipment + muscle) */
|
||||||
|
.pill-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
.filters select {
|
.pill-group-header {
|
||||||
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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
align-items: baseline;
|
||||||
gap: 0.3rem;
|
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;
|
all: unset;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-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;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.3rem;
|
gap: 0.35rem;
|
||||||
padding: 0.25rem 0.6rem;
|
padding: 0.35rem 0.75rem;
|
||||||
border-radius: var(--radius-pill, 100px);
|
border-radius: var(--radius-pill, 1000px);
|
||||||
color: var(--color-primary-contrast);
|
background: var(--color-bg-tertiary);
|
||||||
font-size: 0.75rem;
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.78rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
text-transform: capitalize;
|
||||||
cursor: pointer;
|
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 {
|
.chip :global(svg) {
|
||||||
filter: brightness(1.1);
|
flex-shrink: 0;
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
}
|
||||||
.filter-pill:active {
|
.chip:hover {
|
||||||
transform: scale(0.95);
|
background: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
transform: scale(1.04);
|
||||||
}
|
}
|
||||||
.filter-pill.muscle {
|
.chip:active {
|
||||||
background: var(--lightblue);
|
transform: scale(0.96);
|
||||||
color: black;
|
|
||||||
}
|
}
|
||||||
.filter-pill.equipment {
|
.chip.active {
|
||||||
background: var(--blue);
|
background: var(--color-primary);
|
||||||
color: white;
|
color: var(--color-text-on-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
.pill-remove {
|
.chip.equipment-chip :global(svg) {
|
||||||
font-size: 0.7rem;
|
opacity: 0.85;
|
||||||
font-weight: bold;
|
|
||||||
margin-left: 0.1rem;
|
|
||||||
}
|
}
|
||||||
.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;
|
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-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
cursor: pointer;
|
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);
|
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 {
|
.exercise-list {
|
||||||
|
|||||||
@@ -1,6 +1,25 @@
|
|||||||
<script>
|
<script>
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { getEnrichedExerciseById } from '$lib/data/exercisedb';
|
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 { localizeExercise, translateTerm } from '$lib/data/exercises';
|
||||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||||
import { ChevronRight } from '@lucide/svelte';
|
import { ChevronRight } from '@lucide/svelte';
|
||||||
@@ -36,6 +55,7 @@
|
|||||||
let activeTab = $state('about');
|
let activeTab = $state('about');
|
||||||
|
|
||||||
const exercise = $derived(data.exercise ?? getEnrichedExerciseById($page.params.id, lang));
|
const exercise = $derived(data.exercise ?? getEnrichedExerciseById($page.params.id, lang));
|
||||||
|
const typeInfo = $derived(exerciseTypeInfo(exercise?.exerciseType, lang));
|
||||||
const similar = $derived(data.similar ?? []);
|
const similar = $derived(data.similar ?? []);
|
||||||
const history = $derived(data.history?.history ?? []);
|
const history = $derived(data.history?.history ?? []);
|
||||||
const stats = $derived(data.stats ?? {});
|
const stats = $derived(data.stats ?? {});
|
||||||
@@ -133,6 +153,9 @@
|
|||||||
<div class="about-main">
|
<div class="about-main">
|
||||||
<!-- Tags -->
|
<!-- Tags -->
|
||||||
<div class="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 body-part">{exercise?.localBodyPart}</span>
|
||||||
<span class="tag equipment">{exercise?.localEquipment}</span>
|
<span class="tag equipment">{exercise?.localEquipment}</span>
|
||||||
<span class="tag target">{exercise?.localTarget}</span>
|
<span class="tag target">{exercise?.localTarget}</span>
|
||||||
@@ -350,6 +373,17 @@
|
|||||||
.body-part { background: rgba(94, 129, 172, 0.2); color: var(--nord9); }
|
.body-part { background: rgba(94, 129, 172, 0.2); color: var(--nord9); }
|
||||||
.equipment { background: rgba(163, 190, 140, 0.2); color: var(--nord14); }
|
.equipment { background: rgba(163, 190, 140, 0.2); color: var(--nord14); }
|
||||||
.target { background: rgba(208, 135, 112, 0.2); color: var(--nord12); }
|
.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 — two-column on wide screens */
|
||||||
.about-layout {
|
.about-layout {
|
||||||
|
|||||||
Reference in New Issue
Block a user