feat: add template library for browsing and adding defaults
All checks were successful
CI / update (push) Successful in 3m55s

Replace auto-seed with a browsable template library. Users can
selectively add built-in templates to their collection via a
BookOpen icon or the empty-state prompt. Each library template
tracks its origin via libraryId to prevent duplicates.

- Extract default templates to shared $lib/data/defaultTemplates.ts
- Add GET/POST /api/fitness/templates/library endpoint
- Add library modal with add/added state per template
- Keep seed endpoint as fallback (imports from shared data)
This commit is contained in:
2026-04-11 15:01:30 +02:00
parent 8591e5cff7
commit 9527c253ed
7 changed files with 333 additions and 418 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.24.1", "version": "1.25.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -0,0 +1,91 @@
/**
* Built-in workout template library.
* Each template has a stable `id` for dedup (so users can't add the same one twice).
*/
export const defaultTemplates = [
{
id: 'default-pull',
name: 'Day 1 - Pull',
description: 'Back, biceps, and shoulders pull day',
exercises: [
{ exerciseId: 'bent-over-row-barbell', sets: [{ reps: 10, weight: 60, rpe: 7 }, { reps: 10, weight: 60, rpe: 8 }, { reps: 10, weight: 60, rpe: 9 }], restTime: 120 },
{ exerciseId: 'pull-up', sets: [{ reps: 6, rpe: 8 }, { reps: 6, rpe: 8 }, { reps: 6, rpe: 9 }], restTime: 120 },
{ exerciseId: 'incline-row-dumbbell', sets: [{ reps: 12, weight: 16, rpe: 7 }, { reps: 12, weight: 16, rpe: 8 }], restTime: 90 },
{ exerciseId: 'upright-row-barbell', sets: [{ reps: 12, weight: 30, rpe: 7 }, { reps: 12, weight: 30, rpe: 8 }], restTime: 90 },
{ exerciseId: 'decline-crunch', sets: [{ reps: 15, rpe: 7 }, { reps: 15, rpe: 8 }], restTime: 60 },
{ exerciseId: 'lateral-raise-dumbbell', sets: [{ reps: 15, weight: 10, rpe: 7 }, { reps: 15, weight: 10, rpe: 8 }], restTime: 90 },
{ exerciseId: 'front-raise-dumbbell', sets: [{ reps: 10, weight: 10, rpe: 7 }, { reps: 10, weight: 10, rpe: 8 }], restTime: 90 },
]
},
{
id: 'default-push',
name: 'Day 2 - Push',
description: 'Chest, triceps, and biceps push day',
exercises: [
{ exerciseId: 'bench-press-barbell', sets: [{ reps: 8, weight: 80, rpe: 7 }, { reps: 8, weight: 80, rpe: 8 }, { reps: 8, weight: 80, rpe: 9 }], restTime: 120 },
{ exerciseId: 'incline-bench-press-barbell', sets: [{ reps: 10, weight: 60, rpe: 7 }, { reps: 10, weight: 60, rpe: 8 }], restTime: 120 },
{ exerciseId: 'skullcrusher-dumbbell', sets: [{ reps: 15, weight: 15, rpe: 7 }, { reps: 15, weight: 15, rpe: 8 }], restTime: 90 },
{ exerciseId: 'bench-press-close-grip-barbell', sets: [{ reps: 10, weight: 60, rpe: 7 }, { reps: 10, weight: 60, rpe: 8 }], restTime: 120 },
{ exerciseId: 'hammer-curl-dumbbell', sets: [{ reps: 15, weight: 12, rpe: 7 }, { reps: 15, weight: 12, rpe: 8 }], restTime: 90 },
{ exerciseId: 'bicep-curl-dumbbell', sets: [{ reps: 15, weight: 10, rpe: 7 }, { reps: 15, weight: 10, rpe: 8 }], restTime: 90 },
]
},
{
id: 'default-legs',
name: 'Day 3 - Legs',
description: 'Quad, hamstring, and calf focused leg day',
exercises: [
{ exerciseId: 'squat-barbell', sets: [{ reps: 8, weight: 80, rpe: 7 }, { reps: 8, weight: 80, rpe: 8 }, { reps: 8, weight: 80, rpe: 9 }], restTime: 150 },
{ exerciseId: 'romanian-deadlift-barbell', sets: [{ reps: 10, weight: 70, rpe: 7 }, { reps: 10, weight: 70, rpe: 8 }, { reps: 10, weight: 70, rpe: 9 }], restTime: 120 },
{ exerciseId: 'bulgarian-split-squat-dumbbell', sets: [{ reps: 10, weight: 16, rpe: 7 }, { reps: 10, weight: 16, rpe: 8 }], restTime: 120 },
{ exerciseId: 'calf-raise-standing', sets: [{ reps: 15, rpe: 7 }, { reps: 15, rpe: 8 }, { reps: 15, rpe: 9 }], restTime: 60 },
]
},
{
id: 'default-upper',
name: 'Day 4 - Upper',
description: 'Full upper body day — shoulders, chest, back, and core',
exercises: [
{ exerciseId: 'overhead-press-barbell', sets: [{ reps: 8, weight: 40, rpe: 7 }, { reps: 8, weight: 40, rpe: 8 }], restTime: 120 },
{ exerciseId: 'bench-press-dumbbell', sets: [{ reps: 10, weight: 28, rpe: 7 }, { reps: 10, weight: 28, rpe: 8 }], restTime: 120 },
{ exerciseId: 'chin-up', sets: [{ reps: 6, rpe: 8 }, { reps: 6, rpe: 9 }], restTime: 120 },
{ exerciseId: 'bench-press-close-grip-barbell', sets: [{ reps: 10, weight: 60, rpe: 7 }, { reps: 10, weight: 60, rpe: 8 }], restTime: 120 },
{ exerciseId: 'decline-crunch', sets: [{ reps: 15, rpe: 7 }, { reps: 15, rpe: 8 }], restTime: 60 },
{ exerciseId: 'flat-leg-raise', sets: [{ reps: 15, rpe: 7 }, { reps: 15, rpe: 8 }], restTime: 60 },
]
},
{
id: 'default-lower',
name: 'Day 5 - Lower',
description: 'Glute, quad, and hamstring focused lower day',
exercises: [
{ exerciseId: 'front-squat-barbell', sets: [{ reps: 8, weight: 60, rpe: 7 }, { reps: 8, weight: 60, rpe: 8 }, { reps: 8, weight: 60, rpe: 9 }], restTime: 150 },
{ exerciseId: 'romanian-deadlift-dumbbell', sets: [{ reps: 10, weight: 24, rpe: 7 }, { reps: 10, weight: 24, rpe: 8 }], restTime: 120 },
{ exerciseId: 'hip-thrust-barbell', sets: [{ reps: 10, weight: 60, rpe: 7 }, { reps: 10, weight: 60, rpe: 8 }], restTime: 120 },
{ exerciseId: 'goblet-squat-dumbbell', sets: [{ reps: 12, weight: 20, rpe: 7 }, { reps: 12, weight: 20, rpe: 8 }], restTime: 90 },
{ exerciseId: 'calf-raise-standing', sets: [{ reps: 15, rpe: 7 }, { reps: 15, rpe: 8 }], restTime: 60 },
]
},
{
id: 'default-stretching',
name: 'Day 6 - Stretching',
description: 'Full-body stretching — all muscle groups, no equipment (~30 min)',
exercises: [
{ exerciseId: 'neck-circle-stretch', sets: [{ duration: 1.5 }, { duration: 1.5 }], restTime: 15 },
{ exerciseId: 'side-push-neck-stretch', sets: [{ duration: 1 }, { duration: 1 }], restTime: 15 },
{ exerciseId: 'seated-shoulder-flexor-stretch-bent-knee', sets: [{ duration: 1 }, { duration: 1 }], restTime: 15 },
{ exerciseId: 'shoulder-stretch-behind-back', sets: [{ duration: 1 }, { duration: 1 }], restTime: 15 },
{ exerciseId: 'elbows-back-stretch', sets: [{ duration: 1 }, { duration: 1 }], restTime: 15 },
{ exerciseId: 'back-pec-stretch', sets: [{ duration: 1 }, { duration: 1 }], restTime: 15 },
{ exerciseId: 'cow-stretch', sets: [{ duration: 1.5 }, { duration: 1.5 }], restTime: 15 },
{ exerciseId: 'thoracic-bridge', sets: [{ duration: 1 }, { duration: 1 }], restTime: 15 },
{ exerciseId: 'butterfly-yoga-pose', sets: [{ duration: 1.5 }, { duration: 1.5 }], restTime: 15 },
{ exerciseId: 'seated-single-leg-hamstring-stretch', sets: [{ duration: 1 }, { duration: 1 }], restTime: 15 },
{ exerciseId: 'kneeling-toe-up-hamstring-stretch', sets: [{ duration: 1 }, { duration: 1 }], restTime: 15 },
{ exerciseId: 'side-lunge-stretch', sets: [{ duration: 1 }, { duration: 1 }], restTime: 15 },
{ exerciseId: 'lying-lower-back-stretch', sets: [{ duration: 1.5 }, { duration: 1.5 }], restTime: 15 },
{ exerciseId: 'calf-stretch-wall', sets: [{ duration: 1 }, { duration: 1 }], restTime: 15 },
{ exerciseId: 'elbow-flexor-stretch', sets: [{ duration: 1 }, { duration: 1 }], restTime: 15 },
]
}
];

