Pārlūkot izejas kodu

Create new workout submission.

Lee Morgan 4 dienas atpakaļ
vecāks
revīzija
adf6dafb02

+ 100 - 0
src/lib/server/workouts.js

@@ -0,0 +1,100 @@
+export const EXERCISE_TYPE_VALUES = /** @type {const} */ ([
+	'weights',
+	'bodyweight',
+	'timed',
+	'distance',
+	'stretch'
+]);
+
+const WORKOUTS = 'workouts';
+
+/**
+ * @param {unknown} type
+ * @returns {type is typeof EXERCISE_TYPE_VALUES[number]}
+ */
+export function isExerciseType(type) {
+	return EXERCISE_TYPE_VALUES.includes(/** @type {typeof EXERCISE_TYPE_VALUES[number]} */ (type));
+}
+
+/**
+ * @param {{ title?: string, exercises?: Array<{ name?: string, type?: string }> }} input
+ * @returns {{ ok: true, title: string, exercises: Array<{ name: string, type: typeof EXERCISE_TYPE_VALUES[number] }> } | { ok: false, message: string }}
+ */
+export function validateWorkout(input) {
+	const title = String(input.title ?? '').trim();
+	if (!title) {
+		return { ok: false, message: 'Workout name is required' };
+	}
+
+	const rawExercises = Array.isArray(input.exercises) ? input.exercises : [];
+	if (rawExercises.length === 0) {
+		return { ok: false, message: 'Add at least one exercise' };
+	}
+
+	/** @type {Array<{ name: string, type: typeof EXERCISE_TYPE_VALUES[number] }>} */
+	const exercises = [];
+
+	for (let i = 0; i < rawExercises.length; i++) {
+		const name = String(rawExercises[i]?.name ?? '').trim();
+		const type = String(rawExercises[i]?.type ?? '').trim();
+
+		if (!name) {
+			return { ok: false, message: `Exercise ${i + 1} needs a name` };
+		}
+
+		if (!isExerciseType(type)) {
+			return {
+				ok: false,
+				message: `Exercise ${i + 1} has an invalid type`
+			};
+		}
+
+		exercises.push({ name, type });
+	}
+
+	return { ok: true, title, exercises };
+}
+
+/**
+ * @param {import('mongodb').Db} db
+ * @param {{ userId: string, title: string, exercises: Array<{ name: string, type: string }> }} data
+ * @returns {Promise<{ ok: true, workoutId: string } | { ok: false, message: string }>}
+ */
+export async function createWorkout(db, data) {
+	const userId = String(data.userId ?? '').trim();
+	if (!userId) {
+		return { ok: false, message: 'Not authorized' };
+	}
+
+	const workouts = db.collection(WORKOUTS);
+	await workouts.createIndex({ userId: 1 });
+
+	const doc = {
+		userId,
+		title: data.title,
+		exercises: data.exercises
+	};
+
+	const result = await workouts.insertOne(doc);
+
+	return {
+		ok: true,
+		workoutId: String(result.insertedId)
+	};
+}
+
+/**
+ * @param {import('mongodb').Db} db
+ * @param {string} userId
+ * @param {{ title?: string, exercises?: Array<{ name?: string, type?: string }> }} input
+ */
+export async function saveWorkout(db, userId, input) {
+	const validated = validateWorkout(input);
+	if (!validated.ok) return validated;
+
+	return createWorkout(db, {
+		userId,
+		title: validated.title,
+		exercises: validated.exercises
+	});
+}

+ 1 - 1
src/routes/login/+page.server.js

@@ -23,6 +23,6 @@ export const actions = {
 		});
 		setSessionCookie(cookies, token);
 
-		redirect(303, '/workouts');
+		redirect(303, '/workout');
 	}
 };

+ 1 - 1
src/routes/login/+page.svelte

