Refactor bike builder into Next.js app
Replace the single JSX file with a Next.js project, shared catalog and recommendation modules, backend API routes, file-backed persistence, Ollama recommendation endpoint, and picture-layer bike composition. Add initial aligned component assets for existing BSU layers plus RockShox and Maxxis products.
@@ -0,0 +1,3 @@
|
|||||||
|
DATABASE_URL="file:./dev.db"
|
||||||
|
OLLAMA_URL="http://localhost:11434"
|
||||||
|
OLLAMA_MODEL="llama3.1:8b"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
BSU/
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
prisma/dev.db
|
||||||
|
data/*.db
|
||||||
|
data/*.json
|
||||||
|
public/images/user/*.png
|
||||||
|
tmp/
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...nextVitals,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
"@next/next/no-img-element": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"scripts":{"dev":"next dev","build":"next build","start":"next start","lint":"eslint src","db:seed":"tsx prisma/seed.ts"},"dependencies":{"@prisma/client":"^5.22.0","next":"^16.2.6","postcss":"^8.5.10","prisma":"^5.22.0","react":"^19.2.1","react-dom":"^19.2.1","sharp":"^0.33.5","zod":"^3.23.8"},"devDependencies":{"@types/node":"^20.17.6","@types/react":"^19.2.7","@types/react-dom":"^19.2.3","eslint":"^9.39.4","eslint-config-next":"^16.2.6","tsx":"^4.19.2","typescript":"^5.6.3"}}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
output = "../node_modules/.prisma/client"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Product {
|
||||||
|
id String @id
|
||||||
|
category String
|
||||||
|
position String?
|
||||||
|
brand String
|
||||||
|
model String
|
||||||
|
imagePath String?
|
||||||
|
specs String
|
||||||
|
setup String
|
||||||
|
sources String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model BikeBuild {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
riderWeightKg Int
|
||||||
|
discipline String
|
||||||
|
riderLevel String
|
||||||
|
rideStyle String
|
||||||
|
frameId String?
|
||||||
|
forkId String?
|
||||||
|
shockId String?
|
||||||
|
frontTireId String?
|
||||||
|
rearTireId String?
|
||||||
|
setup String
|
||||||
|
imagePath String?
|
||||||
|
reviews RideReview[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model RideReview {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
buildId String
|
||||||
|
build BikeBuild @relation(fields: [buildId], references: [id], onDelete: Cascade)
|
||||||
|
suspFeel Int
|
||||||
|
frontGrip Int
|
||||||
|
rearGrip Int
|
||||||
|
rollSpeed Int
|
||||||
|
confidence Int
|
||||||
|
bottomOut Boolean @default(false)
|
||||||
|
harshHit Boolean @default(false)
|
||||||
|
wallow Boolean @default(false)
|
||||||
|
smallBump Boolean @default(false)
|
||||||
|
notes String?
|
||||||
|
advice String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model RiderPreference {
|
||||||
|
id String @id @default("default")
|
||||||
|
harshTendency Int @default(0)
|
||||||
|
softTendency Int @default(0)
|
||||||
|
gripPreference Int @default(0)
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { frames, forks, shocks, tires } from "../src/lib/catalog";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const products = [...frames, ...forks, ...shocks, ...tires];
|
||||||
|
for (const product of products) {
|
||||||
|
const { id, category, brand, model, imagePath, sources } = product;
|
||||||
|
const position = "position" in product ? product.position : null;
|
||||||
|
await prisma.product.upsert({
|
||||||
|
where: { id },
|
||||||
|
update: { category, position, brand, model, imagePath, specs: JSON.stringify(product), setup: JSON.stringify(product), sources: JSON.stringify(sources) },
|
||||||
|
create: { id, category, position, brand, model, imagePath, specs: JSON.stringify(product), setup: JSON.stringify(product), sources: JSON.stringify(sources) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await prisma.riderPreference.upsert({
|
||||||
|
where: { id: "default" },
|
||||||
|
update: {},
|
||||||
|
create: { id: "default" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().finally(async () => prisma.$disconnect());
|
||||||
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 439 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 673 KiB |
|
After Width: | Height: | Size: 834 KiB |
|
After Width: | Height: | Size: 892 KiB |
|
After Width: | Height: | Size: 729 KiB |
|
After Width: | Height: | Size: 673 KiB |
|
After Width: | Height: | Size: 835 KiB |
|
After Width: | Height: | Size: 778 KiB |
|
After Width: | Height: | Size: 722 KiB |
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { store } from "@/lib/db";
|
||||||
|
import { selectedProducts } from "@/lib/recommendations";
|
||||||
|
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const body = schema.parse(await request.json());
|
||||||
|
const products = selectedProducts(body.selection);
|
||||||
|
const prefs = 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 ?? {})}.`,
|
||||||
|
].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 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
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,53 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json(store.listBuilds());
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = 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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { catalog } from "@/lib/catalog";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json(catalog);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { mkdir } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { canvas, layerPaths, publicPathToFile } from "@/lib/imageLayers";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(1).default("bike"),
|
||||||
|
frameId: z.string().optional(),
|
||||||
|
forkId: z.string().optional(),
|
||||||
|
shockId: z.string().optional(),
|
||||||
|
frontTireId: z.string().optional(),
|
||||||
|
rearTireId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const selection = schema.parse(await request.json());
|
||||||
|
const layers = layerPaths(selection).map((layer) => ({ input: publicPathToFile(layer), left: 0, top: 0 }));
|
||||||
|
const outputDir = path.join(process.cwd(), "public", "images", "user");
|
||||||
|
await mkdir(outputDir, { recursive: true });
|
||||||
|
const safeName = selection.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "bike";
|
||||||
|
const fileName = `${safeName}-${Date.now()}.png`;
|
||||||
|
const output = path.join(outputDir, fileName);
|
||||||
|
|
||||||
|
await sharp({
|
||||||
|
create: {
|
||||||
|
width: canvas.width,
|
||||||
|
height: canvas.height,
|
||||||
|
channels: 4,
|
||||||
|
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.composite(layers)
|
||||||
|
.png()
|
||||||
|
.toFile(output);
|
||||||
|
|
||||||
|
return NextResponse.json({ imagePath: `/images/user/${fileName}`, layers: layerPaths(selection) });
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const body = schema.parse(await request.json());
|
||||||
|
const review = store.createReview(body);
|
||||||
|
return NextResponse.json(review, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #071009;
|
||||||
|
--panel: #132016;
|
||||||
|
--panel2: #19291d;
|
||||||
|
--line: #2a3f2d;
|
||||||
|
--line2: #456147;
|
||||||
|
--text: #d9e7d4;
|
||||||
|
--muted: #8aa284;
|
||||||
|
--dim: #60745d;
|
||||||
|
--green: #79b84a;
|
||||||
|
--amber: #c9913d;
|
||||||
|
--red: #d16a55;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
button, input, select, textarea { font: inherit; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
|
||||||
|
.app { max-width: 1180px; margin: 0 auto; padding: 24px 16px 80px; }
|
||||||
|
.header { display: flex; justify-content: space-between; gap: 16px; align-items: flex-end; border-bottom: 1px solid var(--line); padding-bottom: 16px; margin-bottom: 18px; }
|
||||||
|
.brand { font-size: clamp(30px, 7vw, 64px); line-height: .9; letter-spacing: .08em; text-transform: uppercase; font-weight: 900; }
|
||||||
|
.subtitle { color: var(--muted); text-transform: uppercase; letter-spacing: .16em; font-size: 12px; }
|
||||||
|
.tabs { display: flex; gap: 4px; overflow-x: auto; background: #0b160e; border: 1px solid var(--line); padding: 4px; margin-bottom: 18px; }
|
||||||
|
.tab { flex: 1; min-width: 110px; border: 0; background: transparent; color: var(--muted); padding: 10px; text-transform: uppercase; letter-spacing: .08em; font-weight: 800; }
|
||||||
|
.tab.active { background: var(--green); color: #061007; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 12px; }
|
||||||
|
.profile { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; background: var(--panel); border: 1px solid var(--line); padding: 14px; margin-bottom: 16px; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.field label { color: var(--muted); text-transform: uppercase; letter-spacing: .12em; font-size: 11px; font-weight: 800; }
|
||||||
|
.field input, .field select, .field textarea { width: 100%; border: 1px solid var(--line); background: #0b160e; color: var(--text); padding: 10px; border-radius: 4px; }
|
||||||
|
.field textarea { min-height: 100px; resize: vertical; }
|
||||||
|
.hint { color: var(--dim); font-size: 12px; }
|
||||||
|
.section-title { margin: 24px 0 10px; padding-bottom: 8px; border-bottom: 1px solid var(--line); color: var(--muted); text-transform: uppercase; letter-spacing: .16em; font-size: 12px; font-weight: 900; }
|
||||||
|
.card { border: 1px solid var(--line); background: var(--panel); padding: 12px; border-radius: 6px; text-align: left; color: inherit; min-height: 132px; }
|
||||||
|
.card:hover, .card.selected { border-color: var(--green); background: var(--panel2); }
|
||||||
|
.card .brand-small { color: var(--muted); font-size: 11px; letter-spacing: .14em; text-transform: uppercase; }
|
||||||
|
.card .model { font-size: 22px; font-weight: 900; margin: 4px 0; }
|
||||||
|
.tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
|
||||||
|
.tag { border: 1px solid var(--line2); color: var(--muted); padding: 3px 7px; font-size: 11px; border-radius: 999px; }
|
||||||
|
.tag.rec { border-color: var(--green); color: var(--green); }
|
||||||
|
.viewer { position: relative; border: 1px solid var(--line); background: radial-gradient(circle at 50% 60%, #182719, #071009 70%); overflow: hidden; aspect-ratio: 2400 / 1464; margin-bottom: 16px; }
|
||||||
|
.viewer img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
|
||||||
|
.viewer-empty { position: absolute; inset: 0; display: grid; place-items: center; color: var(--dim); text-transform: uppercase; letter-spacing: .12em; font-weight: 800; font-size: 12px; }
|
||||||
|
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; }
|
||||||
|
.row > .field { flex: 1; min-width: 240px; }
|
||||||
|
.btn { border: 1px solid var(--line2); background: #101d13; color: var(--text); padding: 10px 14px; border-radius: 4px; font-weight: 900; text-transform: uppercase; letter-spacing: .08em; }
|
||||||
|
.btn.primary { background: var(--green); border-color: var(--green); color: #061007; }
|
||||||
|
.btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||||
|
.setup-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
|
||||||
|
.stat { background: var(--panel); border: 1px solid var(--line); padding: 12px; border-radius: 6px; }
|
||||||
|
.stat b { display: block; font-size: 24px; }
|
||||||
|
.notice, .output { border-left: 3px solid var(--green); background: #0b160e; padding: 12px 14px; color: var(--muted); line-height: 1.55; }
|
||||||
|
.output { color: var(--text); white-space: pre-wrap; margin-top: 12px; }
|
||||||
|
.range-row { display: grid; grid-template-columns: 150px 1fr 40px; gap: 10px; align-items: center; margin: 10px 0; }
|
||||||
|
.build-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; }
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.header { align-items: start; flex-direction: column; }
|
||||||
|
.range-row { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Loam MTB Builder",
|
||||||
|
description: "Build, tune, and review MTB setups.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import BikeBuilderApp from "@/components/BikeBuilderApp";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return <BikeBuilderApp />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { catalog } from "@/lib/catalog";
|
||||||
|
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";
|
||||||
|
|
||||||
|
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 };
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/builds")
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => setBuilds(Array.isArray(data) ? data : []))
|
||||||
|
.catch(() => setBuilds([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function choose(key: keyof BuildSelection, id: string) {
|
||||||
|
setSelection((current) => ({ ...current, [key]: current[key] === id ? undefined : id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
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("");
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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. 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>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelFor(key: string) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { FrameProduct, ForkProduct, ShockProduct, TireProduct } from "./types";
|
||||||
|
|
||||||
|
const manufacturer = (title: string, url = "") => ({ title, url, kind: "manufacturer" as const });
|
||||||
|
const community = (title: string, url = "") => ({ title, url, kind: "community" as const });
|
||||||
|
const placeholder = { title: "Needs verified setup research before production use", url: "", kind: "placeholder" as const };
|
||||||
|
|
||||||
|
export const frames: FrameProduct[] = [
|
||||||
|
{ id: "transition-sentinel", category: "frame", brand: "Transition", model: "Sentinel", travel: 140, headAngle: 64, geometry: "enduro", bestFor: ["enduro", "trail"], description: "Aggressive trail and light enduro frame.", sources: [manufacturer("Transition Sentinel geometry"), placeholder] },
|
||||||
|
{ id: "transition-scout", category: "frame", brand: "Transition", model: "Scout", travel: 130, headAngle: 65.5, geometry: "trail", bestFor: ["trail", "xc"], description: "Playful short-travel trail frame.", imagePath: "/images/frame/transition/scout.png", sources: [manufacturer("Transition Scout geometry"), community("Existing BSU image asset")] },
|
||||||
|
{ id: "norco-sight-vlt", category: "frame", brand: "Norco", model: "Sight VLT", travel: 140, headAngle: 64, geometry: "enduro", bestFor: ["enduro", "trail"], description: "Full-power e-MTB enduro platform.", sources: [manufacturer("Norco Sight VLT geometry"), placeholder] },
|
||||||
|
{ id: "canyon-spectral", category: "frame", brand: "Canyon", model: "Spectral", travel: 140, headAngle: 64.5, geometry: "trail", bestFor: ["trail", "enduro"], description: "Balanced all-mountain platform.", sources: [manufacturer("Canyon Spectral geometry"), placeholder] },
|
||||||
|
{ id: "last-coal", category: "frame", brand: "Last", model: "Coal", travel: 150, headAngle: 63.5, geometry: "enduro", bestFor: ["enduro"], description: "Long, slack enduro frame.", sources: [manufacturer("LAST Coal geometry"), placeholder] },
|
||||||
|
{ id: "evil-the-calling", category: "frame", brand: "Evil", model: "The Calling", travel: 140, headAngle: 65, geometry: "trail", bestFor: ["trail", "enduro"], description: "Responsive technical trail frame.", sources: [manufacturer("Evil The Calling geometry"), placeholder] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const forks: ForkProduct[] = [
|
||||||
|
{ id: "manitou-mezzer", category: "fork", brand: "Manitou", model: "Mezzer", travels: [140, 150, 160], damper: "MC2 / IRT", psi: { lt: [60, 72], md: [70, 84], hv: [82, 100] }, sag: { xc: 20, trail: 25, enduro: 27 }, rebound: { lt: 14, md: 11, hv: 9 }, lsc: { lt: 5, md: 7, hv: 9 }, hsc: { lt: 3, md: 4, hv: 5 }, tokens: { lt: 0, md: 1, hv: 2 }, notes: "Very tunable, supple air spring with strong mid-stroke support.", imagePath: "/images/fork/manitou/mezzer.png", sources: [manufacturer("Manitou Mezzer setup guide"), community("Pinkbike and MTBR Mezzer setup discussions")] },
|
||||||
|
{ id: "fox-38", category: "fork", brand: "Fox", model: "38 Factory", travels: [160, 170, 180], damper: "GRIP2", psi: { lt: [65, 78], md: [76, 92], hv: [88, 110] }, sag: { xc: 20, trail: 25, enduro: 28 }, rebound: { lt: 13, md: 11, hv: 8 }, lsc: { lt: 6, md: 8, hv: 10 }, hsc: { lt: 3, md: 4, hv: 5 }, tokens: { lt: 0, md: 1, hv: 2 }, notes: "Stiff enduro chassis for hard charging.", sources: [manufacturer("Fox 38 tuning guide"), placeholder] },
|
||||||
|
{ id: "fox-36", category: "fork", brand: "Fox", model: "36 Factory", travels: [140, 150, 160], damper: "GRIP2", psi: { lt: [60, 74], md: [70, 86], hv: [82, 102] }, sag: { xc: 20, trail: 25, enduro: 27 }, rebound: { lt: 13, md: 11, hv: 9 }, lsc: { lt: 5, md: 7, hv: 9 }, hsc: { lt: 2, md: 3, hv: 4 }, tokens: { lt: 0, md: 1, hv: 2 }, notes: "All-mountain default with good stiffness-to-weight balance.", sources: [manufacturer("Fox 36 tuning guide"), placeholder] },
|
||||||
|
{ id: "fox-34", category: "fork", brand: "Fox", model: "34 Factory", travels: [120, 130, 140], damper: "GRIP2", psi: { lt: [55, 68], md: [64, 80], hv: [76, 94] }, sag: { xc: 20, trail: 22, enduro: 25 }, rebound: { lt: 12, md: 10, hv: 8 }, lsc: { lt: 4, md: 6, hv: 8 }, hsc: { lt: 2, md: 3, hv: 4 }, tokens: { lt: 0, md: 0, hv: 1 }, notes: "Light trail and downcountry option.", sources: [manufacturer("Fox 34 tuning guide"), placeholder] },
|
||||||
|
{ id: "rockshox-zeb", category: "fork", brand: "RockShox", model: "ZEB Ultimate", travels: [150, 160, 170, 180], damper: "Charger 3 / DebonAir+", psi: { lt: [62, 76], md: [72, 88], hv: [84, 106] }, sag: { xc: 20, trail: 25, enduro: 28 }, rebound: { lt: 14, md: 12, hv: 10 }, lsc: { lt: 5, md: 7, hv: 9 }, hsc: { lt: 3, md: 4, hv: 5 }, tokens: { lt: 0, md: 1, hv: 2 }, notes: "Plush big-bike fork for enduro terrain.", imagePath: "/images/fork/rockshox/zeb.png", sources: [manufacturer("RockShox ZEB product page", "https://www.sram.com/en/rockshox/models/fs-zeb-ult-b1"), placeholder] },
|
||||||
|
{ id: "rockshox-lyrik", category: "fork", brand: "RockShox", model: "Lyrik Ultimate", travels: [140, 150, 160], damper: "Charger 3 / DebonAir+", psi: { lt: [58, 72], md: [68, 83], hv: [80, 100] }, sag: { xc: 20, trail: 25, enduro: 28 }, rebound: { lt: 14, md: 12, hv: 10 }, lsc: { lt: 4, md: 6, hv: 8 }, hsc: { lt: 2, md: 3, hv: 4 }, tokens: { lt: 0, md: 1, hv: 2 }, notes: "Strong trail/enduro all-rounder.", imagePath: "/images/fork/rockshox/lyrik.png", sources: [manufacturer("RockShox Lyrik product page", "https://www.sram.com/en/rockshox/models/fs-lyrk-ult-e1"), placeholder] },
|
||||||
|
{ id: "rockshox-pike", category: "fork", brand: "RockShox", model: "Pike Ultimate", travels: [120, 130, 140], damper: "Charger 3 / DebonAir+", psi: { lt: [55, 68], md: [64, 78], hv: [76, 93] }, sag: { xc: 20, trail: 22, enduro: 25 }, rebound: { lt: 13, md: 11, hv: 9 }, lsc: { lt: 3, md: 5, hv: 7 }, hsc: { lt: 2, md: 3, hv: 3 }, tokens: { lt: 0, md: 0, hv: 1 }, notes: "Light and precise trail fork.", imagePath: "/images/fork/rockshox/pike.png", sources: [manufacturer("RockShox Pike product page", "https://www.sram.com/en/rockshox/models/fs-pike-ult-c2"), placeholder] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const shocks: ShockProduct[] = [
|
||||||
|
{ id: "fox-x2", category: "shock", brand: "Fox", model: "X2 Factory", type: "air", psi: { lt: [140, 158], md: [158, 185], hv: [185, 212] }, sag: { xc: 25, trail: 28, enduro: 31 }, rebound: { lt: 12, md: 10, hv: 8 }, lsc: { lt: 5, md: 7, hv: 9 }, hsc: { lt: 3, md: 4, hv: 5 }, tokens: { lt: 0, md: 1, hv: 2 }, notes: "Highly adjustable air shock for technical speed.", sources: [manufacturer("Fox X2 tuning guide"), placeholder] },
|
||||||
|
{ id: "fox-float-x", category: "shock", brand: "Fox", model: "Float X Factory", type: "air", psi: { lt: [132, 152], md: [152, 178], hv: [178, 205] }, sag: { xc: 25, trail: 28, enduro: 30 }, rebound: { lt: 11, md: 9, hv: 7 }, lsc: { lt: 5, md: 7, hv: 9 }, tokens: { lt: 0, md: 1, hv: 2 }, notes: "Simpler trail/enduro air shock.", sources: [manufacturer("Fox Float X tuning guide"), placeholder] },
|
||||||
|
{ id: "rockshox-vivid-air-ultimate", category: "shock", brand: "RockShox", model: "Vivid Air Ultimate", type: "air", psi: { lt: [128, 150], md: [150, 178], hv: [178, 208] }, sag: { xc: 25, trail: 28, enduro: 32 }, rebound: { lt: 13, md: 11, hv: 9 }, lsc: { lt: 4, md: 6, hv: 8 }, hsc: { lt: 3, md: 4, hv: 5 }, tokens: { lt: 0, md: 1, hv: 2 }, notes: "Supple high-volume air shock.", imagePath: "/images/shock/rockshox/vivid_air_ultimate.png", sources: [manufacturer("RockShox Vivid setup guide"), community("Existing BSU image asset")] },
|
||||||
|
{ id: "rockshox-super-deluxe-ultimate", category: "shock", brand: "RockShox", model: "Super Deluxe Ultimate", type: "air", psi: { lt: [135, 158], md: [158, 184], hv: [184, 212] }, sag: { xc: 25, trail: 28, enduro: 31 }, rebound: { lt: 12, md: 10, hv: 8 }, lsc: { lt: 3, md: 5, hv: 7 }, hsc: { lt: 3, md: 4, hv: 5 }, tokens: { lt: 0, md: 1, hv: 2 }, notes: "Reliable trail/enduro OEM workhorse.", imagePath: "/images/shock/rockshox/super_deluxe_ultimate.png", sources: [manufacturer("RockShox Super Deluxe product page", "https://www.sram.com/en/rockshox/models/rs-sdlx-ult-d1"), placeholder] },
|
||||||
|
{ id: "rockshox-super-deluxe-coil-ultimate", category: "shock", brand: "RockShox", model: "Super Deluxe Coil Ultimate", type: "coil", sag: { xc: 30, trail: 33, enduro: 36 }, rebound: { lt: 12, md: 10, hv: 8 }, lsc: { lt: 4, md: 6, hv: 8 }, hsc: { lt: 3, md: 4, hv: 5 }, spring: { lt: "350-400 lb/in", md: "400-500 lb/in", hv: "500-600 lb/in" }, notes: "Coil feel and heat consistency for descending-focused builds.", imagePath: "/images/shock/rockshox/super_deluxe_coil_ultimate.png", sources: [manufacturer("RockShox Super Deluxe Coil product page", "https://www.sram.com/en/rockshox/models/rs-sdlc-ult-b1"), placeholder] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const tireSources = [manufacturer("Brand tire pressure guidance"), community("Common community starting pressures")];
|
||||||
|
const maxxisSources = [manufacturer("Maxxis product images", "https://www.maxxis.com/catalog/tire/"), manufacturer("Brand tire pressure guidance"), community("Common community starting pressures")];
|
||||||
|
export const tires: TireProduct[] = [
|
||||||
|
{ id: "maxxis-assegai-front", category: "tire", position: "front", brand: "Maxxis", model: "Assegai", width: 2.5, grip: 10, rolling: 3, bestFor: ["enduro", "wet"], psi: { lt: [17, 22], md: [21, 26], hv: [25, 30] }, notes: "Maximum front grip.", imagePath: "/images/tire/front/maxxis/assegai.png", sources: maxxisSources },
|
||||||
|
{ id: "maxxis-dissector-front", category: "tire", position: "front", brand: "Maxxis", model: "Dissector", width: 2.4, grip: 7, rolling: 7, bestFor: ["trail", "xc", "dry"], psi: { lt: [20, 24], md: [23, 28], hv: [27, 32] }, notes: "Fast dry front option.", imagePath: "/images/tire/front/maxxis/dissector.png", sources: maxxisSources },
|
||||||
|
{ id: "maxxis-minion-front", category: "tire", position: "front", brand: "Maxxis", model: "Minion DHF", width: 2.5, grip: 9, rolling: 5, bestFor: ["enduro", "trail"], psi: { lt: [18, 23], md: [22, 27], hv: [25, 30] }, notes: "Predictable benchmark front.", imagePath: "/images/tire/front/maxxis/minion.png", sources: maxxisSources },
|
||||||
|
{ id: "schwalbe-magic-mary-front", category: "tire", position: "front", brand: "Schwalbe", model: "Magic Mary", width: 2.4, grip: 10, rolling: 3, bestFor: ["enduro", "wet"], psi: { lt: [16, 21], md: [20, 25], hv: [24, 29] }, notes: "Wet and soft-condition front.", sources: tireSources },
|
||||||
|
{ id: "schwalbe-albert-radial-front", category: "tire", position: "front", brand: "Schwalbe", model: "Albert Radial", width: 2.35, grip: 8, rolling: 6, bestFor: ["trail", "enduro"], psi: { lt: [19, 24], md: [22, 27], hv: [26, 30] }, notes: "Supple radial casing.", imagePath: "/images/tire/front/schwalbe/albert.png", sources: tireSources },
|
||||||
|
{ id: "specialized-eliminator-front", category: "tire", position: "front", brand: "Specialized", model: "Eliminator", width: 2.3, grip: 7, rolling: 7, bestFor: ["trail", "xc"], psi: { lt: [21, 26], md: [24, 29], hv: [28, 33] }, notes: "Efficient front option.", sources: tireSources },
|
||||||
|
{ id: "specialized-butcher-front", category: "tire", position: "front", brand: "Specialized", model: "Butcher", width: 2.3, grip: 8, rolling: 6, bestFor: ["enduro", "trail"], psi: { lt: [20, 25], md: [23, 28], hv: [26, 31] }, notes: "Aggressive daily front.", sources: tireSources },
|
||||||
|
{ id: "maxxis-assegai-rear", category: "tire", position: "rear", brand: "Maxxis", model: "Assegai", width: 2.5, grip: 9, rolling: 4, bestFor: ["enduro"], psi: { lt: [22, 27], md: [25, 30], hv: [29, 34] }, notes: "Very grippy but slow rear.", imagePath: "/images/tire/rear/maxxis/assegai.png", sources: maxxisSources },
|
||||||
|
{ id: "maxxis-dissector-rear", category: "tire", position: "rear", brand: "Maxxis", model: "Dissector", width: 2.4, grip: 7, rolling: 8, bestFor: ["trail", "xc", "dry"], psi: { lt: [22, 27], md: [26, 31], hv: [30, 35] }, notes: "Fast rolling rear.", imagePath: "/images/tire/rear/maxxis/dissector.png", sources: maxxisSources },
|
||||||
|
{ id: "maxxis-minion-rear", category: "tire", position: "rear", brand: "Maxxis", model: "Minion DHR II", width: 2.4, grip: 8, rolling: 6, bestFor: ["enduro", "trail"], psi: { lt: [21, 26], md: [24, 29], hv: [28, 33] }, notes: "Balanced rear traction.", imagePath: "/images/tire/rear/maxxis/minion.png", sources: maxxisSources },
|
||||||
|
{ id: "schwalbe-magic-mary-rear", category: "tire", position: "rear", brand: "Schwalbe", model: "Magic Mary", width: 2.4, grip: 9, rolling: 3, bestFor: ["enduro", "wet"], psi: { lt: [20, 25], md: [23, 28], hv: [27, 32] }, notes: "Wet rear traction.", sources: tireSources },
|
||||||
|
{ id: "schwalbe-albert-radial-rear", category: "tire", position: "rear", brand: "Schwalbe", model: "Albert Radial", width: 2.35, grip: 7, rolling: 7, bestFor: ["trail", "enduro"], psi: { lt: [21, 26], md: [24, 29], hv: [27, 32] }, notes: "Forgiving all-conditions rear.", imagePath: "/images/tire/rear/schwalbe/albert.png", sources: tireSources },
|
||||||
|
{ id: "specialized-eliminator-rear", category: "tire", position: "rear", brand: "Specialized", model: "Eliminator", width: 2.3, grip: 7, rolling: 8, bestFor: ["trail", "xc"], psi: { lt: [23, 28], md: [26, 31], hv: [30, 35] }, notes: "Fast trail rear.", sources: tireSources },
|
||||||
|
{ id: "specialized-butcher-rear", category: "tire", position: "rear", brand: "Specialized", model: "Butcher", width: 2.3, grip: 8, rolling: 7, bestFor: ["enduro", "trail"], psi: { lt: [21, 26], md: [24, 29], hv: [27, 33] }, notes: "Balanced rear option.", sources: tireSources },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const catalog = { frames, forks, shocks, tires };
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
type Database = {
|
||||||
|
builds: any[];
|
||||||
|
reviews: any[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const dbPath = path.join(process.cwd(), "data", "bike-app.json");
|
||||||
|
|
||||||
|
function readDb(): Database {
|
||||||
|
mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(dbPath, "utf8")) as Database;
|
||||||
|
} catch {
|
||||||
|
return { builds: [], reviews: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeDb(data: Database) {
|
||||||
|
mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||||
|
writeFileSync(dbPath, JSON.stringify(data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const store = {
|
||||||
|
listBuilds() {
|
||||||
|
return readDb().builds.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
|
||||||
|
},
|
||||||
|
createBuild(input: any) {
|
||||||
|
const data = readDb();
|
||||||
|
const build = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
...input,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
data.builds.unshift(build);
|
||||||
|
writeDb(data);
|
||||||
|
return build;
|
||||||
|
},
|
||||||
|
createReview(input: any) {
|
||||||
|
const data = readDb();
|
||||||
|
const review = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
...input,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
data.reviews.unshift(review);
|
||||||
|
writeDb(data);
|
||||||
|
return review;
|
||||||
|
},
|
||||||
|
preferences() {
|
||||||
|
const reviews = readDb().reviews;
|
||||||
|
return {
|
||||||
|
harshTendency: reviews.filter((review) => review.suspFeel >= 7).length,
|
||||||
|
softTendency: reviews.filter((review) => review.suspFeel <= 3).length,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
export { canvas, layerPaths } from "./layers";
|
||||||
|
|
||||||
|
export function publicPathToFile(publicPath: string) {
|
||||||
|
const clean = publicPath.replace(/^\/+/, "");
|
||||||
|
return path.join(process.cwd(), "public", clean);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { selectedProducts } from "./recommendations";
|
||||||
|
import type { BuildSelection } from "./types";
|
||||||
|
|
||||||
|
export const canvas = { width: 2400, height: 1464 };
|
||||||
|
|
||||||
|
export function layerPaths(selection: BuildSelection) {
|
||||||
|
const selected = selectedProducts(selection);
|
||||||
|
return [
|
||||||
|
"/images/common/cockpit.png",
|
||||||
|
"/images/common/seatpost.png",
|
||||||
|
selected.rearTire?.imagePath,
|
||||||
|
selected.frontTire?.imagePath,
|
||||||
|
selected.shock?.imagePath,
|
||||||
|
selected.fork?.imagePath,
|
||||||
|
selected.frame?.imagePath,
|
||||||
|
].filter(Boolean) as string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { frames, forks, shocks, tires } from "./catalog";
|
||||||
|
import type { BuildSelection, BuildSetup, DisciplineCategory, RiderProfile, WeightCategory } from "./types";
|
||||||
|
|
||||||
|
export const disciplines = ["XC / Marathon", "Trail", "Enduro", "DH / Park"];
|
||||||
|
export const riderLevels = ["Beginner", "Intermediate", "Advanced", "Expert / Racer"];
|
||||||
|
export const rideStyles = ["Smooth & Pedally", "Aggressive / Attack", "Tech-focused", "Flow & Jump", "Mixed"];
|
||||||
|
|
||||||
|
export function weightCategory(kg: number): WeightCategory {
|
||||||
|
if (kg < 65) return "lt";
|
||||||
|
if (kg < 85) return "md";
|
||||||
|
return "hv";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disciplineCategory(discipline: string): DisciplineCategory {
|
||||||
|
const text = discipline.toLowerCase();
|
||||||
|
if (text.includes("xc")) return "xc";
|
||||||
|
if (text.includes("enduro") || text.includes("dh")) return "enduro";
|
||||||
|
return "trail";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRecommended(productId: string, rider: RiderProfile) {
|
||||||
|
const dc = disciplineCategory(rider.discipline);
|
||||||
|
const frame = frames.find((item) => item.id === productId);
|
||||||
|
if (frame) return frame.bestFor.includes(dc) || frame.bestFor.includes("trail");
|
||||||
|
|
||||||
|
const fork = forks.find((item) => item.id === productId);
|
||||||
|
if (fork) {
|
||||||
|
const minTravel = dc === "enduro" ? 150 : dc === "trail" ? 130 : 110;
|
||||||
|
const maxTravel = dc === "xc" ? 140 : 200;
|
||||||
|
return fork.travels.some((travel) => travel >= minTravel && travel <= maxTravel);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shock = shocks.find((item) => item.id === productId);
|
||||||
|
if (shock) return !(dc === "xc" && shock.type === "coil");
|
||||||
|
|
||||||
|
const tire = tires.find((item) => item.id === productId);
|
||||||
|
if (tire) return tire.bestFor.includes(dc) || (tire.position === "front" && tire.bestFor.includes("trail"));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSetupDefaults(selection: BuildSelection, rider: RiderProfile): BuildSetup {
|
||||||
|
const wc = weightCategory(rider.weight);
|
||||||
|
const dc = disciplineCategory(rider.discipline);
|
||||||
|
const levelMod = rider.level === "Expert / Racer" ? 2 : rider.level === "Advanced" ? 1 : 0;
|
||||||
|
const fork = forks.find((item) => item.id === selection.forkId);
|
||||||
|
const shock = shocks.find((item) => item.id === selection.shockId);
|
||||||
|
const frontTire = tires.find((item) => item.id === selection.frontTireId);
|
||||||
|
const rearTire = tires.find((item) => item.id === selection.rearTireId);
|
||||||
|
|
||||||
|
const midpoint = (range?: [number, number], mod = 0) => range ? Math.round((range[0] + range[1]) / 2 + mod) : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
forkPsi: midpoint(fork?.psi[wc], levelMod * 2),
|
||||||
|
forkSag: fork?.sag[dc] ?? 25,
|
||||||
|
forkRebound: fork ? Math.max(0, fork.rebound[wc] - levelMod) : undefined,
|
||||||
|
forkLsc: fork ? fork.lsc[wc] + levelMod : undefined,
|
||||||
|
forkHsc: fork?.hsc ? fork.hsc[wc] + levelMod : undefined,
|
||||||
|
forkTokens: fork?.tokens[wc],
|
||||||
|
shockPsi: shock?.type === "air" ? midpoint(shock.psi?.[wc], levelMod * 3) : undefined,
|
||||||
|
shockSpring: shock?.type === "coil" ? shock.spring?.[wc] : undefined,
|
||||||
|
shockSag: shock?.sag[dc] ?? 28,
|
||||||
|
shockRebound: shock ? Math.max(0, shock.rebound[wc] - levelMod) : undefined,
|
||||||
|
shockLsc: shock ? shock.lsc[wc] + levelMod : undefined,
|
||||||
|
shockHsc: shock?.hsc ? shock.hsc[wc] + levelMod : undefined,
|
||||||
|
shockTokens: shock?.tokens?.[wc],
|
||||||
|
frontTirePsi: midpoint(frontTire?.psi[wc]),
|
||||||
|
rearTirePsi: midpoint(rearTire?.psi[wc]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectedProducts(selection: BuildSelection) {
|
||||||
|
return {
|
||||||
|
frame: frames.find((item) => item.id === selection.frameId),
|
||||||
|
fork: forks.find((item) => item.id === selection.forkId),
|
||||||
|
shock: shocks.find((item) => item.id === selection.shockId),
|
||||||
|
frontTire: tires.find((item) => item.id === selection.frontTireId),
|
||||||
|
rearTire: tires.find((item) => item.id === selection.rearTireId),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
export type WeightCategory = "lt" | "md" | "hv";
|
||||||
|
export type DisciplineCategory = "xc" | "trail" | "enduro";
|
||||||
|
|
||||||
|
export type RiderProfile = {
|
||||||
|
weight: number;
|
||||||
|
discipline: string;
|
||||||
|
level: string;
|
||||||
|
style: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SourceRef = {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
kind: "manufacturer" | "community" | "placeholder";
|
||||||
|
};
|
||||||
|
|
||||||
|
type SetupRange = Record<WeightCategory, [number, number]>;
|
||||||
|
type SetupByDiscipline = Record<DisciplineCategory, number>;
|
||||||
|
type SetupByWeight = Record<WeightCategory, number>;
|
||||||
|
|
||||||
|
export type FrameProduct = {
|
||||||
|
id: string;
|
||||||
|
category: "frame";
|
||||||
|
brand: string;
|
||||||
|
model: string;
|
||||||
|
travel: number;
|
||||||
|
headAngle: number;
|
||||||
|
geometry: DisciplineCategory;
|
||||||
|
bestFor: DisciplineCategory[];
|
||||||
|
description: string;
|
||||||
|
imagePath?: string;
|
||||||
|
sources: SourceRef[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ForkProduct = {
|
||||||
|
id: string;
|
||||||
|
category: "fork";
|
||||||
|
brand: string;
|
||||||
|
model: string;
|
||||||
|
travels: number[];
|
||||||
|
damper: string;
|
||||||
|
psi: SetupRange;
|
||||||
|
sag: SetupByDiscipline;
|
||||||
|
rebound: SetupByWeight;
|
||||||
|
lsc: SetupByWeight;
|
||||||
|
hsc?: SetupByWeight;
|
||||||
|
tokens: SetupByWeight;
|
||||||
|
notes: string;
|
||||||
|
imagePath?: string;
|
||||||
|
sources: SourceRef[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShockProduct = {
|
||||||
|
id: string;
|
||||||
|
category: "shock";
|
||||||
|
brand: string;
|
||||||
|
model: string;
|
||||||
|
type: "air" | "coil";
|
||||||
|
psi?: SetupRange;
|
||||||
|
sag: SetupByDiscipline;
|
||||||
|
rebound: SetupByWeight;
|
||||||
|
lsc: SetupByWeight;
|
||||||
|
hsc?: SetupByWeight;
|
||||||
|
tokens?: SetupByWeight;
|
||||||
|
spring?: Record<WeightCategory, string>;
|
||||||
|
notes: string;
|
||||||
|
imagePath?: string;
|
||||||
|
sources: SourceRef[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TireProduct = {
|
||||||
|
id: string;
|
||||||
|
category: "tire";
|
||||||
|
position: "front" | "rear";
|
||||||
|
brand: string;
|
||||||
|
model: string;
|
||||||
|
width: number;
|
||||||
|
grip: number;
|
||||||
|
rolling: number;
|
||||||
|
bestFor: string[];
|
||||||
|
psi: SetupRange;
|
||||||
|
notes: string;
|
||||||
|
imagePath?: string;
|
||||||
|
sources: SourceRef[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Product = FrameProduct | ForkProduct | ShockProduct | TireProduct;
|
||||||
|
|
||||||
|
export type BuildSelection = {
|
||||||
|
name: string;
|
||||||
|
frameId?: string;
|
||||||
|
forkId?: string;
|
||||||
|
shockId?: string;
|
||||||
|
frontTireId?: string;
|
||||||
|
rearTireId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BuildSetup = {
|
||||||
|
forkPsi?: number;
|
||||||
|
forkSag: number;
|
||||||
|
forkRebound?: number;
|
||||||
|
forkLsc?: number;
|
||||||
|
forkHsc?: number;
|
||||||
|
forkTokens?: number;
|
||||||
|
shockPsi?: number;
|
||||||
|
shockSpring?: string;
|
||||||
|
shockSag: number;
|
||||||
|
shockRebound?: number;
|
||||||
|
shockLsc?: number;
|
||||||
|
shockHsc?: number;
|
||||||
|
shockTokens?: number;
|
||||||
|
frontTirePsi?: number;
|
||||||
|
rearTirePsi?: number;
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
"prisma/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"BSU"
|
||||||
|
]
|
||||||
|
}
|
||||||