View File

@@ -115,7 +115,11 @@ const translations: Translations = {
templates: { en: 'Templates', de: 'Vorlagen' }, templates: { en: 'Templates', de: 'Vorlagen' },
schedule: { en: 'Schedule', de: 'Zeitplan' }, schedule: { en: 'Schedule', de: 'Zeitplan' },
my_templates: { en: 'My Templates', de: 'Meine Vorlagen' }, my_templates: { en: 'My Templates', de: 'Meine Vorlagen' },
no_templates_yet: { en: 'No templates yet. Create one or start an empty workout.', de: 'Noch keine Vorlagen. Erstelle eine oder starte ein leeres Training.' }, no_templates_yet: { en: 'No templates yet. Browse the library or create your own.', de: 'Noch keine Vorlagen. Stöbere in der Bibliothek oder erstelle deine eigene.' },
template_library: { en: 'Template Library', de: 'Vorlagen-Bibliothek' },
browse_library: { en: 'Browse Library', de: 'Bibliothek durchsuchen' },
template_added: { en: 'Template added', de: 'Vorlage hinzugefügt' },
loading: { en: 'Loading', de: 'Laden' },
edit_template: { en: 'Edit Template', de: 'Vorlage bearbeiten' }, edit_template: { en: 'Edit Template', de: 'Vorlage bearbeiten' },
new_template: { en: 'New Template', de: 'Neue Vorlage' }, new_template: { en: 'New Template', de: 'Neue Vorlage' },
template_name_placeholder: { en: 'Template name', de: 'Vorlagenname' }, template_name_placeholder: { en: 'Template name', de: 'Vorlagenname' },

View File

@@ -25,6 +25,7 @@ export interface IWorkoutTemplate {
exercises: IExercise[]; exercises: IExercise[];
createdBy: string; // username/nickname of the person who created the template createdBy: string; // username/nickname of the person who created the template
isPublic?: boolean; // whether other users can see/use this template isPublic?: boolean; // whether other users can see/use this template
libraryId?: string; // tracks which built-in library template this was created from
createdAt?: Date; createdAt?: Date;
updatedAt?: Date; updatedAt?: Date;
} }
@@ -126,6 +127,10 @@ const WorkoutTemplateSchema = new mongoose.Schema(
isPublic: { isPublic: {
type: Boolean, type: Boolean,
default: false default: false
},
libraryId: {
type: String,
default: undefined
} }
}, },
{ {

View File

@@ -0,0 +1,58 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth';
import { dbConnect } from '$utils/db';
import { WorkoutTemplate } from '$models/WorkoutTemplate';
import { defaultTemplates } from '$lib/data/defaultTemplates';
// GET /api/fitness/templates/library — list available library templates
// Returns each template with an `added` flag if user already has it
export const GET: RequestHandler = async ({ locals }) => {
const user = await requireAuth(locals);
await dbConnect();
// Find which library templates the user already saved (by libraryId)
const existing = await WorkoutTemplate.find(
{ createdBy: user.nickname, libraryId: { $exists: true, $ne: null } },
{ libraryId: 1 }
).lean();
const addedIds = new Set(existing.map((t: any) => t.libraryId));
const library = defaultTemplates.map((t) => ({
...t,
added: addedIds.has(t.id),
}));
return json({ templates: library });
};
// POST /api/fitness/templates/library — add a library template to user's collection
export const POST: RequestHandler = async ({ request, locals }) => {
const user = await requireAuth(locals);
await dbConnect();
const { id } = await request.json();
const libTemplate = defaultTemplates.find((t) => t.id === id);
if (!libTemplate) {
return json({ error: 'Template not found in library' }, { status: 404 });
}
// Prevent duplicates
const exists = await WorkoutTemplate.exists({
createdBy: user.nickname,
libraryId: id
});
if (exists) {
return json({ error: 'Template already added' }, { status: 409 });
}
const template = await WorkoutTemplate.create({
name: libTemplate.name,
description: libTemplate.description,
exercises: libTemplate.exercises,
libraryId: libTemplate.id,
createdBy: user.nickname,
});
return json({ template }, { status: 201 });
};

View File

@@ -3,411 +3,7 @@ import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth'; import { requireAuth } from '$lib/server/middleware/auth';
import { dbConnect } from '$utils/db'; import { dbConnect } from '$utils/db';
import { WorkoutTemplate } from '$models/WorkoutTemplate'; import { WorkoutTemplate } from '$models/WorkoutTemplate';
import { defaultTemplates } from '$lib/data/defaultTemplates';
const defaultTemplates = [
{
name: 'Day 1 - Pull',
description: 'Back, biceps, and shoulders pull day',
exercises: [
{
exerciseId: 'bent-over-row-barbell',
sets: [
{ reps: 10, weight: 60, rpe: 7 },
{ reps: 10, weight: 60, rpe: 8 },
{ reps: 10, weight: 60, rpe: 9 }
],
restTime: 120
},
{
exerciseId: 'pull-up',
sets: [
{ reps: 6, rpe: 8 },
{ reps: 6, rpe: 8 },
{ reps: 6, rpe: 9 }
],
restTime: 120
},
{
exerciseId: 'incline-row-dumbbell',
sets: [
{ reps: 12, weight: 16, rpe: 7 },
{ reps: 12, weight: 16, rpe: 8 }
],
restTime: 90
},
{
exerciseId: 'upright-row-barbell',
sets: [
{ reps: 12, weight: 30, rpe: 7 },
{ reps: 12, weight: 30, rpe: 8 }
],
restTime: 90
},
{
exerciseId: 'decline-crunch',
sets: [
{ reps: 15, rpe: 7 },
{ reps: 15, rpe: 8 }
],
restTime: 60
},
{
exerciseId: 'lateral-raise-dumbbell',
sets: [
{ reps: 15, weight: 10, rpe: 7 },
{ reps: 15, weight: 10, rpe: 8 }
],
restTime: 90
},
{
exerciseId: 'front-raise-dumbbell',
sets: [
{ reps: 10, weight: 10, rpe: 7 },
{ reps: 10, weight: 10, rpe: 8 }
],
restTime: 90
}
]
},
{
name: 'Day 2 - Push',
description: 'Chest, triceps, and biceps push day',
exercises: [
{
exerciseId: 'bench-press-barbell',
sets: [
{ reps: 8, weight: 80, rpe: 7 },
{ reps: 8, weight: 80, rpe: 8 },
{ reps: 8, weight: 80, rpe: 9 }
],
restTime: 120
},
{
exerciseId: 'incline-bench-press-barbell',
sets: [
{ reps: 10, weight: 60, rpe: 7 },
{ reps: 10, weight: 60, rpe: 8 }
],
restTime: 120
},
{
exerciseId: 'skullcrusher-dumbbell',
sets: [
{ reps: 15, weight: 15, rpe: 7 },
{ reps: 15, weight: 15, rpe: 8 }
],
restTime: 90
},
{
exerciseId: 'bench-press-close-grip-barbell',
sets: [
{ reps: 10, weight: 60, rpe: 7 },
{ reps: 10, weight: 60, rpe: 8 }
],
restTime: 120
},
{
exerciseId: 'hammer-curl-dumbbell',
sets: [
{ reps: 15, weight: 12, rpe: 7 },
{ reps: 15, weight: 12, rpe: 8 }
],
restTime: 90
},
{
exerciseId: 'bicep-curl-dumbbell',
sets: [
{ reps: 15, weight: 10, rpe: 7 },
{ reps: 15, weight: 10, rpe: 8 }
],
restTime: 90
}
]
},
{
name: 'Day 3 - Legs',
description: 'Quad, hamstring, and calf focused leg day',
exercises: [
{
exerciseId: 'squat-barbell',
sets: [
{ reps: 8, weight: 80, rpe: 7 },
{ reps: 8, weight: 80, rpe: 8 },
{ reps: 8, weight: 80, rpe: 9 }
],
restTime: 150
},
{
exerciseId: 'romanian-deadlift-barbell',
sets: [
{ reps: 10, weight: 70, rpe: 7 },
{ reps: 10, weight: 70, rpe: 8 },
{ reps: 10, weight: 70, rpe: 9 }
],
restTime: 120
},
{
exerciseId: 'bulgarian-split-squat-dumbbell',
sets: [
{ reps: 10, weight: 16, rpe: 7 },
{ reps: 10, weight: 16, rpe: 8 }
],
restTime: 120
},
{
exerciseId: 'calf-raise-standing',
sets: [
{ reps: 15, rpe: 7 },
{ reps: 15, rpe: 8 },
{ reps: 15, rpe: 9 }
],
restTime: 60
}
]
},
{
name: 'Day 4 - Upper',
description: 'Full upper body day — shoulders, chest, back, and core',
exercises: [
{
exerciseId: 'overhead-press-barbell',
sets: [
{ reps: 8, weight: 40, rpe: 7 },
{ reps: 8, weight: 40, rpe: 8 }
],
restTime: 120
},
{
exerciseId: 'bench-press-dumbbell',
sets: [
{ reps: 10, weight: 28, rpe: 7 },
{ reps: 10, weight: 28, rpe: 8 }
],
restTime: 120
},
{
exerciseId: 'chin-up',
sets: [
{ reps: 6, rpe: 8 },
{ reps: 6, rpe: 9 }
],
restTime: 120
},
{
exerciseId: 'bench-press-close-grip-barbell',
sets: [
{ reps: 10, weight: 60, rpe: 7 },
{ reps: 10, weight: 60, rpe: 8 }
],
restTime: 120
},
{
exerciseId: 'decline-crunch',
sets: [
{ reps: 15, rpe: 7 },
{ reps: 15, rpe: 8 }
],
restTime: 60
},
{
exerciseId: 'flat-leg-raise',
sets: [
{ reps: 15, rpe: 7 },
{ reps: 15, rpe: 8 }
],
restTime: 60
}
]
},
{
name: 'Day 5 - Lower',
description: 'Glute, quad, and hamstring focused lower day',
exercises: [
{
exerciseId: 'front-squat-barbell',
sets: [
{ reps: 8, weight: 60, rpe: 7 },
{ reps: 8, weight: 60, rpe: 8 },
{ reps: 8, weight: 60, rpe: 9 }
],
restTime: 150
},
{
exerciseId: 'romanian-deadlift-dumbbell',
sets: [
{ reps: 10, weight: 24, rpe: 7 },
{ reps: 10, weight: 24, rpe: 8 }
],
restTime: 120
},
{
exerciseId: 'hip-thrust-barbell',
sets: [
{ reps: 10, weight: 60, rpe: 7 },
{ reps: 10, weight: 60, rpe: 8 }
],
restTime: 120
},
{
exerciseId: 'goblet-squat-dumbbell',
sets: [
{ reps: 12, weight: 20, rpe: 7 },
{ reps: 12, weight: 20, rpe: 8 }
],
restTime: 90
},
{
exerciseId: 'calf-raise-standing',
sets: [
{ reps: 15, rpe: 7 },
{ reps: 15, rpe: 8 }
],
restTime: 60
}
]
},
{
name: 'Day 6 - Stretching',
description: 'Full-body stretching — all muscle groups, no equipment (~30 min)',
exercises: [
{
// Neck: sternocleidomastoid, levator scapulae, trapezius
exerciseId: 'neck-circle-stretch',
sets: [
{ duration: 1.5 },
{ duration: 1.5 }
],
restTime: 15
},
{
// Neck/traps: lateral neck flexion
exerciseId: 'side-push-neck-stretch',
sets: [
{ duration: 1 },
{ duration: 1 }
],
restTime: 15
},
{
// Chest & front delts
exerciseId: 'seated-shoulder-flexor-stretch-bent-knee',
sets: [
{ duration: 1 },
{ duration: 1 }
],
restTime: 15
},
{
// Rear delts, triceps, traps
exerciseId: 'shoulder-stretch-behind-back',
sets: [
{ duration: 1 },
{ duration: 1 }
],
restTime: 15
},
{
// Triceps
exerciseId: 'elbows-back-stretch',
sets: [
{ duration: 1 },
{ duration: 1 }
],
restTime: 15
},
{
// Lats & back
exerciseId: 'back-pec-stretch',
sets: [
{ duration: 1 },
{ duration: 1 }
],
restTime: 15
},
{
// Spine, erector spinae, levator scapulae
exerciseId: 'cow-stretch',
sets: [
{ duration: 1.5 },
{ duration: 1.5 }
],
restTime: 15
},
{
// Thoracic spine, shoulders, glutes, calves (multi-target bridge)
exerciseId: 'thoracic-bridge',
sets: [
{ duration: 1 },
{ duration: 1 }
],
restTime: 15
},
{
// Hip adductors & hip flexors
exerciseId: 'butterfly-yoga-pose',
sets: [
{ duration: 1.5 },
{ duration: 1.5 }
],
restTime: 15
},
{
// Hamstrings
exerciseId: 'seated-single-leg-hamstring-stretch',
sets: [
{ duration: 1 },
{ duration: 1 }
],
restTime: 15
},
{
// Hamstrings (toe-up variation)
exerciseId: 'kneeling-toe-up-hamstring-stretch',
sets: [
{ duration: 1 },
{ duration: 1 }
],
restTime: 15
},
{
// Quads, glutes, hip flexors, calves
exerciseId: 'side-lunge-stretch',
sets: [
{ duration: 1 },
{ duration: 1 }
],
restTime: 15
},
{
// Lower back & glutes
exerciseId: 'lying-lower-back-stretch',
sets: [
{ duration: 1.5 },
{ duration: 1.5 }
],
restTime: 15
},
{
// Calves (soleus & gastrocnemius)
exerciseId: 'calf-stretch-wall',
sets: [
{ duration: 1 },
{ duration: 1 }
],
restTime: 15
},
{
// Biceps & forearm flexors
exerciseId: 'elbow-flexor-stretch',
sets: [
{ duration: 1 },
{ duration: 1 }
],
restTime: 15
}
]
}
];
export const POST: RequestHandler = async ({ locals }) => { export const POST: RequestHandler = async ({ locals }) => {
const user = await requireAuth(locals); const user = await requireAuth(locals);

View File

@@ -2,7 +2,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { Plus, Trash2, Play, Pencil, X, Save, CalendarClock, ChevronUp, ChevronDown, ArrowRight, MapPin, Dumbbell, Timer } from '@lucide/svelte'; import { Plus, Trash2, Play, Pencil, X, Save, CalendarClock, ChevronUp, ChevronDown, ArrowRight, MapPin, Dumbbell, Timer, BookOpen, Check } from '@lucide/svelte';
import { getWorkout } from '$lib/js/workout.svelte'; import { getWorkout } from '$lib/js/workout.svelte';
import { getWorkoutSync } from '$lib/js/workoutSync.svelte'; import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n'; import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
@@ -20,7 +20,11 @@
const sync = getWorkoutSync(); const sync = getWorkoutSync();
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
let templates = $state(data.templates?.templates ? [...data.templates.templates] : []); let templates = $state(data.templates?.templates ? [...data.templates.templates] : []);
let seeded = $state(false); // Library browser
let showLibrary = $state(false);
/** @type {any[]} */
let libraryTemplates = $state([]);
let libraryLoading = $state(false);
// Schedule state // Schedule state
/** @type {string[]} */ /** @type {string[]} */
@@ -84,15 +88,8 @@
return; return;
} }
if (templates.length === 0 && !seeded) { if (templates.length === 0 && !showLibrary) {
seeded = true; openLibrary();
fetch('/api/fitness/templates/seed', { method: 'POST' }).then(async (res) => {
if (res.ok) {
const refreshRes = await fetch('/api/fitness/templates');
const refreshData = await refreshRes.json();
templates = refreshData.templates ?? [];
}
});
} }
}); });
@@ -135,6 +132,41 @@
await startFromTemplate(nextTemplate); await startFromTemplate(nextTemplate);
} }
async function openLibrary() {
showLibrary = true;
if (libraryTemplates.length > 0) return;
libraryLoading = true;
try {
const res = await fetch('/api/fitness/templates/library');
if (res.ok) {
const data = await res.json();
libraryTemplates = data.templates ?? [];
}
} catch {}
libraryLoading = false;
}
/** @param {any} libTemplate */
async function addFromLibrary(libTemplate) {
try {
const res = await fetch('/api/fitness/templates/library', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: libTemplate.id })
});
if (res.ok) {
const { template } = await res.json();
templates = [...templates, template];
libTemplate.added = true;
libraryTemplates = [...libraryTemplates];
toast.success(t('template_added', lang));
} else if (res.status === 409) {
libTemplate.added = true;
libraryTemplates = [...libraryTemplates];
}
} catch {}
}
function openCreateTemplate() { function openCreateTemplate() {
editingTemplate = null; editingTemplate = null;
editorName = ''; editorName = '';
@@ -425,6 +457,9 @@
<button class="header-icon-btn" onclick={openCreateTemplate} aria-label="Create template"> <button class="header-icon-btn" onclick={openCreateTemplate} aria-label="Create template">
<Plus size={18} /> <Plus size={18} />
</button> </button>
<button class="header-icon-btn" onclick={openLibrary} aria-label="Browse template library">
<BookOpen size={18} />
</button>
<button class="schedule-btn" onclick={openScheduleEditor} aria-label="Edit workout schedule"> <button class="schedule-btn" onclick={openScheduleEditor} aria-label="Edit workout schedule">
<CalendarClock size={16} /> <CalendarClock size={16} />
{t('schedule', lang)} {t('schedule', lang)}
@@ -444,6 +479,10 @@
</div> </div>
{:else} {:else}
<p class="no-templates">{t('no_templates_yet', lang)}</p> <p class="no-templates">{t('no_templates_yet', lang)}</p>
<button class="browse-library-btn" onclick={openLibrary}>
<BookOpen size={16} />
{t('browse_library', lang)}
</button>
{/if} {/if}
</section> </section>
</div> </div>
@@ -702,6 +741,49 @@
</div> </div>
{/if} {/if}
<!-- Library Modal -->
{#if showLibrary}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onkeydown={(e) => e.key === 'Escape' && (showLibrary = false)}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="modal-backdrop" onclick={() => showLibrary = false}></div>
<div class="modal-panel library-panel">
<div class="modal-header">
<h2>{t('template_library', lang)}</h2>
<button class="close-btn" onclick={() => showLibrary = false} aria-label="Close"><X size={20} /></button>
</div>
<div class="modal-body">
{#if libraryLoading}
<p class="library-loading">{t('loading', lang)}...</p>
{:else}
<div class="library-grid">
{#each libraryTemplates as libTmpl (libTmpl.id)}
<div class="library-card" class:added={libTmpl.added}>
<div class="library-card-info">
<h3>{libTmpl.name}</h3>
<p>{libTmpl.description}</p>
<span class="library-card-meta">{libTmpl.exercises.length} {t('exercises_heading', lang)}</span>
</div>
<button
class="library-add-btn"
disabled={libTmpl.added}
onclick={() => addFromLibrary(libTmpl)}
>
{#if libTmpl.added}
<Check size={16} />
{:else}
<Plus size={16} />
{/if}
</button>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/if}
<style> <style>
/* Primary contrast: white in light mode, nord0 in dark mode */ /* Primary contrast: white in light mode, nord0 in dark mode */
:global(:root) { --primary-contrast: white; } :global(:root) { --primary-contrast: white; }
@@ -1376,4 +1458,83 @@
padding: 0.5rem 0; padding: 0.5rem 0;
margin: 0; margin: 0;
} }
/* Browse library button (empty state) */
.browse-library-btn {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.5rem auto 0;
padding: 0.6rem 1.2rem;
background: var(--color-primary);
color: var(--color-text-on-primary);
border: none;
border-radius: var(--radius-md);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
/* Library modal */
.library-panel {
max-width: 500px;
}
.library-loading {
text-align: center;
color: var(--color-text-secondary);
padding: 2rem 0;
}
.library-grid {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.library-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: var(--color-bg-tertiary);
border-radius: var(--radius-md);
}
.library-card.added {
opacity: 0.55;
}
.library-card-info {
flex: 1;
min-width: 0;
}
.library-card-info h3 {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text-primary);
}
.library-card-info p {
margin: 0.15rem 0 0;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.library-card-meta {
font-size: 0.7rem;
color: var(--color-text-tertiary);
}
.library-add-btn {
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: var(--color-primary);
color: var(--color-text-on-primary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.library-add-btn:disabled {
background: var(--color-bg-elevated);
color: var(--nord14);
cursor: default;
}
</style> </style>