Initial commit for Start9 packaging
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function POST(_request: NextRequest) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const sessionCookie = cookieStore.get('sessionToken');
|
||||
|
||||
if (sessionCookie) {
|
||||
// Delete the session from the database
|
||||
await prisma.session.delete({
|
||||
where: {
|
||||
token: sessionCookie.value,
|
||||
},
|
||||
}).catch(() => {
|
||||
// Session might not exist, that's ok
|
||||
});
|
||||
|
||||
// Clear the cookie
|
||||
cookieStore.delete('sessionToken');
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'An error occurred during logout' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { verifyPassword, createSession } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { email, password } = loginSchema.parse(body);
|
||||
|
||||
// Look up user by email
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email or password' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the password
|
||||
const isValid = await verifyPassword(password, user.passwordHash);
|
||||
|
||||
if (!isValid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid email or password' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create a session
|
||||
const session = await createSession(user.id);
|
||||
|
||||
// Set the session cookie
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
},
|
||||
});
|
||||
|
||||
response.cookies.set('sessionToken', session.token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
path: '/',
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.error('Login error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'An error occurred during login' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* GET /api/exercises/[id]
|
||||
* Get exercise with history
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const exercise = await prisma.exercise.findFirst({
|
||||
where: {
|
||||
id: params.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!exercise) {
|
||||
return NextResponse.json({ error: "Exercise not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get exercise history grouped by workout
|
||||
const setLogs = await prisma.setLog.findMany({
|
||||
where: {
|
||||
exerciseId: params.id,
|
||||
workout: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
workout: {
|
||||
select: {
|
||||
id: true,
|
||||
date: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ workout: { date: "desc" } },
|
||||
{ setNumber: "asc" },
|
||||
],
|
||||
take: 100,
|
||||
});
|
||||
|
||||
// Group by workout
|
||||
const workoutMap = new Map<string, { workout: any; sets: any[] }>();
|
||||
for (const log of setLogs) {
|
||||
const key = log.workoutId;
|
||||
if (!workoutMap.has(key)) {
|
||||
workoutMap.set(key, { workout: log.workout, sets: [] });
|
||||
}
|
||||
workoutMap.get(key)!.sets.push(log);
|
||||
}
|
||||
|
||||
const history = Array.from(workoutMap.values()).slice(0, 20);
|
||||
|
||||
return NextResponse.json({
|
||||
exercise,
|
||||
history,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("GET /api/exercises/[id] error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/exercises/[id]
|
||||
* Edit exercise details
|
||||
*/
|
||||
const updateExerciseSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
type: z.string().min(1).optional(),
|
||||
muscleGroups: z.array(z.string()).optional(),
|
||||
description: z.string().optional(),
|
||||
inputFields: z.array(z.string().min(1)).optional(),
|
||||
defaultWeightUnit: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const exercise = await prisma.exercise.findFirst({
|
||||
where: {
|
||||
id: params.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!exercise) {
|
||||
return NextResponse.json({ error: "Exercise not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const validated = updateExerciseSchema.parse(body);
|
||||
|
||||
const data: any = {};
|
||||
if (validated.name !== undefined) data.name = validated.name;
|
||||
if (validated.type !== undefined) data.type = validated.type;
|
||||
if (validated.description !== undefined) data.description = validated.description;
|
||||
if (validated.muscleGroups !== undefined)
|
||||
data.muscleGroups = JSON.stringify(validated.muscleGroups);
|
||||
if (validated.inputFields !== undefined)
|
||||
data.inputFields = JSON.stringify(validated.inputFields);
|
||||
if (validated.defaultWeightUnit !== undefined)
|
||||
data.defaultWeightUnit = validated.defaultWeightUnit;
|
||||
|
||||
const updated = await prisma.exercise.update({
|
||||
where: { id: params.id },
|
||||
data,
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid data", details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
console.error("PATCH /api/exercises/[id] error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/exercises/[id]
|
||||
*/
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const exercise = await prisma.exercise.findFirst({
|
||||
where: {
|
||||
id: params.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!exercise) {
|
||||
return NextResponse.json({ error: "Exercise not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.setLog.deleteMany({
|
||||
where: { exerciseId: params.id },
|
||||
});
|
||||
|
||||
await prisma.exercise.delete({
|
||||
where: { id: params.id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: "Exercise deleted successfully" });
|
||||
} catch (error) {
|
||||
console.error("DELETE /api/exercises/[id] error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { getExercises, createExercise } from "@/lib/db/exercises";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
const CreateExerciseSchema = z.object({
|
||||
name: z.string().min(1, "Exercise name is required"),
|
||||
type: z.string().min(1),
|
||||
muscleGroups: z.array(z.string()).default([]),
|
||||
description: z.string().optional(),
|
||||
inputFields: z.array(z.string().min(1)).optional(),
|
||||
defaultWeightUnit: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/exercises
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get("q");
|
||||
|
||||
let exercises;
|
||||
|
||||
if (query) {
|
||||
exercises = await prisma.exercise.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
name: { contains: query },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
} else {
|
||||
exercises = await getExercises(user.id);
|
||||
}
|
||||
|
||||
return NextResponse.json(exercises);
|
||||
} catch (error) {
|
||||
console.error("GET /api/exercises error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/exercises
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const validated = CreateExerciseSchema.parse(body);
|
||||
|
||||
const existing = await prisma.exercise.findUnique({
|
||||
where: {
|
||||
userId_name: {
|
||||
userId: user.id,
|
||||
name: validated.name,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: "Exercise already exists" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Determine default inputFields based on type
|
||||
let inputFields = validated.inputFields;
|
||||
if (!inputFields) {
|
||||
if (validated.type === "cardio") {
|
||||
inputFields = ["sets", "duration", "calories"];
|
||||
} else {
|
||||
inputFields = ["sets", "reps", "weight"];
|
||||
}
|
||||
}
|
||||
|
||||
// Kettlebell defaults to kg
|
||||
let defaultWeightUnit = validated.defaultWeightUnit;
|
||||
if (defaultWeightUnit === undefined && validated.type === "kettlebell") {
|
||||
defaultWeightUnit = "kg";
|
||||
}
|
||||
|
||||
const exercise = await createExercise({
|
||||
userId: user.id,
|
||||
name: validated.name,
|
||||
type: validated.type,
|
||||
description: validated.description,
|
||||
muscleGroups: JSON.stringify(validated.muscleGroups),
|
||||
inputFields: JSON.stringify(inputFields),
|
||||
defaultWeightUnit: defaultWeightUnit || null,
|
||||
isCustom: true,
|
||||
});
|
||||
|
||||
return NextResponse.json(exercise, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validation error", details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.error("POST /api/exercises error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
/**
|
||||
* GET /api/health
|
||||
* Health check endpoint — verifies both the server and database are operational.
|
||||
* Used by StartOS health checks and Docker health checks.
|
||||
* Excluded from auth middleware in middleware.ts.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
// Verify database connectivity with a lightweight query
|
||||
const userCount = await prisma.user.count();
|
||||
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
timestamp: Date.now(),
|
||||
database: "connected",
|
||||
users: userCount,
|
||||
});
|
||||
} catch (error) {
|
||||
// Server is up but database is unreachable or corrupted
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
timestamp: Date.now(),
|
||||
database: "disconnected",
|
||||
error: error instanceof Error ? error.message : "Unknown database error",
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// Exercise name mapping - CSV shorthand to DB names
|
||||
const NAME_MAP: Record<string, string> = {
|
||||
"Ab Wheel": "Ab Wheel Rollout",
|
||||
"BB Upright Row": "Upright Row",
|
||||
"Ball Situp": "Exercise Ball Situp",
|
||||
"Bench": "Bench Press",
|
||||
"DB Lateral Raise": "Lateral Raise",
|
||||
"Dip": "Dips (Chest)",
|
||||
"Face Pull": "Face Pulls",
|
||||
"SA Lat Pulldown": "Lat Pulldown",
|
||||
"SL Calf Raise": "Calf Raise",
|
||||
"BB Row": "Barbell Row",
|
||||
"DB Row": "Dumbbell Row",
|
||||
"GHD": "Glute ham developer",
|
||||
"Hamstring DL": "Hamstring deadlift",
|
||||
"BB Curl": "Barbell Curl",
|
||||
"BB Hip Bridge": "Hip Thrust",
|
||||
"Cable Trap": "Rear delt",
|
||||
"Chinup (Narrow)": "Chinup",
|
||||
"Chinup Negatives": "Chinup",
|
||||
"Squat (Foot Elevated)": "Squat",
|
||||
"Ball Bicep Curl": "Dumbbell Curl",
|
||||
"KB Hip Flexor": "Hip Flexor",
|
||||
"Hamstring Deadlift": "Hamstring deadlift",
|
||||
"Shoulder Press": "Overhead Press",
|
||||
"CoC": "Captains of Crush",
|
||||
"Hex DL": "Hex Bar Deadlift",
|
||||
"KB Extension": "Kettlebell Leg Extension",
|
||||
"Ski": "SkiErg",
|
||||
};
|
||||
|
||||
interface ParsedSet {
|
||||
setNumber: number;
|
||||
weight?: number;
|
||||
weightUnit: string;
|
||||
reps?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
interface ParsedExercise {
|
||||
exerciseId: string;
|
||||
exerciseName: string;
|
||||
sets: ParsedSet[];
|
||||
}
|
||||
|
||||
interface ParsedWorkout {
|
||||
date: string;
|
||||
exercises: ParsedExercise[];
|
||||
}
|
||||
|
||||
interface ParseResponse {
|
||||
workouts: ParsedWorkout[];
|
||||
unmapped: string[];
|
||||
}
|
||||
|
||||
function parseCSV(content: string): Array<Record<string, string>> {
|
||||
const lines = content.trim().split("\n");
|
||||
if (lines.length === 0) return [];
|
||||
|
||||
// Parse header
|
||||
const header = lines[0].split(",").map((h) => h.trim().toLowerCase());
|
||||
const rows = [];
|
||||
|
||||
// Parse data rows
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
|
||||
const values = line.split(",").map((v) => v.trim());
|
||||
const row: Record<string, string> = {};
|
||||
|
||||
header.forEach((col, idx) => {
|
||||
if (values[idx]) {
|
||||
row[col] = values[idx];
|
||||
}
|
||||
});
|
||||
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function getVariationNote(originalName: string): string | null {
|
||||
if (originalName.includes("Narrow")) return "narrow";
|
||||
if (originalName.includes("Negatives")) return "negatives";
|
||||
if (originalName.includes("Foot Elevated")) return "foot elevated";
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveExerciseName(csvName: string): string {
|
||||
// Check if it's in the name map
|
||||
if (NAME_MAP[csvName]) {
|
||||
return NAME_MAP[csvName];
|
||||
}
|
||||
// Return as-is for direct lookup
|
||||
return csvName;
|
||||
}
|
||||
|
||||
// Parse dates like "1/27/2026" or "2026-01-27" into ISO date string
|
||||
function parseDate(dateStr: string): string {
|
||||
// Try M/D/YYYY format
|
||||
const mdyMatch = dateStr.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
||||
if (mdyMatch) {
|
||||
const month = mdyMatch[1].padStart(2, "0");
|
||||
const day = mdyMatch[2].padStart(2, "0");
|
||||
const year = mdyMatch[3];
|
||||
return `${year}-${month}-${day}T12:00:00.000Z`;
|
||||
}
|
||||
// Try ISO format
|
||||
if (dateStr.includes("-")) {
|
||||
return new Date(dateStr + "T12:00:00.000Z").toISOString();
|
||||
}
|
||||
// Fallback
|
||||
return new Date(dateStr).toISOString();
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file") as File;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!file.name.endsWith(".csv")) {
|
||||
return NextResponse.json(
|
||||
{ error: "File must be a CSV file" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const content = await file.text();
|
||||
const rows = parseCSV(content);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "CSV is empty or invalid format" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get all user exercises for matching
|
||||
const exercises = await prisma.exercise.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
// Build case-insensitive lookup map
|
||||
const exerciseMap = new Map<string, string>();
|
||||
for (const ex of exercises) {
|
||||
exerciseMap.set(ex.name.toLowerCase(), ex.id);
|
||||
}
|
||||
|
||||
// Group rows by date
|
||||
const workoutsByDate = new Map<string, Array<Record<string, string>>>();
|
||||
|
||||
const unmappedExercises = new Set<string>();
|
||||
|
||||
for (const row of rows) {
|
||||
const date = row.date || row.date_str || row.workout_date || "";
|
||||
const exerciseName = row.exercise || row.exercise_name || "";
|
||||
|
||||
if (!date || !exerciseName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!workoutsByDate.has(date)) {
|
||||
workoutsByDate.set(date, []);
|
||||
}
|
||||
|
||||
workoutsByDate.get(date)!.push(row);
|
||||
|
||||
// Check if exercise can be resolved
|
||||
const resolvedName = resolveExerciseName(exerciseName);
|
||||
const isKnown = exerciseMap.has(resolvedName.toLowerCase());
|
||||
if (!isKnown) {
|
||||
unmappedExercises.add(exerciseName);
|
||||
}
|
||||
}
|
||||
|
||||
// Build parsed workouts
|
||||
const parsedWorkouts: ParsedWorkout[] = [];
|
||||
|
||||
for (const [date, rowsForDate] of workoutsByDate) {
|
||||
const exercisesMap = new Map<
|
||||
string,
|
||||
{
|
||||
exerciseId: string;
|
||||
exerciseName: string;
|
||||
sets: ParsedSet[];
|
||||
}
|
||||
>();
|
||||
|
||||
for (const row of rowsForDate) {
|
||||
const csvExerciseName = row.exercise || row.exercise_name || "";
|
||||
const resolvedName = resolveExerciseName(csvExerciseName);
|
||||
const exerciseId =
|
||||
exerciseMap.get(resolvedName.toLowerCase()) || "";
|
||||
|
||||
if (!exerciseId) {
|
||||
unmappedExercises.add(csvExerciseName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!exercisesMap.has(exerciseId)) {
|
||||
exercisesMap.set(exerciseId, {
|
||||
exerciseId,
|
||||
exerciseName: resolvedName,
|
||||
sets: [],
|
||||
});
|
||||
}
|
||||
|
||||
const exerciseData = exercisesMap.get(exerciseId)!;
|
||||
const weight = row.weight ? parseFloat(row.weight) : undefined;
|
||||
const reps = row.reps ? parseInt(row.reps, 10) : undefined;
|
||||
let notes = row.notes || "";
|
||||
|
||||
// Detect weight unit from notes
|
||||
let weightUnit = "lbs";
|
||||
if (notes.toLowerCase().includes("kg")) {
|
||||
weightUnit = "kg";
|
||||
}
|
||||
|
||||
// Add variation note if applicable
|
||||
const variationNote = getVariationNote(csvExerciseName);
|
||||
if (variationNote) {
|
||||
notes = notes
|
||||
? `${notes} (${variationNote})`
|
||||
: `(${variationNote})`;
|
||||
}
|
||||
|
||||
const setNumber = exerciseData.sets.length + 1;
|
||||
|
||||
exerciseData.sets.push({
|
||||
setNumber,
|
||||
weight,
|
||||
weightUnit,
|
||||
reps,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const workoutExercises = Array.from(exercisesMap.values());
|
||||
if (workoutExercises.length > 0) {
|
||||
parsedWorkouts.push({
|
||||
date: parseDate(date),
|
||||
exercises: workoutExercises,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by date ascending (oldest first)
|
||||
parsedWorkouts.sort(
|
||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||
);
|
||||
|
||||
const response: ParseResponse = {
|
||||
workouts: parsedWorkouts,
|
||||
unmapped: Array.from(unmappedExercises),
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error("CSV parsing error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to parse CSV file" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
const PreferencesSchema = z.object({
|
||||
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||
defaultWeightUnit: z.enum(["lbs", "kg"]).optional(),
|
||||
enableClaudeAI: z.boolean().optional(),
|
||||
claudeApiKey: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/preferences
|
||||
* Get user preferences
|
||||
*/
|
||||
export async function GET(_request: NextRequest) {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let preferences = await prisma.userPreferences.findUnique({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
if (!preferences) {
|
||||
// Create default preferences
|
||||
preferences = await prisma.userPreferences.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
theme: "system",
|
||||
defaultWeightUnit: "lbs",
|
||||
defaultRestSeconds: 90,
|
||||
enableClaudeAI: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Don't return API key in response
|
||||
const { claudeApiKey, ...safePreferences } = preferences;
|
||||
return NextResponse.json({
|
||||
...safePreferences,
|
||||
claudeApiKey: claudeApiKey ? "***" : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("GET /api/preferences error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/preferences
|
||||
* Update user preferences
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const validated = PreferencesSchema.parse(body);
|
||||
|
||||
// Get or create preferences
|
||||
let preferences = await prisma.userPreferences.findUnique({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
if (!preferences) {
|
||||
preferences = await prisma.userPreferences.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
...validated,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
preferences = await prisma.userPreferences.update({
|
||||
where: { userId: user.id },
|
||||
data: validated,
|
||||
});
|
||||
}
|
||||
|
||||
// Don't return API key in response
|
||||
const { claudeApiKey, ...safePreferences } = preferences;
|
||||
return NextResponse.json({
|
||||
...safePreferences,
|
||||
claudeApiKey: claudeApiKey ? "***" : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Validation error",
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.error("POST /api/preferences error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { writeFile, copyFile, unlink } from "fs/promises";
|
||||
import { existsSync } from "fs";
|
||||
import path from "path";
|
||||
import { execSync } from "child_process";
|
||||
|
||||
/**
|
||||
* POST /api/settings/import-db
|
||||
* Upload a SQLite database file to replace the current one.
|
||||
* Creates a backup of the existing DB before replacing.
|
||||
* Validates the uploaded file is a valid SQLite database with the expected tables.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("database") as File | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ error: "No database file provided" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Basic size check (SQLite DBs for this app should be under 100MB)
|
||||
if (file.size > 100 * 1024 * 1024) {
|
||||
return NextResponse.json(
|
||||
{ error: "File too large (max 100MB)" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Read the uploaded file into a buffer
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
// Check SQLite magic bytes (first 16 bytes should start with "SQLite format 3\0")
|
||||
const magic = buffer.slice(0, 16).toString("ascii");
|
||||
if (!magic.startsWith("SQLite format 3")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid file — not a SQLite database" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Determine the database file path from DATABASE_URL
|
||||
const dbUrl = process.env.DATABASE_URL || "file:./data/app.db";
|
||||
let dbPath: string;
|
||||
if (dbUrl.startsWith("file:")) {
|
||||
dbPath = dbUrl.replace("file:", "");
|
||||
// Handle relative paths
|
||||
if (!path.isAbsolute(dbPath)) {
|
||||
dbPath = path.resolve(process.cwd(), "prisma", dbPath.replace("./", ""));
|
||||
}
|
||||
} else {
|
||||
dbPath = path.resolve(process.cwd(), "prisma", "data", "app.db");
|
||||
}
|
||||
|
||||
// Write uploaded file to a temp location for validation
|
||||
const tempPath = dbPath + ".upload-temp";
|
||||
await writeFile(tempPath, buffer);
|
||||
|
||||
// Validate the uploaded DB has the expected tables
|
||||
try {
|
||||
const tables = execSync(
|
||||
`sqlite3 "${tempPath}" "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;"`,
|
||||
{ encoding: "utf-8", timeout: 10000 }
|
||||
).trim();
|
||||
|
||||
const tableList = tables.split("\n").map((t) => t.trim());
|
||||
const requiredTables = ["User", "Exercise", "Workout", "SetLog"];
|
||||
const missingTables = requiredTables.filter(
|
||||
(t) => !tableList.includes(t)
|
||||
);
|
||||
|
||||
if (missingTables.length > 0) {
|
||||
await unlink(tempPath);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Invalid database — missing tables: ${missingTables.join(", ")}. This doesn't look like a Workout Planner database.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Run integrity check
|
||||
const integrity = execSync(
|
||||
`sqlite3 "${tempPath}" "PRAGMA integrity_check;"`,
|
||||
{ encoding: "utf-8", timeout: 10000 }
|
||||
).trim();
|
||||
|
||||
if (integrity !== "ok") {
|
||||
await unlink(tempPath);
|
||||
return NextResponse.json(
|
||||
{ error: "Database integrity check failed — file may be corrupted" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Clean up temp file
|
||||
if (existsSync(tempPath)) await unlink(tempPath);
|
||||
return NextResponse.json(
|
||||
{ error: "Could not validate the uploaded database file" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get some stats from the uploaded DB for the response
|
||||
let stats = { users: 0, exercises: 0, workouts: 0 };
|
||||
try {
|
||||
const userCount = execSync(
|
||||
`sqlite3 "${tempPath}" "SELECT COUNT(*) FROM User;"`,
|
||||
{ encoding: "utf-8" }
|
||||
).trim();
|
||||
const exerciseCount = execSync(
|
||||
`sqlite3 "${tempPath}" "SELECT COUNT(*) FROM Exercise;"`,
|
||||
{ encoding: "utf-8" }
|
||||
).trim();
|
||||
const workoutCount = execSync(
|
||||
`sqlite3 "${tempPath}" "SELECT COUNT(*) FROM Workout;"`,
|
||||
{ encoding: "utf-8" }
|
||||
).trim();
|
||||
stats = {
|
||||
users: parseInt(userCount) || 0,
|
||||
exercises: parseInt(exerciseCount) || 0,
|
||||
workouts: parseInt(workoutCount) || 0,
|
||||
};
|
||||
} catch {
|
||||
// Stats are optional, continue anyway
|
||||
}
|
||||
|
||||
// Back up the current database
|
||||
const backupPath = dbPath + ".backup-" + Date.now();
|
||||
if (existsSync(dbPath)) {
|
||||
await copyFile(dbPath, backupPath);
|
||||
}
|
||||
|
||||
// Replace the current database with the uploaded one
|
||||
await copyFile(tempPath, dbPath);
|
||||
|
||||
// Also remove WAL/SHM files if they exist (SQLite journal files)
|
||||
for (const ext of ["-wal", "-shm", "-journal"]) {
|
||||
const journalPath = dbPath + ext;
|
||||
if (existsSync(journalPath)) {
|
||||
await unlink(journalPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up temp file
|
||||
await unlink(tempPath);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Database imported successfully. Please refresh the page.",
|
||||
stats,
|
||||
backup: path.basename(backupPath),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Database import error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "An error occurred during import",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { prisma, getCaloriesBurned, setCaloriesBurned } from "@/lib/prisma";
|
||||
import { z } from "zod";
|
||||
|
||||
// GET: Get workout by ID
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const workout = await prisma.workout.findUnique({
|
||||
where: { id: params.id },
|
||||
include: {
|
||||
setLogs: {
|
||||
include: {
|
||||
exercise: true,
|
||||
},
|
||||
orderBy: {
|
||||
setNumber: "asc",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!workout) {
|
||||
return NextResponse.json({ error: "Workout not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (workout.userId !== user.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Prisma client doesn't know about caloriesBurned — fetch via raw SQL
|
||||
const caloriesBurned = await getCaloriesBurned(workout.id);
|
||||
|
||||
return NextResponse.json({ ...workout, caloriesBurned });
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch workout:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch workout" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH: Update workout — supports metadata-only or full update with sets
|
||||
const setSchema = z.object({
|
||||
exerciseId: z.string().min(1),
|
||||
setNumber: z.number().int().positive(),
|
||||
reps: z.number().int().positive().optional().nullable(),
|
||||
weight: z.number().optional().nullable(),
|
||||
weightUnit: z.string().default("lbs"),
|
||||
rpe: z.number().int().min(1).max(10).optional().nullable(),
|
||||
notes: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
const updateWorkoutSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
notes: z.string().optional().nullable(),
|
||||
date: z.string().optional(), // ISO date string
|
||||
durationMinutes: z.number().int().positive().optional().nullable(),
|
||||
difficulty: z.number().int().min(1).max(10).optional().nullable(),
|
||||
caloriesBurned: z.number().int().positive().optional().nullable(),
|
||||
sets: z.array(setSchema).optional(), // if provided, replaces all sets
|
||||
});
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const workout = await prisma.workout.findUnique({
|
||||
where: { id: params.id },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
if (!workout) {
|
||||
return NextResponse.json({ error: "Workout not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (workout.userId !== user.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const validated = updateWorkoutSchema.parse(body);
|
||||
|
||||
// Extract caloriesBurned separately — handled via raw SQL
|
||||
const caloriesValue = validated.caloriesBurned;
|
||||
const hasCaloriesUpdate = validated.caloriesBurned !== undefined;
|
||||
|
||||
// Build the Prisma-compatible workout update data (no caloriesBurned)
|
||||
const workoutData: Record<string, unknown> = {};
|
||||
if (validated.name !== undefined) workoutData.name = validated.name;
|
||||
if (validated.notes !== undefined) workoutData.notes = validated.notes || null;
|
||||
if (validated.date !== undefined) workoutData.date = new Date(validated.date);
|
||||
if (validated.durationMinutes !== undefined)
|
||||
workoutData.durationMinutes = validated.durationMinutes;
|
||||
if (validated.difficulty !== undefined)
|
||||
workoutData.difficulty = validated.difficulty;
|
||||
|
||||
// If sets are provided, do a full replace inside a transaction
|
||||
if (validated.sets) {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// Update workout metadata (without caloriesBurned)
|
||||
if (Object.keys(workoutData).length > 0) {
|
||||
await tx.workout.update({ where: { id: params.id }, data: workoutData });
|
||||
}
|
||||
|
||||
// Delete all existing sets
|
||||
await tx.setLog.deleteMany({
|
||||
where: { workoutId: params.id },
|
||||
});
|
||||
|
||||
// Create new sets
|
||||
if (validated.sets!.length > 0) {
|
||||
await tx.setLog.createMany({
|
||||
data: validated.sets!.map((set) => ({
|
||||
workoutId: params.id,
|
||||
exerciseId: set.exerciseId,
|
||||
setNumber: set.setNumber,
|
||||
reps: set.reps ?? undefined,
|
||||
weight: set.weight ?? undefined,
|
||||
weightUnit: set.weightUnit,
|
||||
rpe: set.rpe ?? undefined,
|
||||
notes: set.notes ?? undefined,
|
||||
} as any)),
|
||||
});
|
||||
}
|
||||
|
||||
// Return full updated workout
|
||||
return tx.workout.findUnique({
|
||||
where: { id: params.id },
|
||||
include: {
|
||||
setLogs: {
|
||||
include: { exercise: true },
|
||||
orderBy: { setNumber: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Update caloriesBurned via raw SQL (outside transaction since Prisma doesn't know this column)
|
||||
if (hasCaloriesUpdate) {
|
||||
await setCaloriesBurned(params.id, caloriesValue ?? null);
|
||||
}
|
||||
const calories = await getCaloriesBurned(params.id);
|
||||
|
||||
return NextResponse.json({ ...result, caloriesBurned: calories });
|
||||
}
|
||||
|
||||
// Metadata-only update
|
||||
if (Object.keys(workoutData).length > 0) {
|
||||
await prisma.workout.update({
|
||||
where: { id: params.id },
|
||||
data: workoutData,
|
||||
});
|
||||
}
|
||||
|
||||
// Update caloriesBurned via raw SQL
|
||||
if (hasCaloriesUpdate) {
|
||||
await setCaloriesBurned(params.id, caloriesValue ?? null);
|
||||
}
|
||||
|
||||
const updated = await prisma.workout.findUnique({
|
||||
where: { id: params.id },
|
||||
include: {
|
||||
setLogs: {
|
||||
include: { exercise: true },
|
||||
orderBy: { setNumber: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const calories = await getCaloriesBurned(params.id);
|
||||
|
||||
return NextResponse.json({ ...updated, caloriesBurned: calories });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid data", details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
console.error("Failed to update workout:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update workout" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: Delete workout
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const workout = await prisma.workout.findUnique({
|
||||
where: { id: params.id },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
if (!workout) {
|
||||
return NextResponse.json({ error: "Workout not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (workout.userId !== user.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
await prisma.workout.delete({
|
||||
where: { id: params.id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Failed to delete workout:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete workout" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { z } from "zod";
|
||||
|
||||
const addSetsSchema = z.object({
|
||||
exerciseId: z.string().min(1),
|
||||
sets: z.array(
|
||||
z.object({
|
||||
setNumber: z.number().int().positive(),
|
||||
reps: z.number().int().positive().optional(),
|
||||
weight: z.number().optional(),
|
||||
weightUnit: z.string().default("lbs"),
|
||||
rpe: z.number().int().min(1).max(10).optional(),
|
||||
durationSeconds: z.number().int().positive().optional(),
|
||||
distance: z.number().positive().optional(),
|
||||
distanceUnit: z.string().optional(),
|
||||
calories: z.number().int().positive().optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
// POST: Add an exercise's sets to an existing workout
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const workout = await prisma.workout.findUnique({
|
||||
where: { id: params.id },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
if (!workout) {
|
||||
return NextResponse.json({ error: "Workout not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (workout.userId !== user.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const validated = addSetsSchema.parse(body);
|
||||
|
||||
// Delete existing sets for this exercise in this workout (replace mode)
|
||||
await prisma.setLog.deleteMany({
|
||||
where: {
|
||||
workoutId: params.id,
|
||||
exerciseId: validated.exerciseId,
|
||||
},
|
||||
});
|
||||
|
||||
// Create new sets
|
||||
await prisma.setLog.createMany({
|
||||
data: validated.sets.map((set) => ({
|
||||
workoutId: params.id,
|
||||
exerciseId: validated.exerciseId,
|
||||
setNumber: set.setNumber,
|
||||
reps: set.reps,
|
||||
weight: set.weight,
|
||||
weightUnit: set.weightUnit,
|
||||
rpe: set.rpe,
|
||||
durationSeconds: set.durationSeconds,
|
||||
distance: set.distance,
|
||||
distanceUnit: set.distanceUnit,
|
||||
calories: set.calories,
|
||||
notes: set.notes,
|
||||
} as any)),
|
||||
});
|
||||
|
||||
// Return updated workout
|
||||
const updated = await prisma.workout.findUnique({
|
||||
where: { id: params.id },
|
||||
include: {
|
||||
setLogs: {
|
||||
include: { exercise: true },
|
||||
orderBy: { setNumber: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updated, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid data", details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
console.error("Failed to add sets:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to add sets" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: Remove all sets for a specific exercise from a workout
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const workout = await prisma.workout.findUnique({
|
||||
where: { id: params.id },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
if (!workout) {
|
||||
return NextResponse.json({ error: "Workout not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (workout.userId !== user.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const exerciseId = searchParams.get("exerciseId");
|
||||
|
||||
if (!exerciseId) {
|
||||
return NextResponse.json(
|
||||
{ error: "exerciseId query param required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.setLog.deleteMany({
|
||||
where: {
|
||||
workoutId: params.id,
|
||||
exerciseId,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Failed to delete sets:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete sets" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const importSchema = z.object({
|
||||
images: z.array(z.string()).min(1, "At least one image is required"),
|
||||
});
|
||||
|
||||
const CLAUDE_API_URL = "https://api.anthropic.com/v1/messages";
|
||||
|
||||
const SYSTEM_PROMPT = `You are analyzing photos of handwritten workout logs, Apple Notes, or other workout records. Extract all workout data you can find.
|
||||
|
||||
IMPORTANT RULES:
|
||||
- If you can identify a date for the workout, include it as an ISO date string (YYYY-MM-DD)
|
||||
- If no date is visible, set date to null
|
||||
- Extract exercise names as closely as written
|
||||
- For each exercise, extract all sets with whatever data is visible (reps, weight, duration, etc.)
|
||||
- If you're unsure about an exercise name or value, set "uncertain": true and explain in "uncertainReason"
|
||||
- Weight units: assume lbs unless kg or kilograms is explicitly written
|
||||
- For cardio exercises (running, biking, rowing, assault bike, jump rope, etc.), look for duration, distance, and calories
|
||||
- Be conservative — only include data you can actually read
|
||||
|
||||
Return ONLY valid JSON with this exact structure (no markdown, no code fences):
|
||||
{
|
||||
"workouts": [
|
||||
{
|
||||
"date": "2025-01-15" or null,
|
||||
"name": "Upper Body" or null,
|
||||
"notes": "any overall notes" or null,
|
||||
"exercises": [
|
||||
{
|
||||
"name": "Bench Press",
|
||||
"type": "barbell" | "dumbbell" | "machine" | "cable" | "bodyweight" | "cardio" | "kettlebell" | "other",
|
||||
"sets": [
|
||||
{
|
||||
"reps": 8,
|
||||
"weight": 225,
|
||||
"weightUnit": "lbs",
|
||||
"durationSeconds": null,
|
||||
"distance": null,
|
||||
"distanceUnit": null,
|
||||
"calories": null,
|
||||
"rpe": null,
|
||||
"notes": null
|
||||
}
|
||||
],
|
||||
"notes": null,
|
||||
"uncertain": false,
|
||||
"uncertainReason": null
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"confidence": "high" | "medium" | "low",
|
||||
"warnings": ["list any legibility issues or assumptions made"]
|
||||
}`;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get user's Claude API key from preferences
|
||||
const preferences = await prisma.userPreferences.findUnique({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
if (!preferences?.enableClaudeAI || !preferences?.claudeApiKey) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Claude AI is not configured. Please add your API key in Settings.",
|
||||
code: "NO_API_KEY",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const validated = importSchema.parse(body);
|
||||
|
||||
// Build Claude API request with vision
|
||||
const content: any[] = [
|
||||
{
|
||||
type: "text",
|
||||
text: "Please analyze the following workout log image(s) and extract all workout data. Return ONLY valid JSON.",
|
||||
},
|
||||
];
|
||||
|
||||
// Add each image
|
||||
for (const imageData of validated.images) {
|
||||
// imageData could be a data URL or raw base64
|
||||
let base64 = imageData;
|
||||
let mediaType = "image/jpeg";
|
||||
|
||||
if (imageData.startsWith("data:")) {
|
||||
const match = imageData.match(/^data:(image\/\w+);base64,(.+)$/);
|
||||
if (match) {
|
||||
mediaType = match[1];
|
||||
base64 = match[2];
|
||||
}
|
||||
}
|
||||
|
||||
content.push({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: mediaType,
|
||||
data: base64,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Call Claude API
|
||||
const claudeResponse = await fetch(CLAUDE_API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": preferences.claudeApiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "claude-sonnet-4-20250514",
|
||||
max_tokens: 4096,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!claudeResponse.ok) {
|
||||
const errorBody = await claudeResponse.text();
|
||||
console.error("Claude API error:", claudeResponse.status, errorBody);
|
||||
|
||||
if (claudeResponse.status === 401) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid Claude API key. Please check your key in Settings.", code: "INVALID_KEY" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (claudeResponse.status === 429) {
|
||||
return NextResponse.json(
|
||||
{ error: "Claude API rate limit reached. Please try again in a moment.", code: "RATE_LIMITED" },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to analyze images. Please try again.", code: "API_ERROR" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
const claudeData = await claudeResponse.json();
|
||||
|
||||
// Extract text content from Claude's response
|
||||
const textContent = claudeData.content?.find((c: any) => c.type === "text");
|
||||
if (!textContent?.text) {
|
||||
return NextResponse.json(
|
||||
{ error: "No response from Claude. Please try again.", code: "EMPTY_RESPONSE" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse the JSON response
|
||||
let parsed;
|
||||
try {
|
||||
// Try to extract JSON from the response (Claude might wrap it in code fences)
|
||||
let jsonText = textContent.text.trim();
|
||||
const jsonMatch = jsonText.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||
if (jsonMatch) {
|
||||
jsonText = jsonMatch[1].trim();
|
||||
}
|
||||
parsed = JSON.parse(jsonText);
|
||||
} catch {
|
||||
console.error("Failed to parse Claude response:", textContent.text);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Could not parse the workout data. The image may be too unclear.",
|
||||
code: "PARSE_ERROR",
|
||||
raw: textContent.text.substring(0, 500),
|
||||
},
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate basic structure
|
||||
if (!parsed.workouts || !Array.isArray(parsed.workouts)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid response structure from Claude.", code: "INVALID_STRUCTURE" },
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(parsed);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request data", details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.error("POST /api/workouts/import error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const setSchema = z.object({
|
||||
reps: z.number().int().positive().optional(),
|
||||
weight: z.number().positive().optional(),
|
||||
weightUnit: z.string().optional(),
|
||||
durationSeconds: z.number().int().positive().optional(),
|
||||
distance: z.number().positive().optional(),
|
||||
distanceUnit: z.string().optional(),
|
||||
calories: z.number().int().positive().optional(),
|
||||
rpe: z.number().int().min(1).max(10).optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
const exerciseSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
type: z.string().optional(),
|
||||
existingExerciseId: z.string().optional(),
|
||||
sets: z.array(setSchema),
|
||||
});
|
||||
|
||||
const workoutSchema = z.object({
|
||||
date: z.string(),
|
||||
name: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
exercises: z.array(exerciseSchema),
|
||||
});
|
||||
|
||||
const saveImportSchema = z.object({
|
||||
workouts: z.array(workoutSchema).min(1),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const validated = saveImportSchema.parse(body);
|
||||
|
||||
// Load all user exercises for matching
|
||||
const existingExercises = await prisma.exercise.findMany({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
// Build a case-insensitive lookup map
|
||||
const exerciseMap = new Map<string, typeof existingExercises[0]>();
|
||||
for (const ex of existingExercises) {
|
||||
exerciseMap.set(ex.name.toLowerCase(), ex);
|
||||
}
|
||||
|
||||
const createdWorkoutIds: string[] = [];
|
||||
|
||||
for (const workoutData of validated.workouts) {
|
||||
// Resolve exercise IDs (match existing or create new)
|
||||
const resolvedExercises: Array<{
|
||||
exerciseId: string;
|
||||
sets: z.infer<typeof setSchema>[];
|
||||
}> = [];
|
||||
|
||||
for (const ex of workoutData.exercises) {
|
||||
let exerciseId: string;
|
||||
|
||||
if (ex.existingExerciseId) {
|
||||
// User explicitly matched this to an existing exercise
|
||||
exerciseId = ex.existingExerciseId;
|
||||
} else {
|
||||
// Try case-insensitive match
|
||||
const matched = exerciseMap.get(ex.name.toLowerCase());
|
||||
if (matched) {
|
||||
exerciseId = matched.id;
|
||||
} else {
|
||||
// Create new exercise
|
||||
const newExercise = await prisma.exercise.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: ex.name,
|
||||
type: ex.type || "other",
|
||||
muscleGroups: JSON.stringify([]),
|
||||
isCustom: true,
|
||||
} as any,
|
||||
});
|
||||
exerciseId = newExercise.id;
|
||||
// Add to map so subsequent references can find it
|
||||
exerciseMap.set(ex.name.toLowerCase(), newExercise);
|
||||
}
|
||||
}
|
||||
|
||||
resolvedExercises.push({ exerciseId, sets: ex.sets });
|
||||
}
|
||||
|
||||
// Create the workout with all sets
|
||||
const setLogsData: any[] = [];
|
||||
for (const resolved of resolvedExercises) {
|
||||
resolved.sets.forEach((set, index) => {
|
||||
setLogsData.push({
|
||||
exerciseId: resolved.exerciseId,
|
||||
setNumber: index + 1,
|
||||
reps: set.reps || null,
|
||||
weight: set.weight || null,
|
||||
weightUnit: set.weightUnit || "lbs",
|
||||
rpe: set.rpe || null,
|
||||
durationSeconds: set.durationSeconds || null,
|
||||
distance: set.distance || null,
|
||||
distanceUnit: set.distanceUnit || null,
|
||||
calories: set.calories || null,
|
||||
notes: set.notes || null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const workout = await prisma.workout.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
date: new Date(workoutData.date),
|
||||
name: workoutData.name || null,
|
||||
notes: workoutData.notes || null,
|
||||
setLogs: {
|
||||
create: setLogsData,
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
createdWorkoutIds.push(workout.id);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
created: createdWorkoutIds,
|
||||
count: createdWorkoutIds.length,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request data", details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.error("POST /api/workouts/import/save error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { prisma, setCaloriesBurned, getCaloriesBurnedBulk } from "@/lib/prisma";
|
||||
|
||||
// Schema now supports creating empty workouts (just date) or with sets
|
||||
const createWorkoutSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
durationMinutes: z.number().int().positive().optional(),
|
||||
difficulty: z.number().int().min(1).max(10).optional(),
|
||||
caloriesBurned: z.number().int().positive().optional(),
|
||||
date: z.string().optional(), // ISO date string or date-only string
|
||||
sets: z
|
||||
.array(
|
||||
z.object({
|
||||
exerciseId: z.string(),
|
||||
setNumber: z.number().int().positive(),
|
||||
reps: z.number().int().positive().optional(),
|
||||
weight: z.number().positive().optional(),
|
||||
weightUnit: z.string().default("lbs"),
|
||||
rpe: z.number().int().min(1).max(10).optional(),
|
||||
durationSeconds: z.number().int().positive().optional(),
|
||||
distance: z.number().positive().optional(),
|
||||
distanceUnit: z.string().optional(),
|
||||
calories: z.number().int().positive().optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
.default([]),
|
||||
});
|
||||
|
||||
// GET: List workouts with search/date filters
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const query = searchParams.get("q");
|
||||
const dateFrom = searchParams.get("dateFrom");
|
||||
const dateTo = searchParams.get("dateTo");
|
||||
const limit = Math.min(parseInt(searchParams.get("limit") || "50"), 100);
|
||||
const offset = parseInt(searchParams.get("offset") || "0");
|
||||
|
||||
const where: any = {
|
||||
userId: user.id,
|
||||
};
|
||||
|
||||
if (query) {
|
||||
where.name = {
|
||||
contains: query,
|
||||
};
|
||||
}
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
where.date = {};
|
||||
if (dateFrom) {
|
||||
where.date.gte = new Date(dateFrom);
|
||||
}
|
||||
if (dateTo) {
|
||||
const toDate = new Date(dateTo);
|
||||
toDate.setHours(23, 59, 59, 999);
|
||||
where.date.lte = toDate;
|
||||
}
|
||||
}
|
||||
|
||||
const [workouts, total] = await Promise.all([
|
||||
prisma.workout.findMany({
|
||||
where,
|
||||
include: {
|
||||
setLogs: {
|
||||
include: {
|
||||
exercise: true,
|
||||
},
|
||||
orderBy: {
|
||||
setNumber: "asc",
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
date: "desc",
|
||||
},
|
||||
take: limit,
|
||||
skip: offset,
|
||||
}),
|
||||
prisma.workout.count({ where }),
|
||||
]);
|
||||
|
||||
// Supplement with caloriesBurned from raw SQL
|
||||
const ids = workouts.map((w) => w.id);
|
||||
const caloriesMap = await getCaloriesBurnedBulk(ids);
|
||||
const enriched = workouts.map((w) => ({
|
||||
...w,
|
||||
caloriesBurned: caloriesMap[w.id] ?? null,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
data: enriched,
|
||||
meta: {
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: offset + limit < total,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch workouts:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch workouts" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST: Create workout (can be empty or with sets)
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const validated = createWorkoutSchema.parse(body);
|
||||
|
||||
const workoutDate = validated.date ? new Date(validated.date) : new Date();
|
||||
|
||||
// Extract caloriesBurned — handled via raw SQL after creation
|
||||
const caloriesValue = validated.caloriesBurned;
|
||||
|
||||
const createData: any = {
|
||||
userId: user.id,
|
||||
name: validated.name || null,
|
||||
notes: validated.notes,
|
||||
durationMinutes: validated.durationMinutes,
|
||||
difficulty: validated.difficulty,
|
||||
// caloriesBurned handled separately via raw SQL
|
||||
date: workoutDate,
|
||||
setLogs:
|
||||
validated.sets.length > 0
|
||||
? {
|
||||
create: validated.sets.map((set) => ({
|
||||
exerciseId: set.exerciseId,
|
||||
setNumber: set.setNumber,
|
||||
reps: set.reps,
|
||||
weight: set.weight,
|
||||
weightUnit: set.weightUnit,
|
||||
rpe: set.rpe,
|
||||
durationSeconds: set.durationSeconds,
|
||||
distance: set.distance,
|
||||
distanceUnit: set.distanceUnit,
|
||||
calories: set.calories,
|
||||
notes: set.notes,
|
||||
} as any)),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const includeOpts = {
|
||||
setLogs: {
|
||||
include: { exercise: true },
|
||||
orderBy: { setNumber: "asc" as const },
|
||||
},
|
||||
};
|
||||
|
||||
const workout = await prisma.workout.create({ data: createData, include: includeOpts });
|
||||
|
||||
// Set caloriesBurned via raw SQL
|
||||
if (caloriesValue !== undefined) {
|
||||
await setCaloriesBurned(workout.id, caloriesValue);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ...workout, caloriesBurned: caloriesValue ?? null }, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request data", details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.error("Failed to create workout:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create workout" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
import { cookies } from 'next/headers';
|
||||
import { verifyPassword, createSession } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function loginAction(email: string, password: string) {
|
||||
try {
|
||||
// Look up user by email
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return { error: 'Invalid email or password' };
|
||||
}
|
||||
|
||||
// Verify the password
|
||||
const isValid = await verifyPassword(password, user.passwordHash);
|
||||
|
||||
if (!isValid) {
|
||||
return { error: 'Invalid email or password' };
|
||||
}
|
||||
|
||||
// Create a session
|
||||
const session = await createSession(user.id);
|
||||
|
||||
// Set the session cookie
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set('sessionToken', session.token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
path: '/',
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return { error: 'An error occurred during login' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function redirectToDashboard() {
|
||||
redirect('/main/dashboard');
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { loginAction } from './actions';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await loginAction(email, password);
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
router.push('/main/dashboard');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('An unexpected error occurred');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0A0A0A] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-zinc-900 rounded border border-zinc-800 shadow-2xl">
|
||||
<div className="flex flex-col space-y-2 p-8 text-center">
|
||||
<h1 className="text-3xl font-bold leading-none tracking-tight text-white">
|
||||
Workout Planner
|
||||
</h1>
|
||||
<p className="text-xs text-zinc-500 mt-2 uppercase tracking-widest">
|
||||
Track. Lift. Dominate.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-8 pt-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-xs font-semibold text-white uppercase tracking-wider">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-2.5 rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-0 focus:border-white transition-all"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-xs font-semibold text-white uppercase tracking-wider">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-2.5 rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-0 focus:border-white transition-all"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded bg-red-900/50 px-4 py-3 border border-red-800 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 px-4 rounded bg-white text-black font-bold text-sm uppercase tracking-wider transition-all duration-200 hover:bg-gray-100 disabled:bg-zinc-700 disabled:text-zinc-500 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-zinc-600 mt-6 uppercase tracking-wider">
|
||||
Demo: admin@example.com / password
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--sidebar-width: 240px;
|
||||
--nav-height: 64px;
|
||||
--bottom-nav-height: 64px;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-black text-white;
|
||||
background-color: #0a0a0a;
|
||||
}
|
||||
|
||||
/* Premium heading typography */
|
||||
h1, h2, h3 {
|
||||
font-family: var(--font-display), sans-serif;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Scrollbar styling for dark mode */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #262626;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #404040;
|
||||
}
|
||||
|
||||
/* App shell layout utilities */
|
||||
@layer components {
|
||||
.app-content {
|
||||
@apply w-full md:pl-[var(--sidebar-width)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Space_Grotesk, Bebas_Neue } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import Script from 'next/script';
|
||||
|
||||
const spaceGrotesk = Space_Grotesk({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-sans',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
const bebasNeue = Bebas_Neue({
|
||||
weight: '400',
|
||||
subsets: ['latin'],
|
||||
variable: '--font-display',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#0A0A0A',
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Workout Planner',
|
||||
description: 'Track. Lift. Dominate.',
|
||||
manifest: '/manifest.json',
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'black-translucent',
|
||||
title: 'Workout',
|
||||
},
|
||||
icons: {
|
||||
icon: '/icons/favicon.svg',
|
||||
apple: '/icons/icon-192x192.png',
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className={`dark ${spaceGrotesk.variable} ${bebasNeue.variable}`}>
|
||||
<head>
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
|
||||
</head>
|
||||
<body className="bg-[#0A0A0A] text-white antialiased font-sans">
|
||||
{children}
|
||||
<Script src="/sw-register.js" strategy="lazyOnload" />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export async function logoutAction() {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete('sessionToken');
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import {
|
||||
getWeeklyWorkoutCount,
|
||||
getMonthlyWorkoutCount,
|
||||
getYearlyWorkoutCount,
|
||||
getWeeklyVolume,
|
||||
} from "@/lib/db/stats";
|
||||
import { getRecentWorkouts } from "@/lib/db/workouts";
|
||||
import { ActivitySquare, Calendar, CalendarDays, History, Plus } from "lucide-react";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const [weeklyCount, monthlyCount, yearlyCount, _weeklyVolume, recentWorkouts] =
|
||||
await Promise.all([
|
||||
getWeeklyWorkoutCount(user.id),
|
||||
getMonthlyWorkoutCount(user.id),
|
||||
getYearlyWorkoutCount(user.id),
|
||||
getWeeklyVolume(user.id),
|
||||
getRecentWorkouts(user.id, 5),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0A0A0A]">
|
||||
{/* Header with greeting */}
|
||||
<div className="px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-4xl font-bold text-white">
|
||||
Welcome back, {user.name || "Trainer"}!
|
||||
</h1>
|
||||
<p className="text-zinc-400 mt-2">
|
||||
Keep pushing your limits and achieving your goals.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-3 gap-3 sm:gap-4 mb-8">
|
||||
{/* This Week */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-4 sm:p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-zinc-400 text-xs sm:text-sm font-medium">
|
||||
This Week
|
||||
</p>
|
||||
<p className="text-3xl sm:text-4xl font-bold text-white mt-1 sm:mt-2">
|
||||
{weeklyCount}
|
||||
</p>
|
||||
<p className="text-zinc-500 text-[10px] sm:text-xs mt-1 sm:mt-2">
|
||||
workouts
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-zinc-800 p-2 sm:p-3 rounded-lg hidden sm:block">
|
||||
<ActivitySquare className="text-white w-5 h-5 sm:w-6 sm:h-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* This Month */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-4 sm:p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-zinc-400 text-xs sm:text-sm font-medium">
|
||||
This Month
|
||||
</p>
|
||||
<p className="text-3xl sm:text-4xl font-bold text-white mt-1 sm:mt-2">
|
||||
{monthlyCount}
|
||||
</p>
|
||||
<p className="text-zinc-500 text-[10px] sm:text-xs mt-1 sm:mt-2">
|
||||
workouts
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-zinc-800 p-2 sm:p-3 rounded-lg hidden sm:block">
|
||||
<Calendar className="text-white w-5 h-5 sm:w-6 sm:h-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* This Year */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-4 sm:p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-zinc-400 text-xs sm:text-sm font-medium">
|
||||
This Year
|
||||
</p>
|
||||
<p className="text-3xl sm:text-4xl font-bold text-white mt-1 sm:mt-2">
|
||||
{yearlyCount}
|
||||
</p>
|
||||
<p className="text-zinc-500 text-[10px] sm:text-xs mt-1 sm:mt-2">
|
||||
workouts
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-zinc-800 p-2 sm:p-3 rounded-lg hidden sm:block">
|
||||
<CalendarDays className="text-white w-5 h-5 sm:w-6 sm:h-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-8">
|
||||
<Link
|
||||
href="/main/workouts/new"
|
||||
className="flex-1 bg-white hover:bg-gray-100 text-black font-medium py-3 px-4 rounded-lg flex items-center justify-center gap-2 transition"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Log Workout
|
||||
</Link>
|
||||
<Link
|
||||
href="/main/workouts"
|
||||
className="flex-1 bg-zinc-800 hover:bg-zinc-700 text-white font-medium py-3 px-4 rounded-lg flex items-center justify-center gap-2 transition"
|
||||
>
|
||||
<History className="w-5 h-5" />
|
||||
View History
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Recent Workouts */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg">
|
||||
<div className="px-6 py-4 border-b border-zinc-800">
|
||||
<h2 className="text-lg font-bold text-white">
|
||||
Recent Workouts
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{recentWorkouts.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center">
|
||||
<ActivitySquare className="w-12 h-12 text-zinc-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-white mb-2">
|
||||
No workouts yet
|
||||
</h3>
|
||||
<p className="text-zinc-400 mb-6">
|
||||
Start your fitness journey by logging your first workout!
|
||||
</p>
|
||||
<Link
|
||||
href="/main/workouts/new"
|
||||
className="inline-block bg-white hover:bg-gray-100 text-black font-medium py-2 px-4 rounded-lg transition"
|
||||
>
|
||||
Log First Workout
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-zinc-800">
|
||||
{recentWorkouts.map((workout) => (
|
||||
<Link
|
||||
key={workout.id}
|
||||
href={`/main/workouts/${workout.id}`}
|
||||
className="px-6 py-4 hover:bg-zinc-800 transition block"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">
|
||||
{workout.name || "Unnamed Workout"}
|
||||
</h3>
|
||||
<p className="text-sm text-zinc-400 mt-1">
|
||||
{new Date(workout.date).toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-sm text-zinc-500 mt-1">
|
||||
{(workout as any).setLogs.length} sets
|
||||
{workout.durationMinutes &&
|
||||
` · ${workout.durationMinutes} min`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{(workout as any).setLogs.length}
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400 mt-1">
|
||||
{(workout as any).setLogs.length === 1 ? "set" : "sets"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { formatSetsSummary } from "@/lib/formatSets";
|
||||
import {
|
||||
ChevronLeft,
|
||||
Loader,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Check,
|
||||
X,
|
||||
Dumbbell,
|
||||
TrendingUp,
|
||||
Calendar,
|
||||
} from "lucide-react";
|
||||
import { Exercise as PrismaExercise } from "@prisma/client";
|
||||
|
||||
// Extended type until Prisma client is regenerated with new fields
|
||||
type Exercise = PrismaExercise & {
|
||||
inputFields?: string;
|
||||
defaultWeightUnit?: string | null;
|
||||
};
|
||||
|
||||
const EXERCISE_TYPES = [
|
||||
"barbell",
|
||||
"dumbbell",
|
||||
"machine",
|
||||
"cable",
|
||||
"bodyweight",
|
||||
"cardio",
|
||||
"kettlebell",
|
||||
"other",
|
||||
];
|
||||
|
||||
const MUSCLE_GROUPS = [
|
||||
"chest",
|
||||
"back",
|
||||
"shoulders",
|
||||
"quads",
|
||||
"hamstrings",
|
||||
"glutes",
|
||||
"biceps",
|
||||
"triceps",
|
||||
"forearms",
|
||||
"core",
|
||||
"calves",
|
||||
"full body",
|
||||
"cardio",
|
||||
];
|
||||
|
||||
const INPUT_FIELD_OPTIONS = [
|
||||
{ value: "sets", label: "Sets" },
|
||||
{ value: "reps", label: "Reps" },
|
||||
{ value: "weight", label: "Weight" },
|
||||
{ value: "duration", label: "Duration" },
|
||||
{ value: "distance", label: "Distance" },
|
||||
{ value: "calories", label: "Calories" },
|
||||
{ value: "notes", label: "Notes" },
|
||||
];
|
||||
|
||||
interface WorkoutHistory {
|
||||
workout: { id: string; date: string; name: string | null };
|
||||
sets: Array<{
|
||||
id: string;
|
||||
setNumber: number;
|
||||
reps: number | null;
|
||||
weight: number | null;
|
||||
weightUnit: string;
|
||||
rpe: number | null;
|
||||
durationSeconds: number | null;
|
||||
distance: number | null;
|
||||
calories: number | null;
|
||||
notes: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function ExerciseDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const exerciseId = params.id as string;
|
||||
|
||||
const [exercise, setExercise] = useState<Exercise | null>(null);
|
||||
const [history, setHistory] = useState<WorkoutHistory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// Edit form state
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editType, setEditType] = useState("");
|
||||
const [editMuscleGroups, setEditMuscleGroups] = useState<string[]>([]);
|
||||
const [editInputFields, setEditInputFields] = useState<string[]>([]);
|
||||
const [editDefaultUnit, setEditDefaultUnit] = useState<string | null>(null);
|
||||
|
||||
// Custom "+" add state
|
||||
const [addingType, setAddingType] = useState(false);
|
||||
const [newTypeText, setNewTypeText] = useState("");
|
||||
const [addingMuscle, setAddingMuscle] = useState(false);
|
||||
const [newMuscleText, setNewMuscleText] = useState("");
|
||||
const [addingField, setAddingField] = useState(false);
|
||||
const [newFieldText, setNewFieldText] = useState("");
|
||||
const [customTypes, setCustomTypes] = useState<string[]>([]);
|
||||
const [customMuscles, setCustomMuscles] = useState<string[]>([]);
|
||||
const [customFields, setCustomFields] = useState<{ value: string; label: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchExercise();
|
||||
}, [exerciseId]);
|
||||
|
||||
const fetchExercise = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/exercises/${exerciseId}`);
|
||||
if (!res.ok) throw new Error("Not found");
|
||||
const data = await res.json();
|
||||
setExercise(data.exercise);
|
||||
setHistory(data.history || []);
|
||||
|
||||
// Populate edit form
|
||||
setEditName(data.exercise.name);
|
||||
setEditType(data.exercise.type);
|
||||
const mg = JSON.parse(data.exercise.muscleGroups || "[]") as string[];
|
||||
setEditMuscleGroups(mg);
|
||||
const ifs = JSON.parse(data.exercise.inputFields || '["sets","reps","weight"]') as string[];
|
||||
setEditInputFields(ifs);
|
||||
setEditDefaultUnit(data.exercise.defaultWeightUnit);
|
||||
|
||||
// Detect custom values not in default lists
|
||||
const knownTypes = EXERCISE_TYPES;
|
||||
if (!knownTypes.includes(data.exercise.type)) {
|
||||
setCustomTypes((prev) => prev.includes(data.exercise.type) ? prev : [...prev, data.exercise.type]);
|
||||
}
|
||||
const knownMuscles = MUSCLE_GROUPS;
|
||||
mg.forEach((m: string) => {
|
||||
if (!knownMuscles.includes(m)) {
|
||||
setCustomMuscles((prev) => prev.includes(m) ? prev : [...prev, m]);
|
||||
}
|
||||
});
|
||||
const knownFields = INPUT_FIELD_OPTIONS.map((f) => f.value);
|
||||
ifs.forEach((f: string) => {
|
||||
if (!knownFields.includes(f)) {
|
||||
setCustomFields((prev) =>
|
||||
prev.some((cf) => cf.value === f)
|
||||
? prev
|
||||
: [...prev, { value: f, label: f.charAt(0).toUpperCase() + f.slice(1) }]
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/exercises/${exerciseId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: editName,
|
||||
type: editType,
|
||||
muscleGroups: editMuscleGroups,
|
||||
inputFields: editInputFields,
|
||||
defaultWeightUnit: editDefaultUnit,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to save");
|
||||
const updated = await res.json();
|
||||
setExercise(updated);
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert("Failed to save changes");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm("Delete this exercise and all its history?")) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/exercises/${exerciseId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to delete");
|
||||
router.push("/main/exercises");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert("Failed to delete exercise");
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMuscleGroup = (group: string) => {
|
||||
setEditMuscleGroups((prev) =>
|
||||
prev.includes(group) ? prev.filter((g) => g !== group) : [...prev, group]
|
||||
);
|
||||
};
|
||||
|
||||
const toggleInputField = (field: string) => {
|
||||
setEditInputFields((prev) =>
|
||||
prev.includes(field)
|
||||
? prev.filter((f) => f !== field)
|
||||
: [...prev, field]
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0A0A0A] flex items-center justify-center">
|
||||
<Loader className="w-8 h-8 animate-spin text-zinc-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!exercise) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0A0A0A] p-6">
|
||||
<Link
|
||||
href="/main/exercises"
|
||||
className="text-zinc-400 hover:text-white flex items-center gap-1 mb-4"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
Back
|
||||
</Link>
|
||||
<p className="text-zinc-500">Exercise not found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const muscleGroups = JSON.parse(exercise.muscleGroups || "[]") as string[];
|
||||
const inputFields = JSON.parse(
|
||||
exercise.inputFields || '["sets","reps","weight"]'
|
||||
) as string[];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0A0A0A]">
|
||||
{/* Header */}
|
||||
<div className="border-b border-zinc-800 px-4 py-4">
|
||||
<div className="max-w-2xl mx-auto flex items-center gap-4">
|
||||
<Link
|
||||
href="/main/exercises"
|
||||
className="p-2 hover:bg-zinc-900 rounded-lg text-zinc-400 hover:text-white"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold text-white flex-1 truncate">
|
||||
{exercise.name}
|
||||
</h1>
|
||||
{!editing && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="p-2 hover:bg-zinc-900 rounded-lg text-zinc-400 hover:text-white"
|
||||
>
|
||||
<Pencil className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="p-2 hover:bg-zinc-900 rounded-lg text-red-500 hover:text-red-400 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-2xl mx-auto px-4 py-6 space-y-6">
|
||||
{/* Edit Mode */}
|
||||
{editing ? (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6 space-y-5">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-white/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
|
||||
Equipment
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[...EXERCISE_TYPES, ...customTypes].map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setEditType(type)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
|
||||
editType === type
|
||||
? "bg-white text-black"
|
||||
: "bg-zinc-800 text-zinc-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
{addingType ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const val = newTypeText.trim().toLowerCase();
|
||||
if (val && !EXERCISE_TYPES.includes(val) && !customTypes.includes(val)) {
|
||||
setCustomTypes((p) => [...p, val]);
|
||||
setEditType(val);
|
||||
}
|
||||
setNewTypeText("");
|
||||
setAddingType(false);
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={newTypeText}
|
||||
onChange={(e) => setNewTypeText(e.target.value)}
|
||||
onBlur={() => {
|
||||
const val = newTypeText.trim().toLowerCase();
|
||||
if (val && !EXERCISE_TYPES.includes(val) && !customTypes.includes(val)) {
|
||||
setCustomTypes((p) => [...p, val]);
|
||||
setEditType(val);
|
||||
}
|
||||
setNewTypeText("");
|
||||
setAddingType(false);
|
||||
}}
|
||||
placeholder="New type"
|
||||
className="w-24 px-2 py-1.5 bg-zinc-800 border border-zinc-600 rounded-lg text-sm text-white focus:outline-none focus:ring-1 focus:ring-white/30"
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddingType(true)}
|
||||
className="px-2.5 py-1.5 rounded-lg text-sm font-medium bg-zinc-800 text-zinc-500 hover:text-white border border-dashed border-zinc-600 transition"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
|
||||
Muscle Groups
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[...MUSCLE_GROUPS, ...customMuscles].map((group) => (
|
||||
<button
|
||||
key={group}
|
||||
onClick={() => toggleMuscleGroup(group)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
|
||||
editMuscleGroups.includes(group)
|
||||
? "bg-white text-black"
|
||||
: "bg-zinc-800 text-zinc-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{group.charAt(0).toUpperCase() + group.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
{addingMuscle ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const val = newMuscleText.trim().toLowerCase();
|
||||
if (val && !MUSCLE_GROUPS.includes(val) && !customMuscles.includes(val)) {
|
||||
setCustomMuscles((p) => [...p, val]);
|
||||
}
|
||||
if (val && !editMuscleGroups.includes(val)) {
|
||||
setEditMuscleGroups((p) => [...p, val]);
|
||||
}
|
||||
setNewMuscleText("");
|
||||
setAddingMuscle(false);
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={newMuscleText}
|
||||
onChange={(e) => setNewMuscleText(e.target.value)}
|
||||
onBlur={() => {
|
||||
const val = newMuscleText.trim().toLowerCase();
|
||||
if (val && !MUSCLE_GROUPS.includes(val) && !customMuscles.includes(val)) {
|
||||
setCustomMuscles((p) => [...p, val]);
|
||||
}
|
||||
if (val && !editMuscleGroups.includes(val)) {
|
||||
setEditMuscleGroups((p) => [...p, val]);
|
||||
}
|
||||
setNewMuscleText("");
|
||||
setAddingMuscle(false);
|
||||
}}
|
||||
placeholder="New group"
|
||||
className="w-24 px-2 py-1.5 bg-zinc-800 border border-zinc-600 rounded-lg text-sm text-white focus:outline-none focus:ring-1 focus:ring-white/30"
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddingMuscle(true)}
|
||||
className="px-2.5 py-1.5 rounded-lg text-sm font-medium bg-zinc-800 text-zinc-500 hover:text-white border border-dashed border-zinc-600 transition"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
|
||||
Input Fields
|
||||
</label>
|
||||
<p className="text-xs text-zinc-600 mb-2">
|
||||
Choose which fields are relevant when logging this exercise
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[...INPUT_FIELD_OPTIONS, ...customFields].map((field) => (
|
||||
<button
|
||||
key={field.value}
|
||||
onClick={() => toggleInputField(field.value)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
|
||||
editInputFields.includes(field.value)
|
||||
? "bg-white text-black"
|
||||
: "bg-zinc-800 text-zinc-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{field.label}
|
||||
</button>
|
||||
))}
|
||||
{addingField ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const val = newFieldText.trim().toLowerCase();
|
||||
if (
|
||||
val &&
|
||||
!INPUT_FIELD_OPTIONS.some((f) => f.value === val) &&
|
||||
!customFields.some((f) => f.value === val)
|
||||
) {
|
||||
setCustomFields((p) => [
|
||||
...p,
|
||||
{ value: val, label: val.charAt(0).toUpperCase() + val.slice(1) },
|
||||
]);
|
||||
}
|
||||
if (val && !editInputFields.includes(val)) {
|
||||
setEditInputFields((p) => [...p, val]);
|
||||
}
|
||||
setNewFieldText("");
|
||||
setAddingField(false);
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={newFieldText}
|
||||
onChange={(e) => setNewFieldText(e.target.value)}
|
||||
onBlur={() => {
|
||||
const val = newFieldText.trim().toLowerCase();
|
||||
if (
|
||||
val &&
|
||||
!INPUT_FIELD_OPTIONS.some((f) => f.value === val) &&
|
||||
!customFields.some((f) => f.value === val)
|
||||
) {
|
||||
setCustomFields((p) => [
|
||||
...p,
|
||||
{ value: val, label: val.charAt(0).toUpperCase() + val.slice(1) },
|
||||
]);
|
||||
}
|
||||
if (val && !editInputFields.includes(val)) {
|
||||
setEditInputFields((p) => [...p, val]);
|
||||
}
|
||||
setNewFieldText("");
|
||||
setAddingField(false);
|
||||
}}
|
||||
placeholder="New field"
|
||||
className="w-24 px-2 py-1.5 bg-zinc-800 border border-zinc-600 rounded-lg text-sm text-white focus:outline-none focus:ring-1 focus:ring-white/30"
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddingField(true)}
|
||||
className="px-2.5 py-1.5 rounded-lg text-sm font-medium bg-zinc-800 text-zinc-500 hover:text-white border border-dashed border-zinc-600 transition"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
|
||||
Default Weight Unit
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ value: null, label: "User Default" },
|
||||
{ value: "lbs", label: "Pounds" },
|
||||
{ value: "kg", label: "Kilograms" },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.label}
|
||||
onClick={() => setEditDefaultUnit(opt.value)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
|
||||
editDefaultUnit === opt.value
|
||||
? "bg-white text-black"
|
||||
: "bg-zinc-800 text-zinc-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex-1 py-3 bg-white text-black font-bold rounded-lg hover:bg-zinc-200 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Check className="w-5 h-5" />
|
||||
)}
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditing(false)}
|
||||
className="flex-1 py-3 bg-zinc-800 text-white font-medium rounded-lg hover:bg-zinc-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* View Mode */
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className="bg-zinc-800 p-3 rounded-lg">
|
||||
<Dumbbell className="w-6 h-6 text-zinc-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
{exercise.name}
|
||||
</h2>
|
||||
<span className="inline-block mt-1 px-2 py-0.5 bg-zinc-800 text-zinc-300 rounded text-xs font-medium">
|
||||
{exercise.type.charAt(0).toUpperCase() +
|
||||
exercise.type.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{muscleGroups.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{muscleGroups.map((group) => (
|
||||
<span
|
||||
key={group}
|
||||
className="px-2 py-1 bg-zinc-800 text-zinc-400 rounded text-xs"
|
||||
>
|
||||
{group.charAt(0).toUpperCase() + group.slice(1)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-zinc-800 pt-4 mt-4">
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-2">
|
||||
Tracked Fields
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{inputFields.map((field) => (
|
||||
<span
|
||||
key={field}
|
||||
className="px-2 py-1 bg-zinc-800 text-zinc-300 rounded text-xs font-medium"
|
||||
>
|
||||
{field.charAt(0).toUpperCase() + field.slice(1)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{exercise.defaultWeightUnit && (
|
||||
<p className="text-xs text-zinc-500 mt-3">
|
||||
Default unit:{" "}
|
||||
<span className="text-zinc-300">
|
||||
{exercise.defaultWeightUnit}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5 text-zinc-400" />
|
||||
History
|
||||
</h3>
|
||||
|
||||
{history.length === 0 ? (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-8 text-center">
|
||||
<TrendingUp className="w-10 h-10 text-zinc-700 mx-auto mb-3" />
|
||||
<p className="text-zinc-500">No history yet</p>
|
||||
<p className="text-zinc-600 text-sm mt-1">
|
||||
Start logging this exercise to see your progress
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{history.map((entry) => {
|
||||
const summary = formatSetsSummary(
|
||||
entry.sets.map((s: any) => ({ weight: s.weight, reps: s.reps, weightUnit: s.weightUnit }))
|
||||
);
|
||||
const dateStr = new Date(entry.workout.date).toLocaleDateString(
|
||||
"en-US",
|
||||
{ month: "short", day: "numeric" }
|
||||
);
|
||||
return (
|
||||
<Link
|
||||
key={entry.workout.id}
|
||||
href={`/main/workouts/${entry.workout.id}`}
|
||||
className="flex items-baseline gap-2 px-3 py-1.5 rounded-md hover:bg-zinc-800/60 transition"
|
||||
>
|
||||
<span className="text-xs text-zinc-500 flex-shrink-0 tabular-nums">
|
||||
{dateStr}
|
||||
</span>
|
||||
<span className="text-xs text-zinc-600 flex-shrink-0">·</span>
|
||||
<span className="text-xs text-zinc-500 flex-shrink-0">
|
||||
{entry.sets.length} {entry.sets.length === 1 ? "set" : "sets"}
|
||||
</span>
|
||||
{summary && (
|
||||
<>
|
||||
<span className="text-xs text-zinc-600 flex-shrink-0">·</span>
|
||||
<span className="text-sm text-zinc-300 truncate">
|
||||
{summary}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import ExercisesClient from "@/components/exercises/ExercisesClient";
|
||||
|
||||
export default async function ExercisesPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0A0A0A]">
|
||||
<div className="border-b border-zinc-800 px-4 py-4 sm:px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-white">Exercises</h1>
|
||||
<p className="text-zinc-500 text-sm mt-1">
|
||||
Browse and manage your exercise library
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ExercisesClient />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { ChevronLeft, Upload, Trash2, Check, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface ParsedSet {
|
||||
setNumber: number;
|
||||
weight?: number;
|
||||
weightUnit: string;
|
||||
reps?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
interface ParsedExercise {
|
||||
exerciseId: string;
|
||||
exerciseName: string;
|
||||
sets: ParsedSet[];
|
||||
}
|
||||
|
||||
interface ParsedWorkout {
|
||||
date: string;
|
||||
exercises: ParsedExercise[];
|
||||
}
|
||||
|
||||
interface WorkoutState extends ParsedWorkout {
|
||||
status: "pending" | "approved" | "skipped";
|
||||
}
|
||||
|
||||
export default function ImportCSVPage() {
|
||||
const [workouts, setWorkouts] = useState<WorkoutState[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [unmapped, setUnmapped] = useState<string[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const currentWorkout = workouts[currentIndex];
|
||||
const approved = workouts.filter((w) => w.status === "approved").length;
|
||||
const skipped = workouts.filter((w) => w.status === "skipped").length;
|
||||
const remaining = workouts.filter((w) => w.status === "pending").length;
|
||||
|
||||
const handleFileChange = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch("/api/import/parse", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Failed to parse CSV");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const initialWorkouts: WorkoutState[] = data.workouts.map(
|
||||
(w: ParsedWorkout) => ({
|
||||
...w,
|
||||
status: "pending" as const,
|
||||
})
|
||||
);
|
||||
|
||||
setWorkouts(initialWorkouts);
|
||||
setUnmapped(data.unmapped || []);
|
||||
setCurrentIndex(0);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to parse CSV");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
const file = files[0];
|
||||
if (fileInputRef.current) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
fileInputRef.current.files = dataTransfer.files;
|
||||
|
||||
const event = new Event("change", { bubbles: true });
|
||||
fileInputRef.current.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateSet = (
|
||||
exerciseIdx: number,
|
||||
setIdx: number,
|
||||
field: keyof ParsedSet,
|
||||
value: any
|
||||
) => {
|
||||
if (!currentWorkout) return;
|
||||
|
||||
const updatedWorkouts = [...workouts];
|
||||
const workout = updatedWorkouts[currentIndex];
|
||||
const set = workout.exercises[exerciseIdx].sets[setIdx];
|
||||
|
||||
if (field === "setNumber") {
|
||||
set[field] = value ? parseInt(value, 10) : 0;
|
||||
} else if (field === "reps") {
|
||||
set[field] = value ? parseInt(value, 10) : undefined;
|
||||
} else if (field === "weight") {
|
||||
set[field] = value ? parseFloat(value) : undefined;
|
||||
} else {
|
||||
(set[field] as any) = value;
|
||||
}
|
||||
|
||||
setWorkouts(updatedWorkouts);
|
||||
};
|
||||
|
||||
const deleteSet = (exerciseIdx: number, setIdx: number) => {
|
||||
if (!currentWorkout) return;
|
||||
|
||||
const updatedWorkouts = [...workouts];
|
||||
const workout = updatedWorkouts[currentIndex];
|
||||
const exercise = workout.exercises[exerciseIdx];
|
||||
|
||||
// Remove the set
|
||||
exercise.sets.splice(setIdx, 1);
|
||||
|
||||
// Renumber remaining sets
|
||||
exercise.sets.forEach((set, idx) => {
|
||||
set.setNumber = idx + 1;
|
||||
});
|
||||
|
||||
// If no sets left, remove the exercise
|
||||
if (exercise.sets.length === 0) {
|
||||
workout.exercises.splice(exerciseIdx, 1);
|
||||
}
|
||||
|
||||
setWorkouts(updatedWorkouts);
|
||||
};
|
||||
|
||||
const deleteExercise = (exerciseIdx: number) => {
|
||||
if (!currentWorkout) return;
|
||||
|
||||
const updatedWorkouts = [...workouts];
|
||||
const workout = updatedWorkouts[currentIndex];
|
||||
workout.exercises.splice(exerciseIdx, 1);
|
||||
|
||||
setWorkouts(updatedWorkouts);
|
||||
};
|
||||
|
||||
const approveWorkout = async () => {
|
||||
if (!currentWorkout) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Transform workout to API format
|
||||
const setLogs = [];
|
||||
for (const exercise of currentWorkout.exercises) {
|
||||
for (const set of exercise.sets) {
|
||||
setLogs.push({
|
||||
exerciseId: exercise.exerciseId,
|
||||
setNumber: set.setNumber,
|
||||
weight: set.weight || null,
|
||||
weightUnit: set.weightUnit,
|
||||
reps: set.reps || null,
|
||||
notes: set.notes || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch("/api/workouts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
date: currentWorkout.date,
|
||||
sets: setLogs,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to save workout");
|
||||
}
|
||||
|
||||
// Mark as approved and move to next
|
||||
const updatedWorkouts = [...workouts];
|
||||
updatedWorkouts[currentIndex].status = "approved";
|
||||
setWorkouts(updatedWorkouts);
|
||||
|
||||
// Find next pending workout
|
||||
const nextPending = updatedWorkouts.findIndex(
|
||||
(w) => w.status === "pending"
|
||||
);
|
||||
if (nextPending !== -1) {
|
||||
setCurrentIndex(nextPending);
|
||||
} else {
|
||||
setCurrentIndex(currentIndex + 1);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save workout");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const skipWorkout = () => {
|
||||
if (!currentWorkout) return;
|
||||
|
||||
const updatedWorkouts = [...workouts];
|
||||
updatedWorkouts[currentIndex].status = "skipped";
|
||||
setWorkouts(updatedWorkouts);
|
||||
|
||||
// Find next pending workout
|
||||
const nextPending = updatedWorkouts.findIndex(
|
||||
(w, idx) => w.status === "pending" && idx > currentIndex
|
||||
);
|
||||
if (nextPending !== -1) {
|
||||
setCurrentIndex(nextPending);
|
||||
} else {
|
||||
setCurrentIndex(currentIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteWorkout = () => {
|
||||
const updatedWorkouts = workouts.filter((_, idx) => idx !== currentIndex);
|
||||
setWorkouts(updatedWorkouts);
|
||||
|
||||
if (updatedWorkouts.length > 0) {
|
||||
setCurrentIndex(Math.min(currentIndex, updatedWorkouts.length - 1));
|
||||
}
|
||||
};
|
||||
|
||||
// Upload step
|
||||
if (workouts.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0A0A0A] pb-24 md:pb-8">
|
||||
{/* Header */}
|
||||
<div className="border-b border-zinc-800 sticky top-0 z-40 bg-[#0A0A0A]">
|
||||
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center gap-4">
|
||||
<Link
|
||||
href="/main/workouts"
|
||||
className="p-2 hover:bg-zinc-900 rounded-lg -ml-2 text-zinc-400 hover:text-white"
|
||||
aria-label="Back"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
</Link>
|
||||
<h1 className="text-2xl font-display text-white tracking-wider">
|
||||
Import Workouts
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Area */}
|
||||
<div className="max-w-4xl mx-auto px-4 py-12">
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-900/20 border border-red-800 rounded-lg">
|
||||
<p className="text-red-200">{error}</p>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="mt-2 text-sm text-red-300 hover:text-red-200"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-zinc-900 rounded-lg p-12 border-2 border-dashed border-zinc-700 hover:border-zinc-600 transition-colors cursor-pointer"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="p-4 bg-zinc-800 rounded-lg">
|
||||
<Upload className="w-8 h-8 text-zinc-400" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-white mb-2">
|
||||
Upload CSV File
|
||||
</h2>
|
||||
<p className="text-zinc-400 mb-4">
|
||||
Drag and drop your CSV file here or click to select
|
||||
</p>
|
||||
<p className="text-sm text-zinc-500">
|
||||
CSV columns: date, exercise, weight, reps, notes
|
||||
</p>
|
||||
</div>
|
||||
{loading && (
|
||||
<p className="text-zinc-400 text-sm">Parsing CSV...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Example Format */}
|
||||
<div className="mt-12 bg-zinc-900 rounded-lg p-6 border border-zinc-800">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">
|
||||
CSV Format Example
|
||||
</h3>
|
||||
<pre className="text-xs text-zinc-400 overflow-x-auto bg-zinc-950 p-4 rounded border border-zinc-800">
|
||||
{`date,exercise,weight,reps,notes
|
||||
2025-02-15,Bench,225,5,good form
|
||||
2025-02-15,Bench,225,5,
|
||||
2025-02-15,Bench,225,3,
|
||||
2025-02-16,Squat,315,8,30kg per leg
|
||||
2025-02-16,Squat,315,6,`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Review step
|
||||
if (!currentWorkout) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0A0A0A] pb-24 md:pb-8">
|
||||
<div className="border-b border-zinc-800 sticky top-0 z-40 bg-[#0A0A0A]">
|
||||
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center gap-4">
|
||||
<Link
|
||||
href="/main/workouts"
|
||||
className="p-2 hover:bg-zinc-900 rounded-lg -ml-2 text-zinc-400 hover:text-white"
|
||||
aria-label="Back"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
</Link>
|
||||
<h1 className="text-2xl font-display text-white tracking-wider">
|
||||
Import Complete
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-4 py-12">
|
||||
<div className="bg-zinc-900 rounded-lg p-8 border border-zinc-800 text-center">
|
||||
<Check className="w-12 h-12 text-green-500 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-semibold text-white mb-2">
|
||||
All Done!
|
||||
</h2>
|
||||
<p className="text-zinc-400 mb-6">
|
||||
{approved} workouts approved, {skipped} skipped
|
||||
</p>
|
||||
<Link
|
||||
href="/main/workouts"
|
||||
className="inline-block px-6 py-2 bg-white text-black font-semibold rounded-lg hover:bg-zinc-200 transition-colors"
|
||||
>
|
||||
View Workouts
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0A0A0A] pb-24 md:pb-8">
|
||||
{/* Header */}
|
||||
<div className="border-b border-zinc-800 sticky top-0 z-40 bg-[#0A0A0A]">
|
||||
<div className="max-w-4xl mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setWorkouts([])}
|
||||
className="p-2 hover:bg-zinc-900 rounded-lg -ml-2 text-zinc-400 hover:text-white"
|
||||
aria-label="Back"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
</button>
|
||||
<h1 className="text-2xl font-display text-white tracking-wider">
|
||||
Review Workouts
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-zinc-400">
|
||||
{approved} approved, {skipped} skipped, {remaining} remaining
|
||||
</span>
|
||||
<span className="text-zinc-500">
|
||||
{currentIndex + 1} of {workouts.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-zinc-800 h-2 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-white transition-all duration-300"
|
||||
style={{
|
||||
width: `${((approved + skipped) / workouts.length) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-w-4xl mx-auto px-4 py-6">
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-900/20 border border-red-800 rounded-lg">
|
||||
<p className="text-red-200">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unmapped Exercises Warning */}
|
||||
{unmapped.length > 0 && (
|
||||
<div className="mb-6 p-4 bg-yellow-900/20 border border-yellow-800 rounded-lg">
|
||||
<p className="text-yellow-200 font-semibold mb-2">
|
||||
Unmapped exercises (not in your database):
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{unmapped.map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="px-3 py-1 bg-yellow-900/30 border border-yellow-700 rounded text-sm text-yellow-200"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Workout Card */}
|
||||
<div className="bg-zinc-900 rounded-lg border border-zinc-800 overflow-hidden">
|
||||
{/* Date Header */}
|
||||
<div className="bg-zinc-800 px-6 py-4 border-b border-zinc-700">
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
{new Date(currentWorkout.date).toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Exercises */}
|
||||
<div className="divide-y divide-zinc-800">
|
||||
{currentWorkout.exercises.map((exercise, exIdx) => (
|
||||
<div key={exIdx} className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{exercise.exerciseName}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => deleteExercise(exIdx)}
|
||||
className="p-2 hover:bg-zinc-800 rounded text-zinc-400 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sets Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-700">
|
||||
<th className="text-left py-2 px-3 text-zinc-400 font-medium">
|
||||
Set
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-zinc-400 font-medium">
|
||||
Weight
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-zinc-400 font-medium">
|
||||
Unit
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-zinc-400 font-medium">
|
||||
Reps
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-zinc-400 font-medium">
|
||||
Notes
|
||||
</th>
|
||||
<th className="text-right py-2 px-3 text-zinc-400 font-medium">
|
||||
Action
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{exercise.sets.map((set, setIdx) => (
|
||||
<tr key={setIdx}>
|
||||
<td className="py-3 px-3">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={set.setNumber}
|
||||
onChange={(e) =>
|
||||
updateSet(exIdx, setIdx, "setNumber", e.target.value)
|
||||
}
|
||||
className="w-12 bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-white text-center"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
placeholder="—"
|
||||
value={set.weight || ""}
|
||||
onChange={(e) =>
|
||||
updateSet(exIdx, setIdx, "weight", e.target.value)
|
||||
}
|
||||
className="w-20 bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-white"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-3">
|
||||
<select
|
||||
value={set.weightUnit}
|
||||
onChange={(e) =>
|
||||
updateSet(
|
||||
exIdx,
|
||||
setIdx,
|
||||
"weightUnit",
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-white text-sm"
|
||||
>
|
||||
<option>lbs</option>
|
||||
<option>kg</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="py-3 px-3">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="—"
|
||||
value={set.reps || ""}
|
||||
onChange={(e) =>
|
||||
updateSet(exIdx, setIdx, "reps", e.target.value)
|
||||
}
|
||||
className="w-16 bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-white"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="—"
|
||||
value={set.notes || ""}
|
||||
onChange={(e) =>
|
||||
updateSet(exIdx, setIdx, "notes", e.target.value)
|
||||
}
|
||||
className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-white text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-3 text-right">
|
||||
<button
|
||||
onClick={() => deleteSet(exIdx, setIdx)}
|
||||
className="p-1 hover:bg-zinc-800 rounded text-zinc-400 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="mt-6 flex gap-3 justify-between">
|
||||
<button
|
||||
onClick={deleteWorkout}
|
||||
className="px-4 py-2 bg-red-900/20 border border-red-800 text-red-200 rounded-lg hover:bg-red-900/30 transition-colors font-medium"
|
||||
>
|
||||
Delete Workout
|
||||
</button>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={skipWorkout}
|
||||
disabled={loading}
|
||||
className="px-6 py-2 bg-zinc-800 border border-zinc-700 text-white rounded-lg hover:bg-zinc-700 transition-colors font-medium disabled:opacity-50"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
onClick={approveWorkout}
|
||||
disabled={loading || currentWorkout.exercises.length === 0}
|
||||
className="px-6 py-2 bg-white text-black rounded-lg hover:bg-zinc-200 transition-colors font-medium disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{loading ? "Saving..." : "Approve"}
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import ImportCSVPage from "./page-csv";
|
||||
|
||||
export const metadata = {
|
||||
title: "Import Workouts",
|
||||
description: "Import workouts from CSV",
|
||||
};
|
||||
|
||||
export default async function ImportPage() {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect("/auth/login");
|
||||
|
||||
return <ImportCSVPage />;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { getCurrentUser } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
import Navigation from './navigation';
|
||||
|
||||
export default async function MainLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user) {
|
||||
redirect('/auth/login');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-[#0A0A0A]">
|
||||
<Navigation userName={user.name || user.email || 'User'} />
|
||||
<main className="flex-1 app-content pb-20 md:pb-0">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Dumbbell,
|
||||
ListChecks,
|
||||
Upload,
|
||||
Settings,
|
||||
LogOut,
|
||||
} from 'lucide-react';
|
||||
import { logoutAction } from './actions';
|
||||
|
||||
interface NavigationProps {
|
||||
userName: string;
|
||||
}
|
||||
|
||||
const navLinks = [
|
||||
{ href: '/main/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: '/main/workouts', label: 'Workouts', icon: Dumbbell },
|
||||
{ href: '/main/exercises', label: 'Exercises', icon: ListChecks },
|
||||
{ href: '/main/import', label: 'Import', icon: Upload },
|
||||
{ href: '/main/settings', label: 'Settings', icon: Settings },
|
||||
];
|
||||
|
||||
export default function Navigation({ userName }: NavigationProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const isActive = (href: string) => {
|
||||
return pathname === href || pathname.startsWith(href + '/');
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logoutAction();
|
||||
router.push('/auth/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Sidebar */}
|
||||
<aside className="hidden md:flex fixed left-0 top-0 h-screen w-[var(--sidebar-width)] border-r border-zinc-800 bg-[#0A0A0A] flex-col">
|
||||
<div className="p-6 border-b border-zinc-800">
|
||||
<h2 className="text-3xl font-display text-white tracking-wider">Workout</h2>
|
||||
<p className="text-xs text-zinc-500 mt-1 uppercase tracking-widest font-sans">Planner</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
{navLinks.map((link) => {
|
||||
const Icon = link.icon;
|
||||
const active = isActive(link.href);
|
||||
|
||||
return (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 rounded transition-all duration-200 ${
|
||||
active
|
||||
? 'bg-white text-black font-semibold'
|
||||
: 'text-zinc-500 hover:text-white hover:bg-zinc-900'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="text-sm">{link.label}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-zinc-800 p-4 space-y-4">
|
||||
<div className="px-4 py-2">
|
||||
<p className="text-xs text-zinc-600 uppercase tracking-widest">User</p>
|
||||
<p className="font-semibold text-white truncate mt-1">{userName}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-3 px-4 py-2.5 rounded transition-all duration-200 w-full justify-start text-red-500 hover:text-red-400 hover:bg-red-950/30"
|
||||
>
|
||||
<LogOut className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="text-sm">Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile Bottom Nav */}
|
||||
<header className="flex md:hidden fixed bottom-0 left-0 right-0 border-t border-zinc-800 bg-[#0A0A0A]">
|
||||
<nav className="flex items-center justify-around h-[var(--bottom-nav-height)] w-full">
|
||||
{navLinks.map((link) => {
|
||||
const Icon = link.icon;
|
||||
const active = isActive(link.href);
|
||||
|
||||
return (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`flex flex-col items-center justify-center flex-1 h-full gap-1 transition-colors duration-200 ${
|
||||
active
|
||||
? 'text-white bg-zinc-900'
|
||||
: 'text-zinc-500 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-6 h-6" />
|
||||
<span className="text-xs">{link.label}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex flex-col items-center justify-center flex-1 h-full gap-1 text-red-500 hover:text-red-400 transition-colors duration-200"
|
||||
>
|
||||
<LogOut className="w-6 h-6" />
|
||||
<span className="text-xs">Logout</span>
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import SettingsForm from "@/components/settings/SettingsForm";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0A0A0A]">
|
||||
<div className="border-b border-zinc-800 px-4 py-4 sm:px-6">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-white">Settings</h1>
|
||||
<p className="text-zinc-500 text-sm mt-1">
|
||||
Manage your preferences and account
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-2xl mx-auto px-4 py-6 sm:px-6">
|
||||
<SettingsForm user={user} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import {
|
||||
ChevronLeft,
|
||||
Trash2,
|
||||
Loader,
|
||||
Pencil,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from "lucide-react";
|
||||
import { WorkoutWithSets } from "@/types";
|
||||
import { formatSetsSummary } from "@/lib/formatSets";
|
||||
|
||||
function buildSetSummary(set: {
|
||||
weight?: number | null;
|
||||
weightUnit?: string | null;
|
||||
reps?: number | null;
|
||||
rpe?: number | null;
|
||||
notes?: string | null;
|
||||
durationSeconds?: number | null;
|
||||
distance?: number | null;
|
||||
calories?: number | null;
|
||||
}) {
|
||||
const parts: string[] = [];
|
||||
if (set.weight) parts.push(`${set.weight} ${set.weightUnit === "kg" ? "kg" : "lbs"}`);
|
||||
if (set.reps) parts.push(`${set.reps} reps`);
|
||||
if ((set as any).durationSeconds) parts.push(`${(set as any).durationSeconds}s`);
|
||||
if ((set as any).distance) parts.push(`${(set as any).distance} mi`);
|
||||
if ((set as any).calories) parts.push(`${(set as any).calories} cal`);
|
||||
if (set.rpe) parts.push(`RPE ${set.rpe}`);
|
||||
if (set.notes) parts.push(set.notes);
|
||||
return parts.length > 0 ? parts.join(" · ") : "No data";
|
||||
}
|
||||
|
||||
export default function WorkoutDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const [workout, setWorkout] = useState<WorkoutWithSets | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [expandedExercise, setExpandedExercise] = useState<string | null>(null);
|
||||
|
||||
const workoutId = params.id as string;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchWorkout = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/workouts/${workoutId}`);
|
||||
if (!response.ok) throw new Error("Failed to fetch");
|
||||
const data = await response.json();
|
||||
setWorkout(data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching workout:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchWorkout();
|
||||
}, [workoutId]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm("Are you sure you want to delete this workout?")) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const response = await fetch(`/api/workouts/${workoutId}`, { method: "DELETE" });
|
||||
if (!response.ok) throw new Error("Failed to delete");
|
||||
router.push("/main/workouts");
|
||||
} catch (error) {
|
||||
console.error("Error deleting workout:", error);
|
||||
alert("Failed to delete workout");
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0A0A0A] flex items-center justify-center">
|
||||
<Loader className="w-8 h-8 animate-spin text-white" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!workout) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0A0A0A]">
|
||||
<div className="max-w-2xl mx-auto px-4 py-6">
|
||||
<Link href="/main/workouts" className="inline-flex items-center gap-2 text-white hover:text-gray-200 mb-4">
|
||||
<ChevronLeft className="w-5 h-5" /> Back
|
||||
</Link>
|
||||
<p className="text-zinc-400">Workout not found</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Format date
|
||||
const date = new Date(workout.date);
|
||||
const formattedDate = date.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
// Group sets by exercise (preserving order)
|
||||
const exerciseGroups: Array<{ exerciseId: string; exerciseName: string; sets: typeof workout.setLogs }> = [];
|
||||
const seen = new Set<string>();
|
||||
for (const set of workout.setLogs) {
|
||||
if (!seen.has(set.exercise.id)) {
|
||||
seen.add(set.exercise.id);
|
||||
exerciseGroups.push({
|
||||
exerciseId: set.exercise.id,
|
||||
exerciseName: set.exercise.name,
|
||||
sets: workout.setLogs.filter((s) => s.exercise.id === set.exercise.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build post-workout stats
|
||||
const stats: string[] = [];
|
||||
if (workout.durationMinutes) stats.push(`${workout.durationMinutes} min`);
|
||||
if (workout.difficulty) stats.push(`Difficulty ${workout.difficulty}/10`);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0A0A0A]">
|
||||
{/* Header */}
|
||||
<div className="border-b border-zinc-800 sticky top-0 z-40 bg-[#0A0A0A]">
|
||||
<div className="max-w-2xl mx-auto px-4 py-4 flex items-center gap-4">
|
||||
<Link href="/main/workouts" className="p-2 hover:bg-zinc-800 rounded-lg -ml-2" aria-label="Back">
|
||||
<ChevronLeft className="w-6 h-6 text-white" />
|
||||
</Link>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-lg font-bold text-white truncate">
|
||||
{workout.name || "Unnamed Workout"}
|
||||
</h1>
|
||||
<p className="text-sm text-zinc-400">{formattedDate}</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/main/workouts/new?edit=${workoutId}`}
|
||||
className="p-2 hover:bg-zinc-800 text-zinc-400 hover:text-white rounded-lg transition-colors"
|
||||
aria-label="Edit workout"
|
||||
>
|
||||
<Pencil className="w-5 h-5" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="p-2 hover:bg-zinc-800 text-red-500 rounded-lg -mr-2 disabled:opacity-50"
|
||||
aria-label="Delete workout"
|
||||
>
|
||||
{deleting ? <Loader className="w-5 h-5 animate-spin" /> : <Trash2 className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-2xl mx-auto px-4 py-4 space-y-2">
|
||||
{/* Exercises — compact collapsible cards */}
|
||||
{exerciseGroups.map((group) => {
|
||||
const isExpanded = expandedExercise === group.exerciseId;
|
||||
const setsWithData = group.sets.filter((s) => s.reps || s.weight);
|
||||
const summary = formatSetsSummary(setsWithData) ||
|
||||
`${group.sets.length} set${group.sets.length !== 1 ? "s" : ""}`;
|
||||
|
||||
return (
|
||||
<div key={group.exerciseId} className="border border-zinc-800 rounded-lg bg-zinc-900">
|
||||
{/* Exercise header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedExercise(isExpanded ? null : group.exerciseId)}
|
||||
className="w-full flex items-center justify-between px-3 py-2.5 hover:bg-zinc-800/50 transition-colors rounded-lg"
|
||||
>
|
||||
<div className="text-left flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h4 className="font-semibold text-white text-sm truncate">
|
||||
{group.exerciseName}
|
||||
</h4>
|
||||
<span className="text-xs text-zinc-500 flex-shrink-0">
|
||||
{group.sets.length} set{group.sets.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
{!isExpanded && setsWithData.length > 0 && (
|
||||
<p className="text-xs text-zinc-400 mt-0.5 truncate">{summary}</p>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-zinc-500 flex-shrink-0 ml-2" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-zinc-500 flex-shrink-0 ml-2" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded sets — same style as locked SetRow */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-zinc-800 p-3 space-y-2">
|
||||
{group.sets.map((set) => (
|
||||
<div key={set.id} className="flex items-center gap-1.5">
|
||||
<div className="bg-white text-black rounded-full w-6 h-6 flex items-center justify-center font-semibold text-xs flex-shrink-0">
|
||||
{set.setNumber}
|
||||
</div>
|
||||
<p className="text-sm text-zinc-300 truncate flex-1">
|
||||
{buildSetSummary(set)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Post-workout info (notes, duration, difficulty) */}
|
||||
{(workout.notes || stats.length > 0) && (
|
||||
<div className="border border-zinc-800 rounded-lg bg-zinc-900 px-3 py-2.5 space-y-1.5">
|
||||
{stats.length > 0 && (
|
||||
<p className="text-xs text-zinc-400">{stats.join(" · ")}</p>
|
||||
)}
|
||||
{workout.notes && (
|
||||
<p className="text-sm text-zinc-300">{workout.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit button at bottom */}
|
||||
<div className="pt-4 pb-8">
|
||||
<Link
|
||||
href={`/main/workouts/new?edit=${workoutId}`}
|
||||
className="w-full py-3 border border-zinc-700 text-white font-semibold rounded-lg hover:bg-zinc-800 flex items-center justify-center gap-2 transition"
|
||||
>
|
||||
<Pencil className="w-5 h-5" />
|
||||
Edit Workout
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { getExercises } from "@/lib/db/exercises";
|
||||
import { getWorkoutById } from "@/lib/db/workouts";
|
||||
import WorkoutForm, { EditWorkoutData } from "@/components/workouts/WorkoutForm";
|
||||
|
||||
export const metadata = {
|
||||
title: "Log Workout",
|
||||
description: "Log a new workout",
|
||||
};
|
||||
|
||||
export default async function NewWorkoutPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { edit?: string };
|
||||
}) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const exercises = await getExercises(user.id);
|
||||
|
||||
// If ?edit=WORKOUT_ID, fetch existing workout for editing
|
||||
let editWorkout: EditWorkoutData | undefined;
|
||||
if (searchParams.edit) {
|
||||
const workout = await getWorkoutById(searchParams.edit);
|
||||
if (workout && workout.userId === user.id) {
|
||||
// Group sets by exercise
|
||||
const grouped: Record<string, EditWorkoutData["exercises"][number]> = {};
|
||||
for (const set of workout.setLogs) {
|
||||
const exId = set.exercise.id;
|
||||
if (!grouped[exId]) {
|
||||
grouped[exId] = {
|
||||
exercise: set.exercise,
|
||||
sets: [],
|
||||
};
|
||||
}
|
||||
grouped[exId].sets.push({
|
||||
setNumber: set.setNumber,
|
||||
reps: set.reps ?? undefined,
|
||||
weight: set.weight ?? undefined,
|
||||
rpe: set.rpe ?? undefined,
|
||||
notes: set.notes ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
editWorkout = {
|
||||
id: workout.id,
|
||||
name: workout.name || "",
|
||||
date: workout.date.toISOString(),
|
||||
durationMinutes: workout.durationMinutes,
|
||||
difficulty: workout.difficulty,
|
||||
caloriesBurned: (workout as any).caloriesBurned ?? null,
|
||||
notes: workout.notes,
|
||||
exercises: Object.values(grouped),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const isEditing = !!editWorkout;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0A0A0A] pb-24 md:pb-8">
|
||||
{/* Header */}
|
||||
<div className="border-b border-zinc-800 sticky top-0 z-40 bg-[#0A0A0A]">
|
||||
<div className="max-w-2xl mx-auto px-4 py-4 flex items-center gap-4">
|
||||
<Link
|
||||
href={isEditing ? `/main/workouts/${editWorkout!.id}` : "/main/workouts"}
|
||||
className="p-2 hover:bg-zinc-900 rounded-lg -ml-2 text-zinc-400 hover:text-white"
|
||||
aria-label="Back"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
</Link>
|
||||
<h1 className="text-2xl font-display text-white tracking-wider">
|
||||
{isEditing ? "Edit Workout" : "Log Workout"}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="max-w-2xl mx-auto px-4 py-6 pb-12">
|
||||
<WorkoutForm
|
||||
exercises={exercises}
|
||||
recentlyUsedExercises={[]}
|
||||
editWorkout={editWorkout}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Plus, Activity, Upload } from "lucide-react";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { getWorkouts } from "@/lib/db/workouts";
|
||||
import WorkoutCard from "@/components/workouts/WorkoutCard";
|
||||
|
||||
interface PageProps {
|
||||
searchParams: { q?: string; dateFrom?: string; dateTo?: string };
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: "Workout History",
|
||||
description: "View your workout history",
|
||||
};
|
||||
|
||||
export default async function WorkoutsPage({ searchParams }: PageProps) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
// Parse search params
|
||||
const query = searchParams.q || "";
|
||||
const dateFrom = searchParams.dateFrom
|
||||
? new Date(searchParams.dateFrom)
|
||||
: undefined;
|
||||
const dateTo = searchParams.dateTo
|
||||
? new Date(searchParams.dateTo)
|
||||
: undefined;
|
||||
|
||||
// Fetch workouts
|
||||
const workouts = await getWorkouts(user.id, {
|
||||
query,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0A0A0A]">
|
||||
{/* Header */}
|
||||
<div className="border-b border-zinc-800 sticky top-0 z-40">
|
||||
<div className="max-w-2xl mx-auto px-4 py-4 sm:py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-white">
|
||||
Workout History
|
||||
</h1>
|
||||
<Link
|
||||
href="/main/import"
|
||||
className="p-2 hover:bg-zinc-900 rounded-lg text-zinc-400 hover:text-white transition"
|
||||
title="Import workouts from CSV"
|
||||
>
|
||||
<Upload className="w-5 h-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-2xl mx-auto px-4 py-6">
|
||||
{/* Search and filters */}
|
||||
<form className="mb-6 space-y-4">
|
||||
{/* Search bar */}
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
placeholder="Search workouts..."
|
||||
defaultValue={query}
|
||||
className="w-full px-4 py-3 border border-zinc-700 bg-zinc-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-white text-white placeholder-zinc-500 text-base"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date range */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-zinc-400 mb-1">From</label>
|
||||
<input
|
||||
type="date"
|
||||
name="dateFrom"
|
||||
defaultValue={searchParams.dateFrom || ""}
|
||||
className="w-full px-4 py-2 border border-zinc-700 bg-zinc-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-white text-white text-base"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-zinc-400 mb-1">To</label>
|
||||
<input
|
||||
type="date"
|
||||
name="dateTo"
|
||||
defaultValue={searchParams.dateTo || ""}
|
||||
className="w-full px-4 py-2 border border-zinc-700 bg-zinc-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-white text-white text-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit buttons */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 bg-white text-black rounded-lg font-medium hover:bg-gray-100 touch-target"
|
||||
>
|
||||
Filter
|
||||
</button>
|
||||
<Link
|
||||
href="/main/workouts"
|
||||
className="flex-1 px-4 py-2 bg-zinc-800 text-white rounded-lg font-medium hover:bg-zinc-700 text-center touch-target"
|
||||
>
|
||||
Clear
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Workout list */}
|
||||
{workouts.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="flex justify-center mb-4">
|
||||
<Activity className="w-12 h-12 text-zinc-600" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white mb-2">
|
||||
No workouts yet
|
||||
</h2>
|
||||
<p className="text-zinc-400 mb-6">
|
||||
{query || dateFrom || dateTo
|
||||
? "No workouts match your filters. Try adjusting your search."
|
||||
: "Start tracking your fitness journey by logging your first workout."}
|
||||
</p>
|
||||
<Link
|
||||
href="/main/workouts/new"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-white text-black rounded-lg font-semibold hover:bg-gray-100 touch-target"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Log Your First Workout
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 pb-20 sm:pb-6">
|
||||
{workouts.map((workout) => (
|
||||
<WorkoutCard key={workout.id} workout={workout} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Floating action button for mobile, regular button for desktop */}
|
||||
{workouts.length > 0 && (
|
||||
<>
|
||||
{/* Mobile FAB */}
|
||||
<div className="fixed bottom-6 right-6 sm:hidden">
|
||||
<Link
|
||||
href="/main/workouts/new"
|
||||
className="flex items-center justify-center w-14 h-14 bg-white text-black rounded-full shadow-lg hover:bg-gray-100 active:bg-gray-200 touch-target"
|
||||
aria-label="Log new workout"
|
||||
>
|
||||
<Plus className="w-6 h-6" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Desktop button */}
|
||||
<div className="hidden sm:block fixed bottom-6 right-6">
|
||||
<Link
|
||||
href="/main/workouts/new"
|
||||
className="flex items-center gap-2 px-6 py-3 bg-white text-black rounded-lg font-semibold hover:bg-gray-100 shadow-lg"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Log Workout
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
redirect('/main/dashboard');
|
||||
}
|
||||
Reference in New Issue
Block a user