@@ -15,7 +15,7 @@
 
 	onMount(() => {
 		if (localStorage.getItem('loggedIn') === 'true') {
-			goto('/workouts');
+			goto('/workout');
 			return;
 		}
 		emailInput?.focus();

+ 0 - 0
src/routes/workouts/+page.server.js → src/routes/workout/+page.server.js


+ 1 - 1
src/routes/workouts/+page.svelte → src/routes/workout/+page.svelte

@@ -24,7 +24,7 @@
 					<h1>Workouts</h1>
 					<p class="lede">Signed in as {data.user.name}. Your sessions will show up here.</p>
 				</div>
-				<a class="btn btn-primary new-workout" href="/workouts/new">New workout</a>
+				<a class="btn btn-primary new-workout" href="/workout/new">New workout</a>
 			</div>
 		</section>
 	</main>

+ 42 - 0
src/routes/workout/new/+page.server.js

@@ -0,0 +1,42 @@
+import { fail, redirect } from '@sveltejs/kit';
+import { requireUser } from '$lib/server/auth.js';
+import { saveWorkout } from '$lib/server/workouts.js';
+
+/** @type {import('./$types').PageServerLoad} */
+export async function load(event) {
+	const user = await requireUser(event);
+	return { user };
+}
+
+/** @type {import('./$types').Actions} */
+export const actions = {
+	default: async (event) => {
+		const user = await requireUser(event);
+		const form = await event.request.formData();
+
+		const title = String(form.get('title') ?? '');
+		const names = form.getAll('exerciseName').map((value) => String(value ?? ''));
+		const types = form.getAll('exerciseType').map((value) => String(value ?? ''));
+
+		const count = Math.max(names.length, types.length);
+		/** @type {Array<{ name: string, type: string }>} */
+		const exercises = [];
+		for (let i = 0; i < count; i++) {
+			exercises.push({
+				name: names[i] ?? '',
+				type: types[i] ?? ''
+			});
+		}
+
+		const result = await saveWorkout(event.locals.db, user.id, {
+			title,
+			exercises
+		});
+
+		if (!result.ok) {
+			return fail(400, { message: result.message });
+		}
+
+		redirect(303, '/workout');
+	}
+};

+ 50 - 14
src/routes/workouts/new/+page.svelte → src/routes/workout/new/+page.svelte

@@ -1,5 +1,7 @@
 <script>
+	import { applyAction, enhance } from '$app/forms';
 	import logoMark from '$lib/images/logo_white.svg';
+	import { notifier } from '$lib/notifier.svelte.js';
 
 	const EXERCISE_TYPES = [
 		{ value: 'weights', label: 'Weights' },
@@ -18,6 +20,7 @@
 
 	let title = $state('');
 	let exercises = $state([emptyExercise()]);
+	let submitting = $state(false);
 
 	function addExercise() {
 		exercises = [...exercises, emptyExercise()];
@@ -30,13 +33,6 @@
 		if (exercises.length <= 1) return;
 		exercises = exercises.filter((exercise) => exercise.key !== key);
 	}
-
-	/**
-	 * @param {SubmitEvent} event
-	 */
-	function handleSubmit(event) {
-		event.preventDefault();
-	}
 </script>
 
 <svelte:head>
@@ -50,7 +46,7 @@
 			<span class="brand-name">Torus</span>
 		</a>
 		<div class="top-actions">
-			<a class="btn btn-ghost" href="/workouts">Back</a>
+			<a class="btn btn-ghost" href="/workout">Back</a>
 			<a class="btn btn-ghost" href="/logout">Log out</a>
 		</div>
 	</header>
@@ -62,7 +58,33 @@
 				<p class="lede">Name the session, then add as many exercises as you need.</p>
 			</div>
 
-			<form class="form" method="POST" onsubmit={handleSubmit}>
+			<form
+				class="form"
+				method="POST"
+				use:enhance={() => {
+					submitting = true;
+					return async ({ result, update }) => {
+						submitting = false;
+
+						if (result.type === 'failure') {
+							const message =
+								result.data && typeof result.data === 'object' && 'message' in result.data
+									? String(result.data.message)
+									: 'Could not create workout';
+							notifier.fail(message);
+							await update({ reset: false });
+						} else if (result.type === 'redirect') {
+							notifier.ok('Workout created');
+							await applyAction(result);
+						} else if (result.type === 'error') {
+							notifier.fail(result.error?.message ?? 'Could not create workout');
+							await update({ reset: false });
+						} else {
+							await update({ reset: false });
+						}
+					};
+				}}
+			>
 				<label class="field">
 					<span class="label">Workout name</span>
 					<input
@@ -71,6 +93,7 @@
 						name="title"
 						required
 						placeholder="Push A"
+						disabled={submitting}
 						bind:value={title}
 					/>
 				</label>
@@ -78,7 +101,12 @@
 				<div class="exercises" aria-label="Exercises">
 					<div class="exercises-head">
 						<h2>Exercises</h2>
-						<button type="button" class="btn btn-ghost" onclick={addExercise}>
+						<button
+							type="button"
+							class="btn btn-ghost"
+							disabled={submitting}
+							onclick={addExercise}
+						>
 							Add exercise
 						</button>
 					</div>
@@ -97,13 +125,19 @@
 											name="exerciseName"
 											required
 											placeholder="Bench press"
+											disabled={submitting}
 											bind:value={exercise.name}
 										/>
 									</label>
 
 									<label class="field field-type">
 										<span class="label">Type</span>
-										<select class="input select" name="exerciseType" bind:value={exercise.type}>
+										<select
+											class="input select"
+											name="exerciseType"
+											disabled={submitting}
+											bind:value={exercise.type}
+										>
 											{#each EXERCISE_TYPES as option}
 												<option value={option.value}>{option.label}</option>
 											{/each}
@@ -113,7 +147,7 @@
 									<button
 										type="button"
 										class="btn btn-ghost remove"
-										disabled={exercises.length <= 1}
+										disabled={submitting || exercises.length <= 1}
 										onclick={() => removeExercise(exercise.key)}
 									>
 										Remove
@@ -125,8 +159,10 @@
 				</div>
 
 				<div class="form-actions">
-					<a class="btn btn-ghost" href="/workouts">Cancel</a>
-					<button class="btn btn-primary" type="submit">Create workout</button>
+					<a class="btn btn-ghost" href="/workout">Cancel</a>
+					<button class="btn btn-primary" type="submit" disabled={submitting}>
+						{submitting ? 'Creating…' : 'Create workout'}
+					</button>
 				</div>
 			</form>
 		</section>

+ 0 - 7
src/routes/workouts/new/+page.server.js

@@ -1,7 +0,0 @@
-import { requireUser } from '$lib/server/auth.js';
-
-/** @type {import('./$types').PageServerLoad} */
-export async function load(event) {
-	const user = await requireUser(event);
-	return { user };
-}