feat: add template library for browsing and adding defaults
All checks were successful
CI / update (push) Successful in 3m55s
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:
@@ -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": {
|
||||||
|
|||||||
91
src/lib/data/defaultTemplates.ts
Normal file
91
src/lib/data/defaultTemplates.ts
Normal 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 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
58
src/routes/api/fitness/templates/library/+server.ts
Normal file
58
src/routes/api/fitness/templates/library/+server.ts
Normal 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 });
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user