Quellcode durchsuchen

Create page for workout options.

Lee Morgan vor 4 Tagen
Ursprung
Commit
52d49454e4

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

@@ -1,3 +1,5 @@
+import { ObjectId } from 'mongodb';
+
 export const EXERCISE_TYPE_VALUES = /** @type {const} */ ([
 	'weights',
 	'bodyweight',
@@ -106,6 +108,37 @@ export async function saveWorkout(db, userId, input) {
  * @property {Array<{ name: string, type: string }>} exercises
  */
 
+/**
+ * Load one workout owned by a user.
+ * @param {import('mongodb').Db} db
+ * @param {string} userId
+ * @param {string} workoutId
+ * @returns {Promise<WorkoutListItem | null>}
+ */
+export async function getWorkoutForUser(db, userId, workoutId) {
+	const ownerId = String(userId ?? '').trim();
+	const id = String(workoutId ?? '').trim();
+	if (!ownerId || !id || !ObjectId.isValid(id)) return null;
+
+	const doc = await db.collection(WORKOUTS).findOne({
+		_id: new ObjectId(id),
+		userId: ownerId
+	});
+
+	if (!doc) return null;
+
+	return {
+		id: String(doc._id),
+		title: String(doc.title ?? ''),
+		exercises: Array.isArray(doc.exercises)
+			? doc.exercises.map((exercise) => ({
+					name: String(exercise?.name ?? ''),
+					type: String(exercise?.type ?? '')
+				}))
+			: []
+	};
+}
+
 /**
  * List all workouts owned by a user.
  * @param {import('mongodb').Db} db

+ 19 - 0
src/routes/workout/[workoutId]/+page.server.js

@@ -0,0 +1,19 @@
+import { error } from '@sveltejs/kit';
+import { requireUser } from '$lib/server/auth.js';
+import { getWorkoutForUser } from '$lib/server/workouts.js';
+
+/** @type {import('./$types').PageServerLoad} */
+export async function load(event) {
+	const user = await requireUser(event);
+	const workout = await getWorkoutForUser(
+		event.locals.db,
+		user.id,
+		event.params.workoutId
+	);
+
+	if (!workout) {
+		error(404, 'Workout not found');
+	}
+
+	return { user, workout };
+}

+ 202 - 0
src/routes/workout/[workoutId]/+page.svelte

@@ -0,0 +1,202 @@
+<script>
+	import logoMark from '$lib/images/logo_white.svg';
+
+	let { data } = $props();
+
+	const options = $derived([
+		{ label: 'Start Workout', href: `/workout/${data.workout.id}/session` },
+		{ label: 'Workout History', href: `/workout/${data.workout.id}/history` },
+		{ label: 'Edit Workout', href: `/workout/${data.workout.id}/edit` },
+		{ label: 'Delete Workout', href: `/workout/${data.workout.id}/delete` }
+	]);
+</script>
+
+<svelte:head>
+	<title>{data.workout.title} — Torus</title>
+</svelte:head>
+
+<div class="page">
+	<header class="top">
+		<a class="brand" href="/">
+			<img class="brand-mark" src={logoMark} alt="" width="28" height="28" />
+			<span class="brand-name">Torus</span>
+		</a>
+		<div class="top-actions">
+			<a class="btn btn-ghost" href="/workout">Back</a>
+			<a class="btn btn-ghost" href="/logout">Log out</a>
+		</div>
+	</header>
+
+	<main>
+		<div class="page-head">
+			<h1>{data.workout.title}</h1>
+			<p class="lede">Choose what to do with this workout.</p>
+		</div>
+
+		<ul class="option-list" role="list">
+			{#each options as option}
+				<li class="option-item">
+					<a class="option-btn" href={option.href}>
+						<span class="option-btn-label">{option.label}</span>
+						<span class="option-btn-arrow" aria-hidden="true">→</span>
+					</a>
+				</li>
+			{/each}
+		</ul>
+	</main>
+</div>
+
+<style>
+	.page {
+		min-height: 100vh;
+		display: flex;
+		flex-direction: column;
+	}
+
+	.top {
+		display: flex;
+		align-items: center;
+		justify-content: space-between;
+		gap: 16px;
+		padding: 16px 20px;
+		border-bottom: 1px solid var(--border);
+	}
+
+	.top-actions {
+		display: flex;
+		align-items: center;
+		gap: 8px;
+	}
+
+	.brand {
+		display: inline-flex;
+		align-items: center;
+		gap: 10px;
+		font-weight: 700;
+		letter-spacing: -0.02em;
+	}
+
+	.brand-mark {
+		display: block;
+		width: 28px;
+		height: 28px;
+		flex-shrink: 0;
+	}
+
+	.brand-name {
+		font-size: 15px;
+	}
+
+	main {
+		flex: 1;
+		width: min(720px, 100%);
+		margin: 0 auto;
+		padding: 40px 20px 64px;
+	}
+
+	.page-head {
+		margin-bottom: 20px;
+	}
+
+	h1 {
+		margin: 0;
+		font-size: 1.6rem;
+		letter-spacing: -0.03em;
+		font-weight: 750;
+	}
+
+	.lede {
+		margin: 8px 0 0;
+		color: var(--text-muted);
+		line-height: 1.45;
+	}
+
+	.option-list {
+		list-style: none;
+		margin: 0;
+		padding: 0;
+		display: flex;
+		flex-direction: column;
+		gap: 10px;
+		width: 100%;
+	}
+
+	.option-item {
+		width: 100%;
+	}
+
+	.option-btn {
+		display: flex;
+		align-items: center;
+		justify-content: space-between;
+		gap: 16px;
+		box-sizing: border-box;
+		width: 100%;
+		margin: 0;
+		padding: 16px 18px;
+		border: 1px solid var(--graphite);
+		border-radius: 10px;
+		background: var(--plate);
+		color: var(--bone);
+		font: inherit;
+		font-size: 15px;
+		font-weight: 650;
+		line-height: 1.2;
+		text-align: left;
+		text-decoration: none;
+		cursor: pointer;
+	}
+
+	.option-btn-label {
+		flex: 1;
+		min-width: 0;
+		overflow: hidden;
+		text-overflow: ellipsis;
+		white-space: nowrap;
+		text-align: left;
+	}
+
+	.option-btn-arrow {
+		flex-shrink: 0;
+		display: inline-flex;
+		align-items: center;
+		justify-content: center;
+		font-size: 2.75rem;
+		font-weight: 700;
+		line-height: 1;
+		color: var(--ember);
+	}
+
+	.option-btn:hover {
+		background: var(--steel);
+	}
+
+	.option-btn:hover .option-btn-arrow {
+		color: var(--heat);
+	}
+
+	.option-btn:focus-visible {
+		outline: 2px solid var(--focus);
+		outline-offset: 2px;
+	}
+
+	.option-btn:active {
+		background: var(--forge);
+		color: var(--ember-ink);
+		border-color: var(--forge);
+	}
+
+	.option-btn:active .option-btn-arrow {
+		color: var(--ember-ink);
+	}
+
+	@media (min-width: 720px) {
+		.top {
+			padding: 16px 28px;
+		}
+
+		main {
+			padding: 48px 28px 80px;
+		}
+	}
+</style>