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",
|
||||
"version": "1.29.0",
|
||||
"version": "1.30.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Merges the static exercises.ts catalog with ExerciseDB v2 data
|
||||
* 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 { exerciseDbMap, slugToExerciseDbId } from './exercisedb-map';
|
||||
import { edbMuscleToSimple, edbMusclesToGroups, edbBodyPartToSimple, edbEquipmentToSimple } from './muscleMap';
|
||||
@@ -39,6 +39,7 @@ export interface EnrichedExercise extends Exercise {
|
||||
secondaryMusclesDetailed: string[];
|
||||
heroImage: string | null;
|
||||
videoUrl: string | null;
|
||||
exerciseType?: ExerciseType;
|
||||
}
|
||||
|
||||
export interface LocalizedEnrichedExercise extends LocalizedExercise {
|
||||
@@ -50,6 +51,12 @@ export interface LocalizedEnrichedExercise extends LocalizedExercise {
|
||||
secondaryMusclesDetailed: string[];
|
||||
heroImage: 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
|
||||
@@ -97,6 +104,9 @@ function edbToEnriched(edb: EdbRawExercise, slug: string, staticEx?: Exercise):
|
||||
secondaryMusclesDetailed: edb.secondaryMuscles ?? [],
|
||||
heroImage: `/fitness/exercises/${edb.exerciseId}/720p.jpg`,
|
||||
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: [],
|
||||
heroImage: ex.imageUrl ?? null,
|
||||
videoUrl: null,
|
||||
exerciseType: ex.exerciseType,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -144,6 +155,7 @@ export function localizeEnriched(e: EnrichedExercise, lang: 'en' | 'de'): Locali
|
||||
secondaryMusclesDetailed: e.secondaryMusclesDetailed,
|
||||
heroImage: e.heroImage,
|
||||
videoUrl: e.videoUrl,
|
||||
exerciseType: e.exerciseType,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -187,6 +199,8 @@ export function searchAllExercises(opts: {
|
||||
equipment?: string | string[];
|
||||
target?: string;
|
||||
muscleGroups?: string[];
|
||||
/** 'stretch' keeps only stretches/yoga; 'non-stretch' excludes them */
|
||||
stretchFilter?: 'stretch' | 'non-stretch';
|
||||
lang?: 'en' | 'de';
|
||||
}): LocalizedEnrichedExercise[] {
|
||||
const lang = opts.lang ?? 'en';
|
||||
@@ -202,6 +216,11 @@ export function searchAllExercises(opts: {
|
||||
if (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) {
|
||||
const groups = new Set(opts.muscleGroups);
|
||||
results = results.filter(e => {
|
||||
|
||||
@@ -14,6 +14,8 @@ export const METRIC_PRESETS = {
|
||||
timed: ['duration', 'reps'] as MetricField[]
|
||||
};
|
||||
|
||||
export type ExerciseType = 'STRENGTH' | 'CARDIO' | 'PLYOMETRICS' | 'YOGA' | 'STRETCHING' | 'WEIGHTLIFTING';
|
||||
|
||||
export interface Exercise {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -25,6 +27,7 @@ export interface Exercise {
|
||||
metrics?: MetricField[];
|
||||
bilateral?: boolean; // true = weight entered is per hand, actual load is 2×
|
||||
imageUrl?: string;
|
||||
exerciseType?: ExerciseType;
|
||||
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
|
||||
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_equipment: { en: 'All equipment', de: 'Alle Geräte' },
|
||||
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
|
||||
about: { en: 'ABOUT', de: 'INFO' },
|
||||
|
||||
@@ -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