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
+61 -52
View File
@@ -1,66 +1,75 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireAuth } from "@/lib/auth";
import { store } from "@/lib/db";
import { selectedProducts } from "@/lib/recommendations";
import { setupVerification } from "@/lib/setupVerification";
import type { Product } from "@/lib/types";
const schema = z.object({
rider: z.object({ weight: z.number(), discipline: z.string(), level: z.string(), style: z.string() }),
selection: z.object({
name: z.string(),
frameId: z.string().optional(),
forkId: z.string().optional(),
shockId: z.string().optional(),
frontTireId: z.string().optional(),
rearTireId: z.string().optional(),
}),
setup: z.record(z.unknown()).optional(),
review: z.record(z.unknown()).optional(),
rider: z.object({ weight: z.number(), discipline: z.string(), level: z.string(), style: z.string() }),
selection: z.object({
name: z.string(),
frameId: z.string().optional(),
forkId: z.string().optional(),
shockId: z.string().optional(),
frontTireId: z.string().optional(),
rearTireId: z.string().optional(),
}),
setup: z.record(z.unknown()).optional(),
review: z.record(z.unknown()).optional(),
});
export async function POST(request: Request) {
const body = schema.parse(await request.json());
const products = selectedProducts(body.selection);
const evidence = Object.values(products)
.filter((product): product is Product => Boolean(product))
.map((product) => setupVerification[product.id])
.filter(Boolean);
const prefs = await store.preferences();
const prompt = [
"You are a professional mountain bike suspension and tire setup tuner.",
"Combine manufacturer setup ranges, community setup patterns, and the rider's past preferences.",
"Return concise, actionable settings with exact changes where possible.",
`Rider: ${body.rider.weight}kg, ${body.rider.level}, ${body.rider.style}, ${body.rider.discipline}.`,
`Frame: ${products.frame ? `${products.frame.brand} ${products.frame.model}` : "none"}.`,
`Fork: ${products.fork ? `${products.fork.brand} ${products.fork.model}` : "none"}.`,
`Shock: ${products.shock ? `${products.shock.brand} ${products.shock.model}` : "none"}.`,
`Front tire: ${products.frontTire ? `${products.frontTire.brand} ${products.frontTire.model}` : "none"}.`,
`Rear tire: ${products.rearTire ? `${products.rearTire.brand} ${products.rearTire.model}` : "none"}.`,
`Current setup: ${JSON.stringify(body.setup ?? {})}.`,
`Post ride review: ${JSON.stringify(body.review ?? {})}.`,
`Learned preferences: ${JSON.stringify(prefs ?? {})}.`,
`Setup evidence: ${JSON.stringify(evidence)}.`,
].join("\n");
const user = await requireAuth();
const body = schema.parse(await request.json());
const products = selectedProducts(body.selection);
const evidence = Object.values(products)
.filter((product): product is Product => Boolean(product))
.map((product) => setupVerification[product.id])
.filter(Boolean);
const prefs = await store.preferences(user.id);
const prompt = [
"You are a professional mountain bike suspension and tire setup tuner.",
"Combine manufacturer setup ranges, community setup patterns, and the rider's past preferences.",
"Return concise, actionable settings with exact changes where possible.",
`Rider: ${body.rider.weight}kg, ${body.rider.level}, ${body.rider.style}, ${body.rider.discipline}.`,
`Frame: ${products.frame ? `${products.frame.brand} ${products.frame.model}` : "none"}.`,
`Fork: ${products.fork ? `${products.fork.brand} ${products.fork.model}` : "none"}.`,
`Shock: ${products.shock ? `${products.shock.brand} ${products.shock.model}` : "none"}.`,
`Front tire: ${products.frontTire ? `${products.frontTire.brand} ${products.frontTire.model}` : "none"}.`,
`Rear tire: ${products.rearTire ? `${products.rearTire.brand} ${products.rearTire.model}` : "none"}.`,
`Current setup: ${JSON.stringify(body.setup ?? {})}.`,
`Post ride review: ${JSON.stringify(body.review ?? {})}.`,
`Learned preferences (from this user's history): ${JSON.stringify(prefs ?? {})}.`,
`Setup evidence: ${JSON.stringify(evidence)}.`,
].join("\n");
const ollamaUrl = process.env.OLLAMA_URL ?? "http://localhost:11434";
const model = process.env.OLLAMA_MODEL ?? "llama3.1:8b";
const response = await fetch(`${ollamaUrl}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model,
stream: false,
messages: [
{ role: "system", content: "Use metric units plus PSI/clicks. Structure output as Fork, Shock, Tires, Rationale, Next Ride." },
{ role: "user", content: prompt },
],
}),
});
const ollamaUrl = process.env.OLLAMA_URL ?? "http://localhost:11434";
const model = process.env.OLLAMA_MODEL ?? "llama3.1:8b";
const response = await fetch(`${ollamaUrl}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model,
stream: false,
messages: [
{
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) {
return NextResponse.json({ error: `Ollama returned ${response.status}. Is ${model} pulled and Ollama running?` }, { status: 502 });
}
const data = await response.json();
return NextResponse.json({ model, recommendation: data.message?.content ?? data.response ?? "" });
if (!response.ok) {
return NextResponse.json(
{ error: `Ollama returned ${response.status}. Is ${model} pulled and Ollama running?` },
{ status: 502 },
);
}
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 { z } from "zod";
import { requireAuth } from "@/lib/auth";
import { store } from "@/lib/db";
import { buildSetupDefaults } from "@/lib/recommendations";
const schema = z.object({
name: z.string().min(1),
rider: z.object({
weight: z.number().min(35).max(160),
discipline: z.string(),
level: z.string(),
style: z.string(),
}),
frameId: z.string().optional(),
forkId: z.string().optional(),
shockId: z.string().optional(),
frontTireId: z.string().optional(),
rearTireId: z.string().optional(),
setup: z.record(z.unknown()).optional(),
imagePath: z.string().optional(),
name: z.string().min(1),
rider: z.object({
weight: z.number().min(35).max(160),
discipline: z.string(),
level: z.string(),
style: z.string(),
}),
frameId: z.string().optional(),
forkId: z.string().optional(),
shockId: z.string().optional(),
frontTireId: z.string().optional(),
rearTireId: z.string().optional(),
setup: z.record(z.unknown()).optional(),
imagePath: z.string().optional(),
});
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) {
const body = schema.parse(await request.json());
const selection = {
name: body.name,
frameId: body.frameId,
forkId: body.forkId,
shockId: body.shockId,
frontTireId: body.frontTireId,
rearTireId: body.rearTireId,
};
const setup = { ...buildSetupDefaults(selection, body.rider), ...body.setup };
const build = await store.createBuild({
name: body.name,
riderWeightKg: body.rider.weight,
discipline: body.rider.discipline,
riderLevel: body.rider.level,
rideStyle: body.rider.style,
frameId: body.frameId,
forkId: body.forkId,
shockId: body.shockId,
frontTireId: body.frontTireId,
rearTireId: body.rearTireId,
setup,
imagePath: body.imagePath,
});
return NextResponse.json(build, { status: 201 });
const user = await requireAuth();
const body = schema.parse(await request.json());
const selection = {
name: body.name,
frameId: body.frameId,
forkId: body.forkId,
shockId: body.shockId,
frontTireId: body.frontTireId,
rearTireId: body.rearTireId,
};
const setup = { ...buildSetupDefaults(selection, body.rider), ...body.setup };
const build = await store.createBuild(
{
name: body.name,
riderWeightKg: body.rider.weight,
discipline: body.rider.discipline,
riderLevel: body.rider.level,
rideStyle: body.rider.style,
frameId: body.frameId,
forkId: body.forkId,
shockId: body.shockId,
frontTireId: body.frontTireId,
rearTireId: body.rearTireId,
setup,
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 { z } from "zod";
import { requireAuth } from "@/lib/auth";
import { store } from "@/lib/db";
const schema = z.object({
buildId: z.string(),
suspFeel: z.number().min(1).max(10),
frontGrip: z.number().min(1).max(10),
rearGrip: z.number().min(1).max(10),
rollSpeed: z.number().min(1).max(10),
confidence: z.number().min(1).max(10),
bottomOut: z.boolean().default(false),
harshHit: z.boolean().default(false),
wallow: z.boolean().default(false),
smallBump: z.boolean().default(false),
notes: z.string().optional(),
advice: z.string().optional(),
buildId: z.string(),
suspFeel: z.number().min(1).max(10),
frontGrip: z.number().min(1).max(10),
rearGrip: z.number().min(1).max(10),
rollSpeed: z.number().min(1).max(10),
confidence: z.number().min(1).max(10),
bottomOut: z.boolean().default(false),
harshHit: z.boolean().default(false),
wallow: z.boolean().default(false),
smallBump: z.boolean().default(false),
notes: z.string().optional(),
advice: z.string().optional(),
});
export async function POST(request: Request) {
const body = schema.parse(await request.json());
const review = await store.createReview(body);
return NextResponse.json(review, { status: 201 });
const user = await requireAuth();
const body = schema.parse(await request.json());
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 { 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 type { BuildSelection, BuildSetup, Product, RiderProfile } from "@/lib/types";
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 emptyRider: RiderProfile = { weight: 78, discipline: "Trail", level: "Intermediate", style: "Mixed" };
export default function BikeBuilderApp() {
const [tab, setTab] = useState<Tab>("build");
const [rider, setRider] = useState<RiderProfile>(emptyRider);
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 };
const [user, setUser] = useState<User | null>(null);
const [authChecked, setAuthChecked] = useState(false);
useEffect(() => {
fetch("/api/builds")
.then((response) => response.json())
.then((data) => setBuilds(Array.isArray(data) ? data : []))
.catch(() => setBuilds([]));
}, []);
useEffect(() => {
fetch("/api/auth/me")
.then((r) => r.json())
.then((data) => setUser(data.user))
.finally(() => setAuthChecked(true));
}, []);
function choose(key: keyof BuildSelection, id: string) {
setSelection((current) => ({ ...current, [key]: current[key] === id ? undefined : id }));
}
if (!authChecked) {
return (
<main className="app">
<div className="notice" style={{ marginTop: 40 }}>
Loading...
</div>
</main>
);
}
async function saveBuild() {
if (!selection.name.trim()) return;
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");
}
if (!user) {
return <AuthScreen onAuth={(u) => setUser(u)} />;
}
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) => r.json());
setAiOut(result.recommendation ?? result.error ?? "No recommendation returned.");
setLoading("");
}
return <AppContent user={user} onLogout={() => setUser(null)} />;
}
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) });
await getAiRecommendation(review);
}
function AuthScreen({ onAuth }: { onAuth: (user: User) => void }) {
const [mode, setMode] = useState<AuthMode>("login");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
return (
<main className="app">
<header className="header">
<div>
<div className="brand">Loam</div>
<div className="subtitle">MTB Build & Tune</div>
</div>
<div className="subtitle">Next.js web + Node API for Android reuse</div>
</header>
async function submit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
const endpoint = mode === "login" ? "/api/auth/login" : "/api/auth/register";
const body: any = { email, password };
if (mode === "register") body.name = name;
const res = await fetch(endpoint, {
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">
{(["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" />
return (
<main className="app">
<header className="header">
<div>
<div className="brand">Loam</div>
<div className="subtitle">MTB Build & Tune</div>
</div>
</header>
<div style={{ maxWidth: 360, margin: "40px auto" }}>
<div className="tabs">
<button
className={`tab ${mode === "login" ? "active" : ""}`}
onClick={() => {
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>
<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} />
</>
)}
</main>
);
}
{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>
</>
)}
function AppContent({ user, onLogout }: { user: User; onLogout: () => void }) {
const [tab, setTab] = useState<Tab>("build");
const [rider, setRider] = useState<RiderProfile>(emptyRider);
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 };
{tab === "review" && (
<>
<div className="notice">Post-ride reviews are saved to the backend. The preference counters bias 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>}
</>
)}
useEffect(() => {
fetch("/api/builds")
.then((response) => {
if (response.status === 401) return [];
return response.json();
})
.then((data) => setBuilds(Array.isArray(data) ? data : []))
.catch(() => setBuilds([]));
}, []);
{tab === "garage" && (
<div className="build-list">
{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>
)}
function choose(key: keyof BuildSelection, id: string) {
setSelection((current) => ({ ...current, [key]: current[key] === id ? undefined : id }));
}
{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>
);
async function saveBuild() {
if (!selection.name.trim()) return;
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) => {
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 }) {
return (
<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"><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>
);
return (
<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">
<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 }) {
const layers = layerPaths(selection);
return (
<div className="viewer">
{layers.length === 0 && <div className="viewer-empty">Select components to build your bike</div>}
{layers.map((layer) => <img key={layer} src={layer} alt="" />)}
</div>
);
const layers = layerPaths(selection);
return (
<div className="viewer">
{layers.length === 0 && <div className="viewer-empty">Select components to build your bike</div>}
{layers.map((layer) => (
<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 }) {
return (
<>
<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>}
function ProductSection({
title,
products,
selectedId,
onPick,
rider,
}: {
title: string;
products: Product[];
selectedId?: string;
onPick: (id: string) => void;
rider: RiderProfile;
}) {
return (
<>
<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>
</button>
))}
</div>
</>
);
</>
);
}
function Range({ label, value, onChange }: { label: string; value: number; onChange: (value: number) => void }) {
return (
<div className="range-row">
<span>{label}</span>
<input type="range" min={1} max={10} value={value} onChange={(e) => onChange(Number(e.target.value))} />
<b>{value}</b>
</div>
);
return (
<div className="range-row">
<span>{label}</span>
<input type="range" min={1} max={10} value={value} onChange={(e) => onChange(Number(e.target.value))} />
<b>{value}</b>
</div>
);
}
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) {
if (value.trim() === "") return undefined;
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : value;
if (value.trim() === "") return undefined;
const numeric = Number(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 = {
async listBuilds() {
async listBuilds(userId: string) {
const builds = await prisma.bikeBuild.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
});
return builds.map(parseSetup);
},
async createBuild(input: any) {
async createBuild(input: any, userId: string) {
const build = await prisma.bikeBuild.create({
data: {
...input,
userId,
setup: JSON.stringify(input.setup ?? {}),
},
});
return parseSetup(build);
},
async createReview(input: any) {
return prisma.rideReview.create({ data: input });
async getBuild(id: string, userId: string) {
const build = await prisma.bikeBuild.findFirst({
where: { id, userId },
});
return build ? parseSetup(build) : null;
},
async preferences() {
const reviews = await prisma.rideReview.findMany();
async createReview(input: any, userId: string) {
// 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 {
harshTendency: reviews.filter((review) => review.suspFeel >= 7).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 },
});
},
};