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:
@@ -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 ?? "" });
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}"</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}"</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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user