1. Authentication Library (src/lib/auth.ts)

2. Authentication Routes
3. Updated Data Store (`src/lib/db.ts`)
4. Updated API Routes
5. Updated Frontend Component (`src/components/BikeBuilderApp.tsx`)
This commit is contained in:
2026-05-19 16:07:57 +02:00
parent 9c212fbf43
commit cb0ff5feb7
15 changed files with 8127 additions and 7521 deletions
+7218 -7189
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -11,6 +11,8 @@
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"bcryptjs": "^3.0.3",
"jose": "^6.2.3",
"next": "^16.2.6", "next": "^16.2.6",
"postcss": "^8.5.10", "postcss": "^8.5.10",
"prisma": "^5.22.0", "prisma": "^5.22.0",
@@ -20,6 +22,7 @@
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20.17.6", "@types/node": "^20.17.6",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
+19 -1
View File
@@ -8,6 +8,18 @@ datasource db {
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
builds BikeBuild[]
reviews RideReview[]
preference RiderPreference?
}
model Product { model Product {
id String @id id String @id
category String category String
@@ -36,6 +48,8 @@ model BikeBuild {
rearTireId String? rearTireId String?
setup String setup String
imagePath String? imagePath String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
reviews RideReview[] reviews RideReview[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -45,6 +59,8 @@ model RideReview {
id String @id @default(cuid()) id String @id @default(cuid())
buildId String buildId String
build BikeBuild @relation(fields: [buildId], references: [id], onDelete: Cascade) build BikeBuild @relation(fields: [buildId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
suspFeel Int suspFeel Int
frontGrip Int frontGrip Int
rearGrip Int rearGrip Int
@@ -60,7 +76,9 @@ model RideReview {
} }
model RiderPreference { model RiderPreference {
id String @id @default("default") id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
harshTendency Int @default(0) harshTendency Int @default(0)
softTendency Int @default(0) softTendency Int @default(0)
gripPreference Int @default(0) gripPreference Int @default(0)
+29 -15
View File
@@ -4,21 +4,35 @@ import { frames, forks, shocks, tires } from "../src/lib/catalog";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
async function main() { async function main() {
const products = [...frames, ...forks, ...shocks, ...tires]; const products = [...frames, ...forks, ...shocks, ...tires];
for (const product of products) { for (const product of products) {
const { id, category, brand, model, imagePath, sources } = product; const { id, category, brand, model, imagePath, sources } = product;
const position = "position" in product ? product.position : null; const position = "position" in product ? product.position : null;
await prisma.product.upsert({ await prisma.product.upsert({
where: { id }, where: { id },
update: { category, position, brand, model, imagePath, specs: JSON.stringify(product), setup: JSON.stringify(product), sources: JSON.stringify(sources) }, update: {
create: { id, category, position, brand, model, imagePath, specs: JSON.stringify(product), setup: JSON.stringify(product), sources: JSON.stringify(sources) }, category,
}); position,
} brand,
await prisma.riderPreference.upsert({ model,
where: { id: "default" }, imagePath,
update: {}, specs: JSON.stringify(product),
create: { id: "default" }, setup: JSON.stringify(product),
}); sources: JSON.stringify(sources),
},
create: {
id,
category,
position,
brand,
model,
imagePath,
specs: JSON.stringify(product),
setup: JSON.stringify(product),
sources: JSON.stringify(sources),
},
});
}
} }
main().finally(async () => prisma.$disconnect()); main().finally(async () => prisma.$disconnect());
+61 -52
View File
@@ -1,66 +1,75 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { requireAuth } from "@/lib/auth";
import { store } from "@/lib/db"; import { store } from "@/lib/db";
import { selectedProducts } from "@/lib/recommendations"; import { selectedProducts } from "@/lib/recommendations";
import { setupVerification } from "@/lib/setupVerification"; import { setupVerification } from "@/lib/setupVerification";
import type { Product } from "@/lib/types"; import type { Product } from "@/lib/types";
const schema = z.object({ const schema = z.object({
rider: z.object({ weight: z.number(), discipline: z.string(), level: z.string(), style: z.string() }), rider: z.object({ weight: z.number(), discipline: z.string(), level: z.string(), style: z.string() }),
selection: z.object({ selection: z.object({
name: z.string(), name: z.string(),
frameId: z.string().optional(), frameId: z.string().optional(),
forkId: z.string().optional(), forkId: z.string().optional(),
shockId: z.string().optional(), shockId: z.string().optional(),
frontTireId: z.string().optional(), frontTireId: z.string().optional(),
rearTireId: z.string().optional(), rearTireId: z.string().optional(),
}), }),
setup: z.record(z.unknown()).optional(), setup: z.record(z.unknown()).optional(),
review: z.record(z.unknown()).optional(), review: z.record(z.unknown()).optional(),
}); });
export async function POST(request: Request) { export async function POST(request: Request) {
const body = schema.parse(await request.json()); const user = await requireAuth();
const products = selectedProducts(body.selection); const body = schema.parse(await request.json());
const evidence = Object.values(products) const products = selectedProducts(body.selection);
.filter((product): product is Product => Boolean(product)) const evidence = Object.values(products)
.map((product) => setupVerification[product.id]) .filter((product): product is Product => Boolean(product))
.filter(Boolean); .map((product) => setupVerification[product.id])
const prefs = await store.preferences(); .filter(Boolean);
const prompt = [ const prefs = await store.preferences(user.id);
"You are a professional mountain bike suspension and tire setup tuner.", const prompt = [
"Combine manufacturer setup ranges, community setup patterns, and the rider's past preferences.", "You are a professional mountain bike suspension and tire setup tuner.",
"Return concise, actionable settings with exact changes where possible.", "Combine manufacturer setup ranges, community setup patterns, and the rider's past preferences.",
`Rider: ${body.rider.weight}kg, ${body.rider.level}, ${body.rider.style}, ${body.rider.discipline}.`, "Return concise, actionable settings with exact changes where possible.",
`Frame: ${products.frame ? `${products.frame.brand} ${products.frame.model}` : "none"}.`, `Rider: ${body.rider.weight}kg, ${body.rider.level}, ${body.rider.style}, ${body.rider.discipline}.`,
`Fork: ${products.fork ? `${products.fork.brand} ${products.fork.model}` : "none"}.`, `Frame: ${products.frame ? `${products.frame.brand} ${products.frame.model}` : "none"}.`,
`Shock: ${products.shock ? `${products.shock.brand} ${products.shock.model}` : "none"}.`, `Fork: ${products.fork ? `${products.fork.brand} ${products.fork.model}` : "none"}.`,
`Front tire: ${products.frontTire ? `${products.frontTire.brand} ${products.frontTire.model}` : "none"}.`, `Shock: ${products.shock ? `${products.shock.brand} ${products.shock.model}` : "none"}.`,
`Rear tire: ${products.rearTire ? `${products.rearTire.brand} ${products.rearTire.model}` : "none"}.`, `Front tire: ${products.frontTire ? `${products.frontTire.brand} ${products.frontTire.model}` : "none"}.`,
`Current setup: ${JSON.stringify(body.setup ?? {})}.`, `Rear tire: ${products.rearTire ? `${products.rearTire.brand} ${products.rearTire.model}` : "none"}.`,
`Post ride review: ${JSON.stringify(body.review ?? {})}.`, `Current setup: ${JSON.stringify(body.setup ?? {})}.`,
`Learned preferences: ${JSON.stringify(prefs ?? {})}.`, `Post ride review: ${JSON.stringify(body.review ?? {})}.`,
`Setup evidence: ${JSON.stringify(evidence)}.`, `Learned preferences (from this user's history): ${JSON.stringify(prefs ?? {})}.`,
].join("\n"); `Setup evidence: ${JSON.stringify(evidence)}.`,
].join("\n");
const ollamaUrl = process.env.OLLAMA_URL ?? "http://localhost:11434"; const ollamaUrl = process.env.OLLAMA_URL ?? "http://localhost:11434";
const model = process.env.OLLAMA_MODEL ?? "llama3.1:8b"; const model = process.env.OLLAMA_MODEL ?? "llama3.1:8b";
const response = await fetch(`${ollamaUrl}/api/chat`, { const response = await fetch(`${ollamaUrl}/api/chat`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
model, model,
stream: false, stream: false,
messages: [ messages: [
{ role: "system", content: "Use metric units plus PSI/clicks. Structure output as Fork, Shock, Tires, Rationale, Next Ride." }, {
{ role: "user", content: prompt }, role: "system",
], content:
}), "Use metric units plus PSI/clicks. Structure output as Fork, Shock, Tires, Rationale, Next Ride.",
}); },
{ role: "user", content: prompt },
],
}),
});
if (!response.ok) { if (!response.ok) {
return NextResponse.json({ error: `Ollama returned ${response.status}. Is ${model} pulled and Ollama running?` }, { status: 502 }); return NextResponse.json(
} { error: `Ollama returned ${response.status}. Is ${model} pulled and Ollama running?` },
const data = await response.json(); { status: 502 },
return NextResponse.json({ model, recommendation: data.message?.content ?? data.response ?? "" }); );
}
const data = await response.json();
return NextResponse.json({ model, recommendation: data.message?.content ?? data.response ?? "" });
} }
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { findUserByEmail, verifyPassword, createToken, setSessionCookie } from "@/lib/auth";
const schema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
export async function POST(request: Request) {
const body = schema.parse(await request.json());
const user = await findUserByEmail(body.email);
if (!user) {
return NextResponse.json({ error: "Invalid email or password" }, { status: 401 });
}
const valid = await verifyPassword(body.password, user.passwordHash);
if (!valid) {
return NextResponse.json({ error: "Invalid email or password" }, { status: 401 });
}
const token = await createToken({ id: user.id, email: user.email, name: user.name });
await setSessionCookie(token);
return NextResponse.json({ user: { id: user.id, email: user.email, name: user.name } });
}
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { clearSessionCookie } from "@/lib/auth";
export async function POST() {
await clearSessionCookie();
return NextResponse.json({ ok: true });
}
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
export async function GET() {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ user: null });
return NextResponse.json({ user });
}
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { createUser, findUserByEmail, createToken, setSessionCookie } from "@/lib/auth";
const schema = z.object({
email: z.string().email(),
password: z.string().min(6),
name: z.string().optional(),
});
export async function POST(request: Request) {
const body = schema.parse(await request.json());
const existing = await findUserByEmail(body.email);
if (existing) {
return NextResponse.json({ error: "Email already registered" }, { status: 409 });
}
const user = await createUser(body.email, body.password, body.name);
const token = await createToken(user);
await setSessionCookie(token);
return NextResponse.json({ user }, { status: 201 });
}
+46 -40
View File
@@ -1,53 +1,59 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { requireAuth } from "@/lib/auth";
import { store } from "@/lib/db"; import { store } from "@/lib/db";
import { buildSetupDefaults } from "@/lib/recommendations"; import { buildSetupDefaults } from "@/lib/recommendations";
const schema = z.object({ const schema = z.object({
name: z.string().min(1), name: z.string().min(1),
rider: z.object({ rider: z.object({
weight: z.number().min(35).max(160), weight: z.number().min(35).max(160),
discipline: z.string(), discipline: z.string(),
level: z.string(), level: z.string(),
style: z.string(), style: z.string(),
}), }),
frameId: z.string().optional(), frameId: z.string().optional(),
forkId: z.string().optional(), forkId: z.string().optional(),
shockId: z.string().optional(), shockId: z.string().optional(),
frontTireId: z.string().optional(), frontTireId: z.string().optional(),
rearTireId: z.string().optional(), rearTireId: z.string().optional(),
setup: z.record(z.unknown()).optional(), setup: z.record(z.unknown()).optional(),
imagePath: z.string().optional(), imagePath: z.string().optional(),
}); });
export async function GET() { export async function GET() {
return NextResponse.json(await store.listBuilds()); const user = await requireAuth();
return NextResponse.json(await store.listBuilds(user.id));
} }
export async function POST(request: Request) { export async function POST(request: Request) {
const body = schema.parse(await request.json()); const user = await requireAuth();
const selection = { const body = schema.parse(await request.json());
name: body.name, const selection = {
frameId: body.frameId, name: body.name,
forkId: body.forkId, frameId: body.frameId,
shockId: body.shockId, forkId: body.forkId,
frontTireId: body.frontTireId, shockId: body.shockId,
rearTireId: body.rearTireId, frontTireId: body.frontTireId,
}; rearTireId: body.rearTireId,
const setup = { ...buildSetupDefaults(selection, body.rider), ...body.setup }; };
const build = await store.createBuild({ const setup = { ...buildSetupDefaults(selection, body.rider), ...body.setup };
name: body.name, const build = await store.createBuild(
riderWeightKg: body.rider.weight, {
discipline: body.rider.discipline, name: body.name,
riderLevel: body.rider.level, riderWeightKg: body.rider.weight,
rideStyle: body.rider.style, discipline: body.rider.discipline,
frameId: body.frameId, riderLevel: body.rider.level,
forkId: body.forkId, rideStyle: body.rider.style,
shockId: body.shockId, frameId: body.frameId,
frontTireId: body.frontTireId, forkId: body.forkId,
rearTireId: body.rearTireId, shockId: body.shockId,
setup, frontTireId: body.frontTireId,
imagePath: body.imagePath, rearTireId: body.rearTireId,
}); setup,
return NextResponse.json(build, { status: 201 }); imagePath: body.imagePath,
},
user.id,
);
return NextResponse.json(build, { status: 201 });
} }
+17 -15
View File
@@ -1,24 +1,26 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { requireAuth } from "@/lib/auth";
import { store } from "@/lib/db"; import { store } from "@/lib/db";
const schema = z.object({ const schema = z.object({
buildId: z.string(), buildId: z.string(),
suspFeel: z.number().min(1).max(10), suspFeel: z.number().min(1).max(10),
frontGrip: z.number().min(1).max(10), frontGrip: z.number().min(1).max(10),
rearGrip: z.number().min(1).max(10), rearGrip: z.number().min(1).max(10),
rollSpeed: z.number().min(1).max(10), rollSpeed: z.number().min(1).max(10),
confidence: z.number().min(1).max(10), confidence: z.number().min(1).max(10),
bottomOut: z.boolean().default(false), bottomOut: z.boolean().default(false),
harshHit: z.boolean().default(false), harshHit: z.boolean().default(false),
wallow: z.boolean().default(false), wallow: z.boolean().default(false),
smallBump: z.boolean().default(false), smallBump: z.boolean().default(false),
notes: z.string().optional(), notes: z.string().optional(),
advice: z.string().optional(), advice: z.string().optional(),
}); });
export async function POST(request: Request) { export async function POST(request: Request) {
const body = schema.parse(await request.json()); const user = await requireAuth();
const review = await store.createReview(body); const body = schema.parse(await request.json());
return NextResponse.json(review, { status: 201 }); const review = await store.createReview(body, user.id);
return NextResponse.json(review, { status: 201 });
} }
+557 -202
View File
@@ -2,245 +2,600 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { catalog } from "@/lib/catalog"; import { catalog } from "@/lib/catalog";
import { buildSetupDefaults, disciplines, isRecommended, riderLevels, rideStyles, selectedProducts } from "@/lib/recommendations"; import {
buildSetupDefaults,
disciplines,
isRecommended,
riderLevels,
rideStyles,
selectedProducts,
} from "@/lib/recommendations";
import { layerPaths } from "@/lib/layers"; import { layerPaths } from "@/lib/layers";
import type { BuildSelection, BuildSetup, Product, RiderProfile } from "@/lib/types"; import type { BuildSelection, BuildSetup, Product, RiderProfile } from "@/lib/types";
type Tab = "build" | "setup" | "review" | "garage" | "ai"; type Tab = "build" | "setup" | "review" | "garage" | "ai";
type AuthMode = "login" | "register";
type User = { id: string; email: string; name: string | null };
const emptySelection: BuildSelection = { name: "" }; const emptySelection: BuildSelection = { name: "" };
const emptyRider: RiderProfile = { weight: 78, discipline: "Trail", level: "Intermediate", style: "Mixed" }; const emptyRider: RiderProfile = { weight: 78, discipline: "Trail", level: "Intermediate", style: "Mixed" };
export default function BikeBuilderApp() { export default function BikeBuilderApp() {
const [tab, setTab] = useState<Tab>("build"); const [user, setUser] = useState<User | null>(null);
const [rider, setRider] = useState<RiderProfile>(emptyRider); const [authChecked, setAuthChecked] = useState(false);
const [selection, setSelection] = useState<BuildSelection>(emptySelection);
const defaults = useMemo(() => buildSetupDefaults(selection, rider), [selection, rider]);
const [setup, setSetup] = useState<Partial<BuildSetup>>({});
const [builds, setBuilds] = useState<any[]>([]);
const [review, setReview] = useState({ buildId: "", suspFeel: 5, frontGrip: 7, rearGrip: 7, rollSpeed: 6, confidence: 7, bottomOut: false, harshHit: false, wallow: false, smallBump: false, notes: "" });
const [aiOut, setAiOut] = useState("");
const [loading, setLoading] = useState("");
const selected = selectedProducts(selection);
const mergedSetup = { ...defaults, ...setup };
useEffect(() => { useEffect(() => {
fetch("/api/builds") fetch("/api/auth/me")
.then((response) => response.json()) .then((r) => r.json())
.then((data) => setBuilds(Array.isArray(data) ? data : [])) .then((data) => setUser(data.user))
.catch(() => setBuilds([])); .finally(() => setAuthChecked(true));
}, []); }, []);
function choose(key: keyof BuildSelection, id: string) { if (!authChecked) {
setSelection((current) => ({ ...current, [key]: current[key] === id ? undefined : id })); return (
} <main className="app">
<div className="notice" style={{ marginTop: 40 }}>
Loading...
</div>
</main>
);
}
async function saveBuild() { if (!user) {
if (!selection.name.trim()) return; return <AuthScreen onAuth={(u) => setUser(u)} />;
setLoading("Saving build"); }
const image = await fetch("/api/images/compose", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(selection) }).then((r) => r.json());
const saved = await fetch("/api/builds", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...selection, rider, setup: mergedSetup, imagePath: image.imagePath }),
}).then((r) => r.json());
setBuilds((items) => [saved, ...items]);
setLoading("");
setTab("garage");
}
async function getAiRecommendation(extraReview?: typeof review) { return <AppContent user={user} onLogout={() => setUser(null)} />;
setLoading("Querying Ollama"); }
setAiOut("");
const result = await fetch("/api/ai/recommend", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rider, selection, setup: mergedSetup, review: extraReview }),
}).then((r) => r.json());
setAiOut(result.recommendation ?? result.error ?? "No recommendation returned.");
setLoading("");
}
async function saveReview() { function AuthScreen({ onAuth }: { onAuth: (user: User) => void }) {
if (!review.buildId) return; const [mode, setMode] = useState<AuthMode>("login");
setLoading("Saving review"); const [email, setEmail] = useState("");
await fetch("/api/reviews", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(review) }); const [password, setPassword] = useState("");
await getAiRecommendation(review); const [name, setName] = useState("");
} const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
return ( async function submit(e: React.FormEvent) {
<main className="app"> e.preventDefault();
<header className="header"> setError("");
<div> setLoading(true);
<div className="brand">Loam</div> const endpoint = mode === "login" ? "/api/auth/login" : "/api/auth/register";
<div className="subtitle">MTB Build & Tune</div> const body: any = { email, password };
</div> if (mode === "register") body.name = name;
<div className="subtitle">Next.js web + Node API for Android reuse</div> const res = await fetch(endpoint, {
</header> method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({ error: "Unknown error" }));
setLoading(false);
if (!res.ok) {
setError(data.error || `${mode} failed`);
return;
}
onAuth(data.user);
}
<nav className="tabs"> return (
{(["build", "setup", "review", "garage", "ai"] as Tab[]).map((item) => ( <main className="app">
<button key={item} className={`tab ${tab === item ? "active" : ""}`} onClick={() => setTab(item)}>{item}</button> <header className="header">
))} <div>
</nav> <div className="brand">Loam</div>
<div className="subtitle">MTB Build & Tune</div>
{tab === "build" && ( </div>
<> </header>
<RiderProfileForm rider={rider} setRider={setRider} /> <div style={{ maxWidth: 360, margin: "40px auto" }}>
<BikeLayerViewer selection={selection} /> <div className="tabs">
<div className="row"> <button
<div className="field"> className={`tab ${mode === "login" ? "active" : ""}`}
<label>Build name</label> onClick={() => {
<input value={selection.name} onChange={(e) => setSelection((current) => ({ ...current, name: e.target.value }))} placeholder="Scouty wet trail setup" /> setMode("login");
setError("");
}}
>
Log in
</button>
<button
className={`tab ${mode === "register" ? "active" : ""}`}
onClick={() => {
setMode("register");
setError("");
}}
>
Register
</button>
</div>
<form onSubmit={submit} style={{ display: "grid", gap: 12, marginTop: 16 }}>
{mode === "register" && (
<div className="field">
<label>Name</label>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Rider name" />
</div>
)}
<div className="field">
<label>Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
/>
</div>
<div className="field">
<label>Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min 6 characters"
required
minLength={mode === "register" ? 6 : undefined}
/>
</div>
{error && (
<div className="notice" style={{ color: "#ef4444" }}>
{error}
</div>
)}
<button className="btn primary" type="submit" disabled={loading}>
{loading ? "Please wait..." : mode === "login" ? "Log in" : "Create account"}
</button>
</form>
</div> </div>
<button className="btn primary" onClick={saveBuild} disabled={!selection.name || Boolean(loading)}>{loading || "Save build"}</button> </main>
</div> );
<ProductSection title="Frame" products={catalog.frames} selectedId={selection.frameId} onPick={(id) => choose("frameId", id)} rider={rider} /> }
<ProductSection title="Fork" products={catalog.forks} selectedId={selection.forkId} onPick={(id) => choose("forkId", id)} rider={rider} />
<ProductSection title="Rear shock" products={catalog.shocks} selectedId={selection.shockId} onPick={(id) => choose("shockId", id)} rider={rider} />
<ProductSection title="Front tire" products={catalog.tires.filter((t) => t.position === "front")} selectedId={selection.frontTireId} onPick={(id) => choose("frontTireId", id)} rider={rider} />
<ProductSection title="Rear tire" products={catalog.tires.filter((t) => t.position === "rear")} selectedId={selection.rearTireId} onPick={(id) => choose("rearTireId", id)} rider={rider} />
</>
)}
{tab === "setup" && ( function AppContent({ user, onLogout }: { user: User; onLogout: () => void }) {
<> const [tab, setTab] = useState<Tab>("build");
<div className="notice">Recommended values are shown as placeholders and come from the shared setup database plus rider profile rules. Edit values before saving if your real setup differs.</div> const [rider, setRider] = useState<RiderProfile>(emptyRider);
<div className="section-title">Suspension and tires</div> const [selection, setSelection] = useState<BuildSelection>(emptySelection);
<div className="setup-grid"> const defaults = useMemo(() => buildSetupDefaults(selection, rider), [selection, rider]);
{Object.entries(mergedSetup).filter(([key]) => key !== "notes").map(([key, value]) => ( const [setup, setSetup] = useState<Partial<BuildSetup>>({});
<div className="field" key={key}> const [builds, setBuilds] = useState<any[]>([]);
<label>{labelFor(key)}</label> const [review, setReview] = useState({
<input value={(setup as any)[key] ?? ""} placeholder={String(value ?? "")} onChange={(e) => setSetup((current) => ({ ...current, [key]: numericOrText(e.target.value) }))} /> buildId: "",
</div> suspFeel: 5,
))} frontGrip: 7,
</div> rearGrip: 7,
<div className="field" style={{ marginTop: 12 }}> rollSpeed: 6,
<label>Setup notes</label> confidence: 7,
<textarea value={setup.notes ?? ""} onChange={(e) => setSetup((current) => ({ ...current, notes: e.target.value }))} placeholder="Trail conditions, bracketing notes, clicks changed..." /> bottomOut: false,
</div> harshHit: false,
</> wallow: false,
)} smallBump: false,
notes: "",
});
const [aiOut, setAiOut] = useState("");
const [loading, setLoading] = useState("");
const selected = selectedProducts(selection);
const mergedSetup = { ...defaults, ...setup };
{tab === "review" && ( useEffect(() => {
<> fetch("/api/builds")
<div className="notice">Post-ride reviews are saved to the backend. The preference counters bias future Ollama recommendations.</div> .then((response) => {
<div className="field" style={{ marginTop: 12 }}> if (response.status === 401) return [];
<label>Build</label> return response.json();
<select value={review.buildId} onChange={(e) => setReview((current) => ({ ...current, buildId: e.target.value }))}> })
<option value="">Choose saved build</option> .then((data) => setBuilds(Array.isArray(data) ? data : []))
{builds.map((build) => <option key={build.id} value={build.id}>{build.name}</option>)} .catch(() => setBuilds([]));
</select> }, []);
</div>
{["suspFeel", "frontGrip", "rearGrip", "rollSpeed", "confidence"].map((key) => (
<Range key={key} label={labelFor(key)} value={(review as any)[key]} onChange={(value) => setReview((current) => ({ ...current, [key]: value }))} />
))}
<div className="grid">
{["bottomOut", "harshHit", "wallow", "smallBump"].map((key) => (
<label className="card" key={key}>
<input type="checkbox" checked={(review as any)[key]} onChange={(e) => setReview((current) => ({ ...current, [key]: e.target.checked }))} /> {labelFor(key)}
</label>
))}
</div>
<div className="field" style={{ marginTop: 12 }}>
<label>Ride notes</label>
<textarea value={review.notes} onChange={(e) => setReview((current) => ({ ...current, notes: e.target.value }))} />
</div>
<button className="btn primary" style={{ marginTop: 12 }} onClick={saveReview} disabled={!review.buildId || Boolean(loading)}>{loading || "Save review and recommend changes"}</button>
{aiOut && <div className="output">{aiOut}</div>}
</>
)}
{tab === "garage" && ( function choose(key: keyof BuildSelection, id: string) {
<div className="build-list"> setSelection((current) => ({ ...current, [key]: current[key] === id ? undefined : id }));
{builds.length === 0 && <div className="notice">No builds saved in this browser session yet. The API persists builds in SQLite once the database is initialized.</div>} }
{builds.map((build) => (
<button className="card" key={build.id} onClick={() => setSelection({ name: build.name, frameId: build.frameId, forkId: build.forkId, shockId: build.shockId, frontTireId: build.frontTireId, rearTireId: build.rearTireId })}>
<div className="model">{build.name}</div>
<div className="brand-small">{build.discipline} / {build.riderWeightKg}kg / {build.riderLevel}</div>
{build.imagePath && <img src={build.imagePath} alt="" style={{ width: "100%", marginTop: 8 }} />}
</button>
))}
</div>
)}
{tab === "ai" && ( async function saveBuild() {
<> if (!selection.name.trim()) return;
<div className="notice">Recommended local model: <b>llama3.1:8b</b>. It is a good first choice for structured tuning advice on normal developer hardware. Use <b>qwen2.5:7b</b> if you prefer stronger technical reasoning, or a smaller model only if latency matters more than advice quality.</div> setLoading("Saving build");
<div className="section-title">Current build</div> const image = await fetch("/api/images/compose", {
<div className="stat"> method: "POST",
<b>{selection.name || "Unsaved build"}</b> headers: { "Content-Type": "application/json" },
<p>{[selected.frame, selected.fork, selected.shock, selected.frontTire, selected.rearTire].filter(Boolean).map((item) => `${item!.brand} ${item!.model}`).join(" / ") || "No components selected"}</p> body: JSON.stringify(selection),
</div> }).then((r) => r.json());
<button className="btn primary" style={{ marginTop: 12 }} onClick={() => getAiRecommendation()} disabled={Boolean(loading)}>{loading || "Generate Ollama recommendation"}</button> const saved = await fetch("/api/builds", {
{aiOut && <div className="output">{aiOut}</div>} method: "POST",
</> headers: { "Content-Type": "application/json" },
)} body: JSON.stringify({ ...selection, rider, setup: mergedSetup, imagePath: image.imagePath }),
</main> }).then((r) => {
); if (r.status === 401) throw new Error("Session expired. Please log in again.");
return r.json();
});
setBuilds((items) => [saved, ...items]);
setLoading("");
setTab("garage");
}
async function getAiRecommendation(extraReview?: typeof review) {
setLoading("Querying Ollama");
setAiOut("");
const result = await fetch("/api/ai/recommend", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rider, selection, setup: mergedSetup, review: extraReview }),
}).then((r) => {
if (r.status === 401) return { error: "Session expired. Please log in again." };
return r.json();
});
setAiOut(result.recommendation ?? result.error ?? "No recommendation returned.");
setLoading("");
}
async function saveReview() {
if (!review.buildId) return;
setLoading("Saving review");
await fetch("/api/reviews", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(review),
}).then((r) => {
if (r.status === 401) throw new Error("Session expired. Please log in again.");
});
await getAiRecommendation(review);
}
return (
<main className="app">
<header className="header">
<div>
<div className="brand">Loam</div>
<div className="subtitle">MTB Build & Tune</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<span className="subtitle">{user.name || user.email}</span>
<button
className="btn"
onClick={() => {
fetch("/api/auth/logout", { method: "POST" });
onLogout();
}}
>
Log out
</button>
</div>
</header>
<nav className="tabs">
{(["build", "setup", "review", "garage", "ai"] as Tab[]).map((item) => (
<button key={item} className={`tab ${tab === item ? "active" : ""}`} onClick={() => setTab(item)}>
{item}
</button>
))}
</nav>
{tab === "build" && (
<>
<RiderProfileForm rider={rider} setRider={setRider} />
<BikeLayerViewer selection={selection} />
<div className="row">
<div className="field">
<label>Build name</label>
<input
value={selection.name}
onChange={(e) => setSelection((current) => ({ ...current, name: e.target.value }))}
placeholder="Scouty wet trail setup"
/>
</div>
<button
className="btn primary"
onClick={saveBuild}
disabled={!selection.name || Boolean(loading)}
>
{loading || "Save build"}
</button>
</div>
<ProductSection
title="Frame"
products={catalog.frames}
selectedId={selection.frameId}
onPick={(id) => choose("frameId", id)}
rider={rider}
/>
<ProductSection
title="Fork"
products={catalog.forks}
selectedId={selection.forkId}
onPick={(id) => choose("forkId", id)}
rider={rider}
/>
<ProductSection
title="Rear shock"
products={catalog.shocks}
selectedId={selection.shockId}
onPick={(id) => choose("shockId", id)}
rider={rider}
/>
<ProductSection
title="Front tire"
products={catalog.tires.filter((t) => t.position === "front")}
selectedId={selection.frontTireId}
onPick={(id) => choose("frontTireId", id)}
rider={rider}
/>
<ProductSection
title="Rear tire"
products={catalog.tires.filter((t) => t.position === "rear")}
selectedId={selection.rearTireId}
onPick={(id) => choose("rearTireId", id)}
rider={rider}
/>
</>
)}
{tab === "setup" && (
<>
<div className="notice">
Recommended values are shown as placeholders and come from the shared setup database plus rider
profile rules. Edit values before saving if your real setup differs.
</div>
<div className="section-title">Suspension and tires</div>
<div className="setup-grid">
{Object.entries(mergedSetup)
.filter(([key]) => key !== "notes")
.map(([key, value]) => (
<div className="field" key={key}>
<label>{labelFor(key)}</label>
<input
value={(setup as any)[key] ?? ""}
placeholder={String(value ?? "")}
onChange={(e) =>
setSetup((current) => ({
...current,
[key]: numericOrText(e.target.value),
}))
}
/>
</div>
))}
</div>
<div className="field" style={{ marginTop: 12 }}>
<label>Setup notes</label>
<textarea
value={setup.notes ?? ""}
onChange={(e) => setSetup((current) => ({ ...current, notes: e.target.value }))}
placeholder="Trail conditions, bracketing notes, clicks changed..."
/>
</div>
</>
)}
{tab === "review" && (
<>
<div className="notice">
Post-ride reviews are saved to the backend and scoped to your account. Preferences learned here
bias your future Ollama recommendations.
</div>
<div className="field" style={{ marginTop: 12 }}>
<label>Build</label>
<select
value={review.buildId}
onChange={(e) => setReview((current) => ({ ...current, buildId: e.target.value }))}
>
<option value="">Choose saved build</option>
{builds.map((build) => (
<option key={build.id} value={build.id}>
{build.name}
</option>
))}
</select>
</div>
{["suspFeel", "frontGrip", "rearGrip", "rollSpeed", "confidence"].map((key) => (
<Range
key={key}
label={labelFor(key)}
value={(review as any)[key]}
onChange={(value) => setReview((current) => ({ ...current, [key]: value }))}
/>
))}
<div className="grid">
{["bottomOut", "harshHit", "wallow", "smallBump"].map((key) => (
<label className="card" key={key}>
<input
type="checkbox"
checked={(review as any)[key]}
onChange={(e) => setReview((current) => ({ ...current, [key]: e.target.checked }))}
/>{" "}
{labelFor(key)}
</label>
))}
</div>
<div className="field" style={{ marginTop: 12 }}>
<label>Ride notes</label>
<textarea
value={review.notes}
onChange={(e) => setReview((current) => ({ ...current, notes: e.target.value }))}
/>
</div>
<button
className="btn primary"
style={{ marginTop: 12 }}
onClick={saveReview}
disabled={!review.buildId || Boolean(loading)}
>
{loading || "Save review and recommend changes"}
</button>
{aiOut && <div className="output">{aiOut}</div>}
</>
)}
{tab === "garage" && (
<div className="build-list">
{builds.length === 0 && (
<div className="notice">No builds saved yet. Save a build to see it here.</div>
)}
{builds.map((build) => (
<button
className="card"
key={build.id}
onClick={() =>
setSelection({
name: build.name,
frameId: build.frameId,
forkId: build.forkId,
shockId: build.shockId,
frontTireId: build.frontTireId,
rearTireId: build.rearTireId,
})
}
>
<div className="model">{build.name}</div>
<div className="brand-small">
{build.discipline} / {build.riderWeightKg}kg / {build.riderLevel}
</div>
{build.imagePath && (
<img src={build.imagePath} alt="" style={{ width: "100%", marginTop: 8 }} />
)}
</button>
))}
</div>
)}
{tab === "ai" && (
<>
<div className="notice">
Recommended local model: <b>llama3.1:8b</b>. It is a good first choice for structured tuning
advice on normal developer hardware. Use <b>qwen2.5:7b</b> if you prefer stronger technical
reasoning, or a smaller model only if latency matters more than advice quality.
</div>
<div className="section-title">Current build</div>
<div className="stat">
<b>{selection.name || "Unsaved build"}</b>
<p>
{[selected.frame, selected.fork, selected.shock, selected.frontTire, selected.rearTire]
.filter(Boolean)
.map((item) => `${item!.brand} ${item!.model}`)
.join(" / ") || "No components selected"}
</p>
</div>
<button
className="btn primary"
style={{ marginTop: 12 }}
onClick={() => getAiRecommendation()}
disabled={Boolean(loading)}
>
{loading || "Generate Ollama recommendation"}
</button>
{aiOut && <div className="output">{aiOut}</div>}
</>
)}
</main>
);
} }
function RiderProfileForm({ rider, setRider }: { rider: RiderProfile; setRider: (rider: RiderProfile) => void }) { function RiderProfileForm({ rider, setRider }: { rider: RiderProfile; setRider: (rider: RiderProfile) => void }) {
return ( return (
<div className="profile"> <div className="profile">
<div className="field"><label>Weight kg</label><input type="number" value={rider.weight} onChange={(e) => setRider({ ...rider, weight: Number(e.target.value) })} /></div> <div className="field">
<div className="field"><label>Discipline</label><select value={rider.discipline} onChange={(e) => setRider({ ...rider, discipline: e.target.value })}>{disciplines.map((x) => <option key={x}>{x}</option>)}</select></div> <label>Weight kg</label>
<div className="field"><label>Level</label><select value={rider.level} onChange={(e) => setRider({ ...rider, level: e.target.value })}>{riderLevels.map((x) => <option key={x}>{x}</option>)}</select></div> <input
<div className="field"><label>Style</label><select value={rider.style} onChange={(e) => setRider({ ...rider, style: e.target.value })}>{rideStyles.map((x) => <option key={x}>{x}</option>)}</select></div> type="number"
</div> value={rider.weight}
); onChange={(e) => setRider({ ...rider, weight: Number(e.target.value) })}
/>
</div>
<div className="field">
<label>Discipline</label>
<select value={rider.discipline} onChange={(e) => setRider({ ...rider, discipline: e.target.value })}>
{disciplines.map((x) => (
<option key={x}>{x}</option>
))}
</select>
</div>
<div className="field">
<label>Level</label>
<select value={rider.level} onChange={(e) => setRider({ ...rider, level: e.target.value })}>
{riderLevels.map((x) => (
<option key={x}>{x}</option>
))}
</select>
</div>
<div className="field">
<label>Style</label>
<select value={rider.style} onChange={(e) => setRider({ ...rider, style: e.target.value })}>
{rideStyles.map((x) => (
<option key={x}>{x}</option>
))}
</select>
</div>
</div>
);
} }
function BikeLayerViewer({ selection }: { selection: BuildSelection }) { function BikeLayerViewer({ selection }: { selection: BuildSelection }) {
const layers = layerPaths(selection); const layers = layerPaths(selection);
return ( return (
<div className="viewer"> <div className="viewer">
{layers.length === 0 && <div className="viewer-empty">Select components to build your bike</div>} {layers.length === 0 && <div className="viewer-empty">Select components to build your bike</div>}
{layers.map((layer) => <img key={layer} src={layer} alt="" />)} {layers.map((layer) => (
</div> <img key={layer} src={layer} alt="" />
); ))}
</div>
);
} }
function ProductSection({ title, products, selectedId, onPick, rider }: { title: string; products: Product[]; selectedId?: string; onPick: (id: string) => void; rider: RiderProfile }) { function ProductSection({
return ( title,
<> products,
<div className="section-title">{title}</div> selectedId,
<div className="grid"> onPick,
{products.map((product) => ( rider,
<button key={product.id} className={`card ${selectedId === product.id ? "selected" : ""}`} onClick={() => onPick(product.id)}> }: {
<div className="brand-small">{product.brand}</div> title: string;
<div className="model">{product.model}</div> products: Product[];
<div className="hint">{"description" in product ? product.description : product.notes}</div> selectedId?: string;
<div className="tags"> onPick: (id: string) => void;
{isRecommended(product.id, rider) && <span className="tag rec">recommended</span>} rider: RiderProfile;
{"travel" in product && <span className="tag">{product.travel}mm</span>} }) {
{"travels" in product && <span className="tag">{product.travels.join("/")}mm</span>} return (
{"type" in product && <span className="tag">{product.type}</span>} <>
{"width" in product && <span className="tag">{product.width}&quot;</span>} <div className="section-title">{title}</div>
<div className="grid">
{products.map((product) => (
<button
key={product.id}
className={`card ${selectedId === product.id ? "selected" : ""}`}
onClick={() => onPick(product.id)}
>
<div className="brand-small">{product.brand}</div>
<div className="model">{product.model}</div>
<div className="hint">{"description" in product ? product.description : product.notes}</div>
<div className="tags">
{isRecommended(product.id, rider) && <span className="tag rec">recommended</span>}
{"travel" in product && <span className="tag">{product.travel}mm</span>}
{"travels" in product && <span className="tag">{product.travels.join("/")}mm</span>}
{"type" in product && <span className="tag">{product.type}</span>}
{"width" in product && <span className="tag">{product.width}&quot;</span>}
</div>
</button>
))}
</div> </div>
</button> </>
))} );
</div>
</>
);
} }
function Range({ label, value, onChange }: { label: string; value: number; onChange: (value: number) => void }) { function Range({ label, value, onChange }: { label: string; value: number; onChange: (value: number) => void }) {
return ( return (
<div className="range-row"> <div className="range-row">
<span>{label}</span> <span>{label}</span>
<input type="range" min={1} max={10} value={value} onChange={(e) => onChange(Number(e.target.value))} /> <input type="range" min={1} max={10} value={value} onChange={(e) => onChange(Number(e.target.value))} />
<b>{value}</b> <b>{value}</b>
</div> </div>
); );
} }
function labelFor(key: string) { function labelFor(key: string) {
return key.replace(/([A-Z])/g, " $1").replace(/^./, (char) => char.toUpperCase()).replace("Psi", "PSI").replace("Lsc", "LSC").replace("Hsc", "HSC"); return key
.replace(/([A-Z])/g, " $1")
.replace(/^./, (char) => char.toUpperCase())
.replace("Psi", "PSI")
.replace("Lsc", "LSC")
.replace("Hsc", "HSC");
} }
function numericOrText(value: string) { function numericOrText(value: string) {
if (value.trim() === "") return undefined; if (value.trim() === "") return undefined;
const numeric = Number(value); const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : value; return Number.isFinite(numeric) ? numeric : value;
} }
+80
View File
@@ -0,0 +1,80 @@
import bcrypt from "bcryptjs";
import { SignJWT, jwtVerify } from "jose";
import { cookies } from "next/headers";
import { prisma } from "./prisma";
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET ?? "dev-secret-change-in-production");
const COOKIE_NAME = "loam-session";
export type AuthUser = {
id: string;
email: string;
name: string | null;
};
export async function hashPassword(password: string) {
return bcrypt.hash(password, 12);
}
export async function verifyPassword(password: string, hash: string) {
return bcrypt.compare(password, hash);
}
export async function createToken(user: AuthUser) {
return new SignJWT({ id: user.id, email: user.email, name: user.name })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("7d")
.sign(JWT_SECRET);
}
export async function verifyToken(token: string): Promise<AuthUser | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET, { clockTolerance: 60 });
if (typeof payload.id !== "string" || typeof payload.email !== "string") return null;
return { id: payload.id, email: payload.email, name: (payload.name as string | null) ?? null };
} catch {
return null;
}
}
export async function getCurrentUser(): Promise<AuthUser | null> {
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (!token) return null;
return verifyToken(token);
}
export async function requireAuth(): Promise<AuthUser> {
const user = await getCurrentUser();
if (!user) throw new Error("Unauthorized");
return user;
}
export async function setSessionCookie(token: string) {
const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7,
path: "/",
});
}
export async function clearSessionCookie() {
const cookieStore = await cookies();
cookieStore.delete(COOKIE_NAME);
}
export async function findUserByEmail(email: string) {
return prisma.user.findUnique({ where: { email: email.toLowerCase().trim() } });
}
export async function createUser(email: string, password: string, name?: string) {
const passwordHash = await hashPassword(password);
const user = await prisma.user.create({
data: { email: email.toLowerCase().trim(), passwordHash, name: name || null },
});
return { id: user.id, email: user.email, name: user.name };
}
+37 -6
View File
@@ -8,29 +8,60 @@ function parseSetup(build: any) {
} }
export const store = { export const store = {
async listBuilds() { async listBuilds(userId: string) {
const builds = await prisma.bikeBuild.findMany({ const builds = await prisma.bikeBuild.findMany({
where: { userId },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
}); });
return builds.map(parseSetup); return builds.map(parseSetup);
}, },
async createBuild(input: any) { async createBuild(input: any, userId: string) {
const build = await prisma.bikeBuild.create({ const build = await prisma.bikeBuild.create({
data: { data: {
...input, ...input,
userId,
setup: JSON.stringify(input.setup ?? {}), setup: JSON.stringify(input.setup ?? {}),
}, },
}); });
return parseSetup(build); return parseSetup(build);
}, },
async createReview(input: any) { async getBuild(id: string, userId: string) {
return prisma.rideReview.create({ data: input }); const build = await prisma.bikeBuild.findFirst({
where: { id, userId },
});
return build ? parseSetup(build) : null;
}, },
async preferences() { async createReview(input: any, userId: string) {
const reviews = await prisma.rideReview.findMany(); // Ensure the build belongs to the user before reviewing
const build = await prisma.bikeBuild.findFirst({
where: { id: input.buildId, userId },
});
if (!build) throw new Error("Build not found or access denied");
return prisma.rideReview.create({ data: { ...input, userId } });
},
async preferences(userId: string) {
const reviews = await prisma.rideReview.findMany({ where: { userId } });
return { return {
harshTendency: reviews.filter((review) => review.suspFeel >= 7).length, harshTendency: reviews.filter((review) => review.suspFeel >= 7).length,
softTendency: reviews.filter((review) => review.suspFeel <= 3).length, softTendency: reviews.filter((review) => review.suspFeel <= 3).length,
gripPreference: reviews.reduce((sum, review) => sum + review.frontGrip + review.rearGrip, 0),
}; };
}, },
async getOrCreatePreference(userId: string) {
const existing = await prisma.riderPreference.findUnique({ where: { userId } });
if (existing) return existing;
return prisma.riderPreference.create({
data: { userId, harshTendency: 0, softTendency: 0, gripPreference: 0 },
});
},
async updatePreference(
userId: string,
data: { harshTendency?: number; softTendency?: number; gripPreference?: number },
) {
return prisma.riderPreference.upsert({
where: { userId },
update: data,
create: { userId, ...data },
});
},
}; };
+1 -1
View File
File diff suppressed because one or more lines are too long