1310 lines
75 KiB
React
1310 lines
75 KiB
React
import { useState, useEffect } from "react";
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
// STYLES — deep forest aesthetic
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
const STYLES = `
|
||
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=IBM+Plex+Mono:wght@400;500;700&display=swap');
|
||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||
:root{
|
||
--bg:#070f04; --bg2:#0d160a; --bg3:#131e0e;
|
||
--panel:#192512; --panel2:#1e2d16;
|
||
--bdr:#233518; --bdr2:#334825; --bdr3:#4a6535;
|
||
--fern:#5a9635; --sage:#7ab840; --lime:#a0d855;
|
||
--moss:#3d6e22; --bark:#6a4218; --earth:#a06828;
|
||
--txt:#b8cca0; --txt2:#7aaa55; --txt3:#4a6835; --txt4:#2e4220;
|
||
--red:#b83828; --amb:#c07820;
|
||
--fD:'Bebas Neue',sans-serif;
|
||
--fM:'IBM Plex Mono',monospace;
|
||
}
|
||
body{background:var(--bg);color:var(--txt);font-family:var(--fM);min-height:100vh;overflow-x:hidden}
|
||
|
||
/* Grain */
|
||
body::before{content:'';position:fixed;inset:0;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.04'/%3E%3C/svg%3E");pointer-events:none;z-index:9998;opacity:.6}
|
||
|
||
.app{max-width:1000px;margin:0 auto;padding:0 14px 80px}
|
||
|
||
/* Header */
|
||
.hdr{padding:22px 0 16px;border-bottom:1px solid var(--bdr);margin-bottom:18px;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
|
||
.logo{display:flex;align-items:center;gap:10px}
|
||
.logo-leaf{width:40px;height:40px;background:var(--fern);clip-path:polygon(50%0%,85%15%,100%50%,85%85%,50%100%,15%85%,0%50%,15%15%);display:flex;align-items:center;justify-content:center;font-family:var(--fD);font-size:22px;color:#050c02;line-height:1;flex-shrink:0}
|
||
.logo h1{font-family:var(--fD);font-size:clamp(28px,7vw,44px);letter-spacing:5px;line-height:1;color:var(--txt)}
|
||
.hdr-sub{font-size:9px;letter-spacing:3px;text-transform:uppercase;color:var(--fern);margin-left:auto;align-self:flex-end;padding-bottom:3px}
|
||
|
||
/* Tabs */
|
||
.tabs{display:flex;gap:2px;margin-bottom:20px;background:var(--bg2);border:1px solid var(--bdr);padding:3px;overflow-x:auto}
|
||
.tab{flex:1;min-width:70px;padding:8px 4px;background:transparent;border:none;cursor:pointer;font-family:var(--fD);font-size:clamp(12px,2vw,15px);letter-spacing:1.5px;text-transform:uppercase;color:var(--txt4);transition:all .15s;white-space:nowrap;text-align:center}
|
||
.tab:hover{color:var(--txt3);background:var(--bg3)}
|
||
.tab.on{background:var(--fern);color:#050c02}
|
||
|
||
/* Buttons */
|
||
.btn{padding:8px 14px;border:1px solid var(--bdr2);background:var(--bg3);color:var(--txt3);font-family:var(--fM);font-size:10px;font-weight:700;letter-spacing:1px;text-transform:uppercase;cursor:pointer;transition:all .15s}
|
||
.btn:hover{border-color:var(--fern);color:var(--sage)}
|
||
.btn:disabled{opacity:.35;cursor:not-allowed}
|
||
.btn-p{background:var(--fern);color:#050c02;border-color:var(--fern);font-weight:700}
|
||
.btn-p:hover{background:var(--sage);border-color:var(--sage);color:#050c02}
|
||
.btn-sm{padding:5px 9px;font-size:9px}
|
||
.btn-d:hover{border-color:var(--red);color:var(--red)}
|
||
|
||
/* Forms */
|
||
.field{display:flex;flex-direction:column;gap:4px}
|
||
.field label{font-size:9px;letter-spacing:2px;text-transform:uppercase;color:var(--txt3);font-weight:700}
|
||
.field input,.field select,.field textarea{background:var(--bg2);border:1px solid var(--bdr);color:var(--txt);font-family:var(--fM);font-size:12px;padding:8px 10px;outline:none;transition:border-color .15s;width:100%}
|
||
.field input:focus,.field select:focus,.field textarea:focus{border-color:var(--fern)}
|
||
.field select{cursor:pointer}
|
||
.field select option{background:var(--bg2)}
|
||
.field textarea{resize:vertical;min-height:70px;line-height:1.5}
|
||
.field .hint{font-size:9px;color:var(--txt4);letter-spacing:.5px;margin-top:2px}
|
||
|
||
/* Grid */
|
||
.g2{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
|
||
.g3{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px}
|
||
.g4{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:8px}
|
||
.full{grid-column:1/-1}
|
||
|
||
/* Section header */
|
||
.sH{font-family:var(--fD);font-size:12px;letter-spacing:2.5px;color:var(--txt3);padding:16px 0 8px;border-bottom:1px solid var(--bdr);margin-bottom:10px;display:flex;align-items:center;gap:8px}
|
||
.sH::before{content:'';display:block;width:3px;height:12px;background:var(--fern);flex-shrink:0}
|
||
|
||
/* Component card */
|
||
.cCard{background:var(--bg3);border:1px solid var(--bdr);padding:12px;cursor:pointer;transition:all .2s;position:relative;user-select:none}
|
||
.cCard:hover{border-color:var(--moss);background:var(--panel)}
|
||
.cCard.sel{border-color:var(--fern);background:var(--panel)}
|
||
.cCard .cBrand{font-size:8px;letter-spacing:2px;text-transform:uppercase;color:var(--txt3)}
|
||
.cCard .cModel{font-family:var(--fD);font-size:19px;letter-spacing:1px;color:var(--txt);line-height:1.1;margin:2px 0 6px}
|
||
.cCard .cTags{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px}
|
||
.cCard .cTag{font-size:8px;letter-spacing:1px;text-transform:uppercase;padding:2px 5px;background:var(--bg2);border:1px solid var(--bdr2);color:var(--txt4)}
|
||
.cCard .cRec{border-color:var(--fern);color:var(--fern)}
|
||
.cCard .cCheck{position:absolute;top:8px;right:8px;width:14px;height:14px;background:var(--fern);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:#050c02;font-weight:900;line-height:1}
|
||
.cCard .cNote{font-size:9px;color:var(--txt3);line-height:1.4;margin-top:6px;border-top:1px solid var(--bdr);padding-top:6px}
|
||
|
||
/* Rider profile */
|
||
.riderBar{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px;background:var(--panel);border:1px solid var(--bdr);padding:14px;margin-bottom:18px}
|
||
|
||
/* Slider */
|
||
.slW{display:flex;flex-direction:column;gap:4px}
|
||
.slH{display:flex;justify-content:space-between;align-items:baseline}
|
||
.slH span:first-child{font-size:9px;letter-spacing:1.5px;text-transform:uppercase;color:var(--txt3);font-weight:700}
|
||
.slV{font-family:var(--fD);font-size:18px;letter-spacing:1px;color:var(--sage)}
|
||
.slU{font-size:9px;color:var(--txt3);margin-left:2px;font-family:var(--fM)}
|
||
input[type=range]{width:100%;-webkit-appearance:none;appearance:none;height:2px;background:var(--bdr2);outline:none;cursor:pointer;margin:4px 0}
|
||
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;background:var(--fern);cursor:pointer;clip-path:polygon(50%0%,100%50%,50%100%,0%50%)}
|
||
|
||
/* Spec blocks */
|
||
.specs{display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:8px;margin:10px 0}
|
||
.spB{background:var(--bg2);border:1px solid var(--bdr);padding:10px 12px}
|
||
.spL{font-size:8px;letter-spacing:1.5px;text-transform:uppercase;color:var(--txt3);margin-bottom:3px}
|
||
.spV{font-family:var(--fD);font-size:22px;letter-spacing:1px;color:var(--sage);line-height:1}
|
||
.spU{font-size:9px;color:var(--txt3);margin-left:2px;font-family:var(--fM)}
|
||
|
||
/* Meter bars (grip, feel etc) */
|
||
.mBar{display:flex;align-items:center;gap:8px;margin:3px 0}
|
||
.mLabel{font-size:9px;color:var(--txt3);width:90px;letter-spacing:.5px;text-transform:uppercase;flex-shrink:0}
|
||
.mTrack{flex:1;height:4px;background:var(--bdr2);border-radius:0;overflow:hidden}
|
||
.mFill{height:100%;transition:width .3s}
|
||
|
||
/* AI output */
|
||
.aiOut{background:var(--bg2);border:1px solid var(--bdr);border-left:3px solid var(--fern);padding:14px 16px;font-size:12px;line-height:1.9;color:var(--txt);white-space:pre-wrap;max-height:420px;overflow-y:auto;margin-top:12px}
|
||
.thinking{display:flex;align-items:center;gap:6px;color:var(--fern);font-size:11px;letter-spacing:1px;padding:12px 0}
|
||
.dot{width:6px;height:6px;background:var(--fern);border-radius:50%;animation:bl 1.2s infinite}
|
||
.dot:nth-child(2){animation-delay:.2s}.dot:nth-child(3){animation-delay:.4s}
|
||
@keyframes bl{0%,100%{opacity:1}50%{opacity:.15}}
|
||
|
||
/* Garage */
|
||
.gGrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:10px}
|
||
.gCard{background:var(--panel);border:1px solid var(--bdr);border-left:3px solid var(--moss);padding:14px;cursor:pointer;transition:all .15s}
|
||
.gCard:hover{border-left-color:var(--fern);background:var(--panel2)}
|
||
.gBike{font-family:var(--fD);font-size:20px;letter-spacing:1px;text-transform:uppercase;color:var(--txt)}
|
||
.gSub{font-size:10px;color:var(--txt3);letter-spacing:1px;margin:2px 0 8px}
|
||
.gPills{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:8px}
|
||
.pill{display:inline-block;font-size:8px;letter-spacing:1px;text-transform:uppercase;padding:2px 6px;background:var(--bg3);border:1px solid var(--bdr2);color:var(--txt4)}
|
||
.pill-g{border-color:var(--moss);color:var(--fern)}
|
||
.pill-e{border-color:var(--earth);color:var(--amb)}
|
||
|
||
/* Toast */
|
||
.toast{position:fixed;bottom:20px;right:18px;background:var(--fern);color:#050c02;font-family:var(--fM);font-size:10px;font-weight:700;letter-spacing:1px;text-transform:uppercase;padding:9px 16px;z-index:10000;animation:ti .2s ease}
|
||
@keyframes ti{from{transform:translateY(14px);opacity:0}to{transform:translateY(0);opacity:1}}
|
||
|
||
/* Notice */
|
||
.notice{font-size:10px;color:var(--txt3);line-height:1.6;padding:10px 12px;background:var(--bg3);border-left:2px solid var(--bdr3)}
|
||
|
||
/* Feel slider */
|
||
.feelRow{display:grid;grid-template-columns:120px 1fr 32px;gap:8px;align-items:center;margin:6px 0}
|
||
.feelLabel{font-size:9px;letter-spacing:1px;text-transform:uppercase;color:var(--txt3)}
|
||
.feelScore{font-size:12px;font-weight:700;color:var(--sage);text-align:right;font-family:var(--fD)}
|
||
|
||
/* Misc */
|
||
.flex{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
||
.flexE{display:flex;justify-content:flex-end;gap:8px;flex-wrap:wrap}
|
||
.mt{margin-top:12px} .mt2{margin-top:20px} .mb{margin-bottom:10px}
|
||
.divider{height:1px;background:var(--bdr);margin:18px 0}
|
||
.empty{text-align:center;padding:50px 20px;color:var(--txt3)}
|
||
.empty-i{font-size:42px;margin-bottom:12px;opacity:.3}
|
||
.empty h3{font-family:var(--fD);font-size:22px;letter-spacing:2px;text-transform:uppercase;color:var(--txt2);margin-bottom:6px}
|
||
.empty p{font-size:11px;line-height:1.6}
|
||
@media(max-width:540px){
|
||
.tabs{flex-wrap:wrap}
|
||
.tab{flex:none}
|
||
.g2,.g3,.g4{grid-template-columns:1fr}
|
||
.riderBar{grid-template-columns:1fr 1fr}
|
||
.feelRow{grid-template-columns:80px 1fr 28px}
|
||
}
|
||
`;
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
// COMPONENT DATABASE (curated from manufacturer specs + community data)
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
const FRAMES = [
|
||
{ id:'tr-sentinel', brand:'Transition', model:'Sentinel', travel:140, ha:64.0, color:'#e07020', geo:'enduro',
|
||
bestFor:['enduro','trail'], desc:'Aggressive trail / light enduro. Progressive geometry, great for technical terrain.' },
|
||
{ id:'tr-scout', brand:'Transition', model:'Scout', travel:130, ha:65.5, color:'#2060c8', geo:'trail',
|
||
bestFor:['trail','xc'], desc:'Playful short-travel trail weapon. Efficient climber, fun descender.' },
|
||
{ id:'no-sight', brand:'Norco', model:'Sight VLT', travel:140, ha:64.0, color:'#c02020', geo:'enduro',
|
||
bestFor:['enduro','trail'], desc:'Full-power e-MTB enduro. Long, low, slack geometry. (Electric)' },
|
||
{ id:'ca-spectral', brand:'Canyon', model:'Spectral', travel:140, ha:64.5, color:'#1a1a1a', geo:'trail',
|
||
bestFor:['trail','enduro'], desc:'Versatile all-mountain. Balanced pedaling efficiency and descending capability.' },
|
||
{ id:'la-coal', brand:'Last', model:'Coal', travel:150, ha:63.5, color:'#384040', geo:'enduro',
|
||
bestFor:['enduro'], desc:'Austrian enduro machine. Very long and slack, huge travel, shreds everything.' },
|
||
{ id:'ev-calling', brand:'Evil', model:'The Calling', travel:140, ha:65.0, color:'#c02838', geo:'trail',
|
||
bestFor:['trail','enduro'], desc:'High-performance trail bike. Sharp, responsive handling with huge capability.' },
|
||
];
|
||
|
||
const FORKS = [
|
||
{ id:'mn-mezzer', brand:'Manitou', model:'Mezzer', travels:[140,150,160], damper:'IsoSpeed', fcolor:'#9090a0',
|
||
psi:{ lt:[60,72], md:[70,84], hv:[82,100] }, sag:{ xc:20, trail:25, enduro:27 },
|
||
reb:{ lt:14, md:11, hv:9 }, lsc:{ lt:5, md:7, hv:9 }, hsc:{ lt:3, md:4, hv:5 }, tok:{ lt:0, md:1, hv:2 },
|
||
notes:'Superb small-bump. IsoSpeed damper is very tunable. Lighter than Fox/RS alternatives.' },
|
||
{ id:'fx-38', brand:'Fox', model:'38 Factory', travels:[160,170,180], damper:'GRIP2', fcolor:'#f06010',
|
||
psi:{ lt:[65,78], md:[76,92], hv:[88,110] }, sag:{ xc:20, trail:25, enduro:28 },
|
||
reb:{ lt:13, md:11, hv:8 }, lsc:{ lt:6, md:8, hv:10 }, hsc:{ lt:3, md:4, hv:5 }, tok:{ lt:0, md:1, hv:2 },
|
||
notes:'Enduro/DH dedicated. Very stiff chassis. GRIP2 is industry-leading damper. Run 25% sag, low rebound.' },
|
||
{ id:'fx-36', brand:'Fox', model:'36 Factory', travels:[140,150,160], damper:'GRIP2', fcolor:'#f06010',
|
||
psi:{ lt:[60,74], md:[70,86], hv:[82,102] }, sag:{ xc:20, trail:25, enduro:27 },
|
||
reb:{ lt:13, md:11, hv:9 }, lsc:{ lt:5, md:7, hv:9 }, hsc:{ lt:2, md:3, hv:4 }, tok:{ lt:0, md:1, hv:2 },
|
||
notes:'All-mountain workhorse. Best balance of weight, stiffness and feel. Most popular enduro fork.' },
|
||
{ id:'fx-34', brand:'Fox', model:'34 Factory', travels:[120,130,140], damper:'GRIP2', fcolor:'#f06010',
|
||
psi:{ lt:[55,68], md:[64,80], hv:[76,94] }, sag:{ xc:20, trail:22, enduro:25 },
|
||
reb:{ lt:12, md:10, hv:8 }, lsc:{ lt:4, md:6, hv:8 }, hsc:{ lt:2, md:3, hv:4 }, tok:{ lt:0, md:0, hv:1 },
|
||
notes:'Lightweight XC/trail fork. Best paired with lighter frames and short-travel builds.' },
|
||
{ id:'rs-zeb', brand:'RockShox', model:'ZEB Ultimate', travels:[150,160,170,180], damper:'DebonAir+', fcolor:'#c8a010',
|
||
psi:{ lt:[62,76], md:[72,88], hv:[84,106] }, sag:{ xc:20, trail:25, enduro:28 },
|
||
reb:{ lt:14, md:12, hv:10 }, lsc:{ lt:5, md:7, hv:9 }, hsc:{ lt:3, md:4, hv:5 }, tok:{ lt:0, md:1, hv:2 },
|
||
notes:'DebonAir+ gives plush small-bump feel. Slightly more supple than Fox 38 for trail. Great enduro choice.' },
|
||
{ id:'rs-lyrik', brand:'RockShox', model:'Lyrik Ultimate', travels:[140,150,160], damper:'DebonAir+', fcolor:'#c8a010',
|
||
psi:{ lt:[58,72], md:[68,83], hv:[80,100] }, sag:{ xc:20, trail:25, enduro:28 },
|
||
reb:{ lt:14, md:12, hv:10 }, lsc:{ lt:4, md:6, hv:8 }, hsc:{ lt:2, md:3, hv:4 }, tok:{ lt:0, md:1, hv:2 },
|
||
notes:'Best all-rounder in its travel range. Plush + supportive. Pairs perfectly with Super Deluxe shock.' },
|
||
{ id:'rs-pike', brand:'RockShox', model:'Pike Ultimate', travels:[120,130,140], damper:'DebonAir+', fcolor:'#c8a010',
|
||
psi:{ lt:[55,68], md:[64,78], hv:[76,93] }, sag:{ xc:20, trail:22, enduro:25 },
|
||
reb:{ lt:13, md:11, hv:9 }, lsc:{ lt:3, md:5, hv:7 }, hsc:{ lt:2, md:3, hv:3 }, tok:{ lt:0, md:0, hv:1 },
|
||
notes:'Lightweight, responsive. Great XC/trail. Zero tokens to start — very progressive air spring.' },
|
||
];
|
||
|
||
const SHOCKS = [
|
||
{ id:'fx-x2', brand:'Fox', model:'X2 Factory', type:'air', hsr:true, hsc:true, scolor:'#f06010',
|
||
psi:{ lt:[140,158], md:[158,185], hv:[185,212] }, sag:{ xc:25, trail:28, enduro:31 },
|
||
reb:{ lt:12, md:10, hv:8 }, lsc:{ lt:5, md:7, hv:9 }, hsc:{ lt:3, md:4, hv:5 }, tok:{ lt:0, md:1, hv:2 },
|
||
notes:'Top Fox air shock. 4-way adjust (HSC/LSC/HSR/LSR). Best for technical high-speed terrain.' },
|
||
{ id:'fx-fx', brand:'Fox', model:'Float X Factory', type:'air', hsr:false, hsc:false, scolor:'#f06010',
|
||
psi:{ lt:[132,152], md:[152,178], hv:[178,205] }, sag:{ xc:25, trail:28, enduro:30 },
|
||
reb:{ lt:11, md:9, hv:7 }, lsc:{ lt:5, md:7, hv:9 }, hsc:null, tok:{ lt:0, md:1, hv:2 },
|
||
notes:'Simplified 2-way. Easier to set up than X2. Still very capable for trail/enduro use.' },
|
||
{ id:'rs-vivid', brand:'RockShox', model:'Vivid Air Ultimate', type:'air', hsr:true, hsc:true, scolor:'#c8a010',
|
||
psi:{ lt:[128,150], md:[150,178], hv:[178,208] }, sag:{ xc:25, trail:28, enduro:32 },
|
||
reb:{ lt:13, md:11, hv:9 }, lsc:{ lt:4, md:6, hv:8 }, hsc:{ lt:3, md:4, hv:5 }, tok:{ lt:0, md:1, hv:2 },
|
||
notes:'More supple off the top than Fox. DebonAir air spring pairs perfectly with Lyrik/ZEB fork.' },
|
||
{ id:'rs-sdu-air', brand:'RockShox', model:'Super Deluxe Ultimate', type:'air', hsr:false, hsc:true, scolor:'#c8a010',
|
||
psi:{ lt:[135,158], md:[158,184], hv:[184,212] }, sag:{ xc:25, trail:28, enduro:31 },
|
||
reb:{ lt:12, md:10, hv:8 }, lsc:{ lt:3, md:5, hv:7 }, hsc:{ lt:3, md:4, hv:5 }, tok:{ lt:0, md:1, hv:2 },
|
||
notes:'Proven OEM workhorse. Consistent, reliable. HSC/LSC on Factory version. Great trail shock.' },
|
||
{ id:'rs-sdu-coil', brand:'RockShox', model:'Super Deluxe Coil Ultimate', type:'coil', hsr:false, hsc:true, scolor:'#c8a010',
|
||
sag:{ xc:30, trail:33, enduro:36 },
|
||
reb:{ 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 = bottomless feel, no harshness on big hits. Select spring: lighter = softer. Requires respring to tune.' },
|
||
];
|
||
|
||
const TIRES = {
|
||
front: [
|
||
{ id:'mx-assegai-f', brand:'Maxxis', model:'Assegai', w:2.5, grip:10, roll:3, best:['enduro','dh','wet'],
|
||
psi:{ lt:[17,22], md:[21,26], hv:[25,30] }, notes:'Max grip, minimum rolling speed. Dominant enduro front tire worldwide.' },
|
||
{ id:'mx-dissector-f', brand:'Maxxis', model:'Dissector', w:2.4, grip:7, roll:7, best:['trail','xc','dry'],
|
||
psi:{ lt:[20,24], md:[23,28], hv:[27,32] }, notes:'Fast rolling, good dry grip. Best paired with Assegai/DHF.' },
|
||
{ id:'mx-minion-f', brand:'Maxxis', model:'Minion DHF', w:2.5, grip:9, roll:5, best:['enduro','trail','mixed'],
|
||
psi:{ lt:[18,23], md:[22,27], hv:[25,30] }, notes:'The benchmark. Predictable and capable in all conditions.' },
|
||
{ id:'sw-mary-f', brand:'Schwalbe', model:'Magic Mary', w:2.4, grip:10, roll:3, best:['enduro','dh','wet'],
|
||
psi:{ lt:[16,21], md:[20,25], hv:[24,29] }, notes:'Extraordinary wet traction. Soft compound, slow but grippy.' },
|
||
{ id:'sw-albert-f', brand:'Schwalbe', model:'Albert Radial', w:2.35, grip:8, roll:6, best:['trail','enduro'],
|
||
psi:{ lt:[19,24], md:[22,27], hv:[26,30] }, notes:'Radial casing = excellent suppleness. Versatile all-rounder.' },
|
||
{ id:'sp-elim-f', brand:'Specialized', model:'Eliminator', w:2.3, grip:7, roll:7, best:['trail','xc'],
|
||
psi:{ lt:[21,26], md:[24,29], hv:[28,33] }, notes:'Efficient XC/trail option. Good cornering, lower grip in wet.' },
|
||
{ id:'sp-butch-f', brand:'Specialized', model:'Butcher', w:2.3, grip:8, roll:6, best:['enduro','trail'],
|
||
psi:{ lt:[20,25], md:[23,28], hv:[26,31] }, notes:'Aggressive but not max grip. Solid daily enduro tire.' },
|
||
],
|
||
rear: [
|
||
{ id:'mx-assegai-r', brand:'Maxxis', model:'Assegai', w:2.5, grip:9, roll:4, best:['enduro','dh'],
|
||
psi:{ lt:[22,27], md:[25,30], hv:[29,34] }, notes:'Heavy but grippy rear. For very tech/loose terrain.' },
|
||
{ id:'mx-dissector-r', brand:'Maxxis', model:'Dissector', w:2.4, grip:7, roll:8, best:['trail','xc','dry'],
|
||
psi:{ lt:[22,27], md:[26,31], hv:[30,35] }, notes:'Recommended combo: Dissector rear + Assegai front.' },
|
||
{ id:'mx-minion-r', brand:'Maxxis', model:'Minion DHR II', w:2.4, grip:8, roll:6, best:['enduro','trail','mixed'],
|
||
psi:{ lt:[21,26], md:[24,29], hv:[28,33] }, notes:'Classic workhorse rear. Balanced traction and rolling speed.' },
|
||
{ id:'sw-mary-r', brand:'Schwalbe', model:'Magic Mary', w:2.4, grip:9, roll:3, best:['enduro','dh','wet'],
|
||
psi:{ lt:[20,25], md:[23,28], hv:[27,32] }, notes:'Wet conditions specialist rear. Very slow rolling.' },
|
||
{ id:'sw-albert-r', brand:'Schwalbe', model:'Albert Radial', w:2.35, grip:7, roll:7, best:['trail','enduro'],
|
||
psi:{ lt:[21,26], md:[24,29], hv:[27,32] }, notes:'Supple and forgiving. Good all-conditions rear option.' },
|
||
{ id:'sp-elim-r', brand:'Specialized', model:'Eliminator', w:2.3, grip:7, roll:8, best:['trail','xc'],
|
||
psi:{ lt:[23,28], md:[26,31], hv:[30,35] }, notes:'Fast XC rear. Good on hard-pack and gravel mix.' },
|
||
{ id:'sp-butch-r', brand:'Specialized', model:'Butcher', w:2.3, grip:8, roll:7, best:['enduro','trail'],
|
||
psi:{ lt:[21,26], md:[24,29], hv:[27,33] }, notes:'Balanced grip+roll. Reliable enduro/trail rear choice.' },
|
||
],
|
||
};
|
||
|
||
const DISC = ['XC / Marathon', 'Trail', 'Enduro', 'DH / Park'];
|
||
const LEVELS = ['Beginner', 'Intermediate', 'Advanced', 'Expert / Racer'];
|
||
const STYLES_R = ['Smooth & Pedally', 'Aggressive / Attack', 'Tech-focused', 'Flow & Jump', 'Mixed'];
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
// HELPERS
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
const wCat = (kg) => kg < 65 ? 'lt' : kg < 85 ? 'md' : 'hv';
|
||
const dCat = (d) => d.toLowerCase().includes('xc') ? 'xc' : d.toLowerCase().includes('enduro') || d.toLowerCase().includes('dh') ? 'enduro' : 'trail';
|
||
|
||
function isRecFork(fork, rider) {
|
||
const dc = dCat(rider.disc);
|
||
const wc = wCat(rider.weight);
|
||
const minT = dc === 'enduro' ? 150 : dc === 'trail' ? 130 : 110;
|
||
const maxT = dc === 'xc' ? 140 : 200;
|
||
return fork.travels.some(t => t >= minT && t <= maxT);
|
||
}
|
||
function isRecShock(shock, rider) {
|
||
const dc = dCat(rider.disc);
|
||
if (dc === 'xc' && shock.type === 'coil') return false;
|
||
if (dc === 'dh' && shock.id === 'fx-fx') return false;
|
||
return true;
|
||
}
|
||
function isRecFrame(frame, rider) {
|
||
const dc = dCat(rider.disc);
|
||
return frame.bestFor.some(b => b === dc || b === 'trail');
|
||
}
|
||
function isRecTire(tire, rider, pos) {
|
||
const dc = dCat(rider.disc);
|
||
return tire.best.some(b => b === dc || (pos === 'front' && b === 'mixed'));
|
||
}
|
||
|
||
function getSetupDefaults(build, rider) {
|
||
const wc = wCat(rider.weight);
|
||
const dc = dCat(rider.disc);
|
||
const fork = build.fork;
|
||
const shock = build.shock;
|
||
const ft = build.frontTire;
|
||
const rt = build.rearTire;
|
||
const lvlMod = { 'Beginner':0, 'Intermediate':0, 'Advanced':1, 'Expert / Racer':2 }[rider.level] || 0;
|
||
|
||
// Fork PSI midpoint + level adjustment
|
||
const fPsiRange = fork?.psi?.[wc];
|
||
const fPsi = fPsiRange ? Math.round((fPsiRange[0] + fPsiRange[1]) / 2 + lvlMod * 2) : null;
|
||
const fSag = fork?.sag?.[dc] ?? 25;
|
||
const fReb = fork ? Math.max(0, (fork.reb[wc] || 10) - lvlMod) : null;
|
||
const fLsc = fork ? Math.min(20, (fork.lsc[wc] || 6) + lvlMod) : null;
|
||
const fHsc = fork?.hsc ? Math.min(10, (fork.hsc[wc] || 3) + lvlMod) : null;
|
||
const fTok = fork?.tok?.[wc] ?? 0;
|
||
|
||
const sPsiRange = shock?.type === 'air' ? shock.psi?.[wc] : null;
|
||
const sPsi = sPsiRange ? Math.round((sPsiRange[0] + sPsiRange[1]) / 2 + lvlMod * 3) : null;
|
||
const sSag = shock?.sag?.[dc] ?? 28;
|
||
const sReb = shock ? Math.max(0, (shock.reb[wc] || 10) - lvlMod) : null;
|
||
const sLsc = shock ? Math.min(20, (shock.lsc[wc] || 5) + lvlMod) : null;
|
||
const sHsc = shock?.hsc ? Math.min(10, (shock.hsc[wc] || 3) + lvlMod) : null;
|
||
const sTok = shock?.tok?.[wc] ?? 0;
|
||
|
||
const ftPsiRange = ft?.psi?.[wc];
|
||
const fTirePsi = ftPsiRange ? Math.round((ftPsiRange[0] + ftPsiRange[1]) / 2) : null;
|
||
const rtPsiRange = rt?.psi?.[wc];
|
||
const rTirePsi = rtPsiRange ? Math.round((rtPsiRange[0] + rtPsiRange[1]) / 2) : null;
|
||
|
||
return { fPsi, fSag, fReb, fLsc, fHsc, fTok, sPsi, sSag, sReb, sLsc, sHsc, sTok, fTirePsi, rTirePsi };
|
||
}
|
||
|
||
// Storage
|
||
const SK = 'loam-v2';
|
||
async function loadData() {
|
||
try { const r = await window.storage.get(SK); return r ? JSON.parse(r.value) : {}; } catch { return {}; }
|
||
}
|
||
async function saveData(data) {
|
||
try { await window.storage.set(SK, JSON.stringify(data)); } catch {}
|
||
}
|
||
|
||
// AI call — Anthropic or Ollama
|
||
async function callAI(userMsg, systemMsg, config) {
|
||
if (config.backend === 'ollama') {
|
||
const res = await fetch(`${config.ollamaUrl}/api/chat`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ model: config.model, stream: false, messages: [
|
||
{ role: 'system', content: systemMsg },
|
||
{ role: 'user', content: userMsg }
|
||
]})
|
||
});
|
||
if (!res.ok) throw new Error(`Ollama error ${res.status} — is the server running?`);
|
||
const d = await res.json();
|
||
return d.message?.content || d.response || '';
|
||
} else {
|
||
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ model:'claude-sonnet-4-20250514', max_tokens:1000,
|
||
system: systemMsg, messages:[{ role:'user', content:userMsg }] })
|
||
});
|
||
if (!res.ok) throw new Error(`API error ${res.status}`);
|
||
const d = await res.json();
|
||
return d.content?.[0]?.text || '';
|
||
}
|
||
}
|
||
|
||
const AI_SYS = `You are a professional mountain bike suspension tuner with deep hands-on expertise in Fox, RockShox, Manitou, and Öhlins products.
|
||
You combine manufacturer specs with real-world community setups from forums like Pinkbike, Vital MTB, and MTBR.
|
||
Be specific, practical, and technical. Use metric where possible but mention PSI/clicks.
|
||
Format responses with clear section headers using ▸ symbol and short numbered lists.
|
||
Keep recommendations actionable — give exact values, not ranges when possible.`;
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
// BIKE VISUALIZER SVG
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
function BikeVisualizer({ build }) {
|
||
const { frame, fork, shock, frontTire, rearTire } = build;
|
||
const W = 560, H = 310;
|
||
|
||
const ha = frame?.ha || 65;
|
||
const slack = Math.max(0, Math.min(1, (65 - ha) / 2.0));
|
||
const ft = fork ? fork.travels[Math.floor(fork.travels.length / 2)] : 150;
|
||
const forkScale = 0.86 + (ft - 130) / 70 * 0.18;
|
||
const isCoil = shock?.type === 'coil';
|
||
const fc = frame?.color || '#3a5828';
|
||
const fkc = fork?.fcolor || '#4a6035';
|
||
const shc = shock?.scolor || '#3a4828';
|
||
const rtw = Math.round(((rearTire?.w || 2.4) - 2.15) * 32 + 10);
|
||
const ftw = Math.round(((frontTire?.w || 2.4) - 2.15) * 32 + 10);
|
||
|
||
// Key points
|
||
const RW = [116, 224];
|
||
const FWx = 388 + slack * 10;
|
||
const FW = [FWx, 224];
|
||
const WR = 72;
|
||
const BB = [204, 228];
|
||
const HTT = [342 - slack * 8, 114 + slack * 5];
|
||
const HTB = [356 - slack * 6, 148 + slack * 4];
|
||
const STT = [200, 100];
|
||
const SSJ = [202, 140]; // seat stay junction
|
||
|
||
// Fork end scaling
|
||
const fDx = (FW[0] - HTB[0]) * forkScale;
|
||
const fDy = (FW[1] - HTB[1]) * forkScale;
|
||
const fEnd = [HTB[0] + fDx, HTB[1] + fDy];
|
||
|
||
// Fork arch (junction upper/lower legs)
|
||
const archF = 0.38;
|
||
const arch = [HTB[0] + fDx * archF, HTB[1] + fDy * archF];
|
||
|
||
// Fork perpendicular offset (for two-leg visual)
|
||
const fAngle = Math.atan2(fDy, fDx);
|
||
const pOff = [Math.sin(fAngle) * 9, -Math.cos(fAngle) * 9];
|
||
|
||
// Shock mounts
|
||
const ShT = [252, 150];
|
||
const ShB = [226, 195];
|
||
const shMid = [(ShT[0]+ShB[0])/2, (ShT[1]+ShB[1])/2];
|
||
|
||
const spokes = (cx, cy, r1, r2, n=8) =>
|
||
Array.from({length:n}, (_,i) => {
|
||
const a = i * Math.PI * 2 / n + 0.2;
|
||
return <line key={i}
|
||
x1={cx+Math.cos(a)*r1} y1={cy+Math.sin(a)*r1}
|
||
x2={cx+Math.cos(a)*r2} y2={cy+Math.sin(a)*r2}
|
||
stroke="#1e2d14" strokeWidth="1" />;
|
||
});
|
||
|
||
const hasAny = frame || fork || shock || frontTire || rearTire;
|
||
|
||
return (
|
||
<svg viewBox={`0 0 ${W} ${H}`} style={{width:'100%',maxHeight:275,display:'block'}}>
|
||
<defs>
|
||
<radialGradient id="bgG" cx="50%" cy="68%" r="52%">
|
||
<stop offset="0%" stopColor="#142010" />
|
||
<stop offset="100%" stopColor="#070f04" />
|
||
</radialGradient>
|
||
<linearGradient id="fkG" x1="0%" y1="0%" x2="100%" y2="100%">
|
||
<stop offset="0%" stopColor={fkc} stopOpacity="1" />
|
||
<stop offset="100%" stopColor={fkc} stopOpacity="0.55" />
|
||
</linearGradient>
|
||
</defs>
|
||
<rect width={W} height={H} fill="url(#bgG)" />
|
||
{/* Shadow */}
|
||
<ellipse cx={(RW[0]+FW[0])/2+20} cy={RW[1]+WR+6} rx={175} ry={7} fill="#000" opacity="0.35" />
|
||
{/* Ground */}
|
||
<line x1={32} y1={RW[1]+WR+2} x2={528} y2={FW[1]+WR+2} stroke="#1e2d14" strokeWidth="1" />
|
||
|
||
{/* ── REAR WHEEL ── */}
|
||
<circle cx={RW[0]} cy={RW[1]} r={WR} fill="none" stroke="#060e03" strokeWidth={rtw+5} />
|
||
<circle cx={RW[0]} cy={RW[1]} r={WR} fill="none" stroke="#182410" strokeWidth={rtw} />
|
||
<circle cx={RW[0]} cy={RW[1]} r={WR-rtw/2-2} fill="none" stroke="#2a3c1a" strokeWidth="2.5" />
|
||
<circle cx={RW[0]} cy={RW[1]} r={WR-rtw/2-8} fill="none" stroke="#1e2d14" strokeWidth="1.5" />
|
||
<circle cx={RW[0]} cy={RW[1]} r={22} fill="none" stroke="#243018" strokeWidth="3" />
|
||
{spokes(RW[0],RW[1],15,WR-rtw/2-9)}
|
||
<circle cx={RW[0]} cy={RW[1]} r={13} fill="#182410" stroke="#2a3c1a" strokeWidth="2" />
|
||
<circle cx={RW[0]} cy={RW[1]} r={5} fill="#243018" />
|
||
|
||
{/* ── FRONT WHEEL ── */}
|
||
<circle cx={FW[0]} cy={FW[1]} r={WR} fill="none" stroke="#060e03" strokeWidth={ftw+5} />
|
||
<circle cx={FW[0]} cy={FW[1]} r={WR} fill="none" stroke="#182410" strokeWidth={ftw} />
|
||
<circle cx={FW[0]} cy={FW[1]} r={WR-ftw/2-2} fill="none" stroke="#2a3c1a" strokeWidth="2.5" />
|
||
<circle cx={FW[0]} cy={FW[1]} r={WR-ftw/2-8} fill="none" stroke="#1e2d14" strokeWidth="1.5" />
|
||
{spokes(FW[0],FW[1],13,WR-ftw/2-9)}
|
||
<circle cx={FW[0]} cy={FW[1]} r={13} fill="#182410" stroke="#2a3c1a" strokeWidth="2" />
|
||
<circle cx={FW[0]} cy={FW[1]} r={5} fill="#243018" />
|
||
|
||
{/* ── CHAIN STAYS ── */}
|
||
<line x1={BB[0]} y1={BB[1]-5} x2={RW[0]+10} y2={RW[1]-5} stroke={fc} strokeWidth="8" strokeLinecap="round" />
|
||
<line x1={BB[0]} y1={BB[1]+4} x2={RW[0]+10} y2={RW[1]+4} stroke={fc} strokeWidth="5" strokeLinecap="round" />
|
||
|
||
{/* ── SEAT STAYS ── */}
|
||
<line x1={SSJ[0]} y1={SSJ[1]} x2={RW[0]+8} y2={RW[1]-8} stroke={fc} strokeWidth="5.5" strokeLinecap="round" />
|
||
<line x1={SSJ[0]+7} y1={SSJ[1]+3} x2={RW[0]+14} y2={RW[1]-5} stroke={fc} strokeWidth="3.5" strokeLinecap="round" />
|
||
|
||
{/* ── REAR SHOCK ── */}
|
||
{isCoil ? (
|
||
<>
|
||
<line x1={ShT[0]} y1={ShT[1]} x2={ShB[0]} y2={ShB[1]} stroke="#30403a" strokeWidth="10" strokeLinecap="round" />
|
||
{Array.from({length:6},(_,i)=>{
|
||
const f=(i+0.5)/6;
|
||
const mx=ShT[0]+(ShB[0]-ShT[0])*f, my=ShT[1]+(ShB[1]-ShT[1])*f;
|
||
const dx=ShB[0]-ShT[0], dy=ShB[1]-ShT[1];
|
||
const len=Math.sqrt(dx*dx+dy*dy);
|
||
const nx=-dy/len*7, ny=dx/len*7;
|
||
return <line key={i} x1={mx+nx} y1={my+ny} x2={mx-nx} y2={my-ny} stroke={shc} strokeWidth="2.5" strokeLinecap="round" />;
|
||
})}
|
||
</>
|
||
) : (
|
||
<>
|
||
<line x1={ShT[0]} y1={ShT[1]} x2={ShB[0]} y2={ShB[1]} stroke="#252e20" strokeWidth="11" strokeLinecap="round" />
|
||
<line x1={ShT[0]} y1={ShT[1]} x2={shMid[0]} y2={shMid[1]} stroke={shc} strokeWidth="11" strokeLinecap="round" />
|
||
<line x1={ShT[0]} y1={ShT[1]} x2={shMid[0]} y2={shMid[1]} stroke={shc} strokeWidth="5" strokeLinecap="round" opacity="0.6" />
|
||
</>
|
||
)}
|
||
<circle cx={ShT[0]} cy={ShT[1]} r={4.5} fill="#1e2d14" stroke="#3a4828" strokeWidth="1.5" />
|
||
<circle cx={ShB[0]} cy={ShB[1]} r={4.5} fill="#1e2d14" stroke="#3a4828" strokeWidth="1.5" />
|
||
|
||
{/* ── DOWN TUBE ── */}
|
||
<line x1={HTB[0]} y1={HTB[1]} x2={BB[0]} y2={BB[1]} stroke={fc} strokeWidth="13" strokeLinecap="round" />
|
||
|
||
{/* ── TOP TUBE ── */}
|
||
<line x1={HTT[0]} y1={HTT[1]} x2={STT[0]} y2={STT[1]} stroke={fc} strokeWidth="9" strokeLinecap="round" />
|
||
|
||
{/* ── SEAT TUBE ── */}
|
||
<line x1={STT[0]} y1={STT[1]} x2={BB[0]} y2={BB[1]} stroke={fc} strokeWidth="9" strokeLinecap="round" />
|
||
|
||
{/* ── HEAD TUBE ── */}
|
||
<line x1={HTT[0]} y1={HTT[1]} x2={HTB[0]} y2={HTB[1]} stroke={fc} strokeWidth="15" strokeLinecap="round" />
|
||
|
||
{/* ── BB ── */}
|
||
<circle cx={BB[0]} cy={BB[1]} r={14} fill={fc} />
|
||
<circle cx={BB[0]} cy={BB[1]} r={8} fill="#070f04" />
|
||
<circle cx={BB[0]} cy={BB[1]} r={25} fill="none" stroke="#243018" strokeWidth="4.5" />
|
||
<circle cx={BB[0]} cy={BB[1]} r={20} fill="none" stroke="#1e2d14" strokeWidth="1.5" />
|
||
<line x1={BB[0]} y1={BB[1]} x2={BB[0]-16} y2={BB[1]+26} stroke="#2a3c1a" strokeWidth="7" strokeLinecap="round" />
|
||
<rect x={BB[0]-22} y={BB[1]+25} width="13" height="5" rx="2" fill="#2a3c1a" />
|
||
|
||
{/* ── FORK upper stanchions ── */}
|
||
<line x1={HTB[0]} y1={HTB[1]} x2={arch[0]} y2={arch[1]}
|
||
stroke="url(#fkG)" strokeWidth="8" strokeLinecap="round" />
|
||
<line x1={HTB[0]+pOff[0]} y1={HTB[1]+pOff[1]} x2={arch[0]+pOff[0]} y2={arch[1]+pOff[1]}
|
||
stroke={fkc} strokeWidth="5" strokeLinecap="round" opacity="0.55" />
|
||
{/* Fork crown */}
|
||
<ellipse cx={arch[0]+pOff[0]*0.4} cy={arch[1]} rx={11} ry={6}
|
||
fill={fkc} opacity="0.85"
|
||
transform={`rotate(${Math.atan2(fDy,fDx)*180/Math.PI},${arch[0]+pOff[0]*0.4},${arch[1]})`} />
|
||
{/* Fork lower legs */}
|
||
<line x1={arch[0]} y1={arch[1]} x2={fEnd[0]} y2={fEnd[1]}
|
||
stroke={fkc} strokeWidth="11" strokeLinecap="round" opacity="0.75" />
|
||
<line x1={arch[0]+pOff[0]} y1={arch[1]+pOff[1]} x2={fEnd[0]+pOff[0]} y2={fEnd[1]+pOff[1]}
|
||
stroke={fkc} strokeWidth="8" strokeLinecap="round" opacity="0.45" />
|
||
{/* Axle */}
|
||
<line x1={FW[0]-8} y1={FW[1]} x2={FW[0]+8} y2={FW[1]} stroke={fkc} strokeWidth="3" strokeLinecap="round" opacity="0.6" />
|
||
|
||
{/* ── SEAT POST ── */}
|
||
<line x1={STT[0]+2} y1={STT[1]} x2={STT[0]} y2={STT[1]-28} stroke="#344a22" strokeWidth="5.5" strokeLinecap="round" />
|
||
{/* SADDLE */}
|
||
<ellipse cx={STT[0]+6} cy={STT[1]-31} rx="30" ry="5.5" fill="#283c18" />
|
||
<line x1={STT[0]-21} y1={STT[1]-31} x2={STT[0]+33} y2={STT[1]-31} stroke="#344a22" strokeWidth="2.5" strokeLinecap="round" />
|
||
<line x1={STT[0]+5} y1={STT[1]-28} x2={STT[0]+5} y2={STT[1]-31} stroke="#344a22" strokeWidth="4" />
|
||
|
||
{/* ── STEM + BAR ── */}
|
||
<line x1={HTT[0]-2} y1={HTT[1]+4} x2={HTT[0]-20} y2={HTT[1]-18} stroke="#344a22" strokeWidth="8" strokeLinecap="round" />
|
||
<circle cx={HTT[0]-20} cy={HTT[1]-18} r={5} fill="#243018" stroke="#344a22" strokeWidth="1.5" />
|
||
<path d={`M ${HTT[0]-38} ${HTT[1]-30} Q ${HTT[0]-22} ${HTT[1]-26} ${HTT[0]-5} ${HTT[1]-12}`}
|
||
fill="none" stroke="#344a22" strokeWidth="8" strokeLinecap="round" />
|
||
|
||
{/* ── LABELS ── */}
|
||
{fork && <text x={FW[0]+12} y={FW[1]-46} fontSize="8" fill={fkc} fontFamily="'IBM Plex Mono',monospace" opacity="0.75">{fork.brand} {fork.model}</text>}
|
||
{shock && <text x={ShT[0]+12} y={shMid[1]-4} fontSize="8" fill={shc} fontFamily="'IBM Plex Mono',monospace" opacity="0.75">{shock.brand} {shock.model}</text>}
|
||
{frame && <text x={244} y={168} fontSize="8" fill={fc} fontFamily="'IBM Plex Mono',monospace" opacity="0.65">{frame.brand} {frame.model}</text>}
|
||
{frontTire && <text x={FW[0]} y={FW[1]+WR+ftw/2+14} fontSize="8" fill="#5a9635" fontFamily="'IBM Plex Mono',monospace" textAnchor="middle" opacity="0.7">{frontTire.model}</text>}
|
||
{rearTire && <text x={RW[0]} y={RW[1]+WR+rtw/2+14} fontSize="8" fill="#5a9635" fontFamily="'IBM Plex Mono',monospace" textAnchor="middle" opacity="0.7">{rearTire.model}</text>}
|
||
|
||
{!hasAny && (
|
||
<text x={W/2} y={H/2+8} fontSize="12" fill="#2a3c1a" fontFamily="'IBM Plex Mono',monospace" textAnchor="middle">
|
||
SELECT COMPONENTS TO BUILD YOUR BIKE
|
||
</text>
|
||
)}
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
// SUB-COMPONENTS
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
function Sl({ label, value, onChange, min, max, step=1, unit='' }) {
|
||
return (
|
||
<div className="slW">
|
||
<div className="slH">
|
||
<span>{label}</span>
|
||
<span className="slV">{value}<span className="slU">{unit}</span></span>
|
||
</div>
|
||
<input type="range" min={min} max={max} step={step} value={value}
|
||
onChange={e => onChange(Number(e.target.value))} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SpB({ label, value, unit }) {
|
||
return (
|
||
<div className="spB">
|
||
<div className="spL">{label}</div>
|
||
<div className="spV">{value ?? '—'}{unit && <span className="spU">{unit}</span>}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CompCard({ item, selected, recommended, onClick, extraTags=[] }) {
|
||
return (
|
||
<div className={`cCard${selected?' sel':''}${recommended?' rec':''}`} onClick={onClick}>
|
||
{selected && <div className="cCheck">✓</div>}
|
||
<div className="cBrand">{item.brand}</div>
|
||
<div className="cModel">{item.model}</div>
|
||
{item.desc && <div className="cNote">{item.desc}</div>}
|
||
{item.notes && <div className="cNote">{item.notes}</div>}
|
||
<div className="cTags">
|
||
{recommended && <span className="cTag cRec">★ Recommended</span>}
|
||
{extraTags.map((t,i) => <span key={i} className="cTag">{t}</span>)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MeterBar({ label, value, max=10, color }) {
|
||
const pct = Math.round((value / max) * 100);
|
||
const bg = color || (pct > 70 ? 'var(--fern)' : pct > 40 ? 'var(--amb)' : 'var(--red)');
|
||
return (
|
||
<div className="mBar">
|
||
<span className="mLabel">{label}</span>
|
||
<div className="mTrack"><div className="mFill" style={{width:`${pct}%`,background:bg}} /></div>
|
||
<span style={{fontSize:10,color:'var(--txt2)',minWidth:22,textAlign:'right'}}>{value}/{max}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
// MAIN APP
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
const emptyBuild = () => ({ frame:null, fork:null, shock:null, frontTire:null, rearTire:null, name:'' });
|
||
const emptySetup = () => ({ fPsi:'', fSag:25, fReb:'', fLsc:'', fHsc:'', fTok:0, sPsi:'', sSag:28, sReb:'', sLsc:'', sHsc:'', sTok:0, fTirePsi:'', rTirePsi:'', notes:'' });
|
||
const emptyReview = () => ({ buildId:'', suspFeel:5, fGrip:7, rGrip:7, rollRes:5, confidence:7, bottomOut:false, harshHit:false, wallow:false, smallBump:false, notes:'' });
|
||
const emptyRider = () => ({ weight:78, disc:'Trail', level:'Intermediate', style:'Mixed' });
|
||
|
||
export default function App() {
|
||
const [tab, setTab] = useState('build');
|
||
const [rider, setRider] = useState(emptyRider());
|
||
const [build, setBuild] = useState(emptyBuild());
|
||
const [setup, setSetup] = useState(emptySetup());
|
||
const [garage, setGarage] = useState([]);
|
||
const [reviews, setReviews] = useState([]);
|
||
const [prefs, setPrefs] = useState({ harshTendency:0, softTendency:0, gripPref:0 });
|
||
const [aiConfig, setAiConfig] = useState({ backend:'anthropic', ollamaUrl:'http://localhost:11434', model:'llama3.1:8b' });
|
||
const [aiOut, setAiOut] = useState('');
|
||
const [aiLoad, setAiLoad] = useState(false);
|
||
const [review, setReview] = useState(emptyReview());
|
||
const [revOut, setRevOut] = useState('');
|
||
const [revLoad, setRevLoad] = useState(false);
|
||
const [toast, setToast] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [showAiSettings, setShowAiSettings] = useState(false);
|
||
const [detailBuild, setDetailBuild] = useState(null);
|
||
|
||
useEffect(() => {
|
||
loadData().then(d => {
|
||
if (d.garage) setGarage(d.garage);
|
||
if (d.reviews) setReviews(d.reviews);
|
||
if (d.prefs) setPrefs(d.prefs);
|
||
if (d.aiConfig) setAiConfig(p => ({...p,...d.aiConfig}));
|
||
setLoading(false);
|
||
});
|
||
}, []);
|
||
|
||
const persist = async (updates) => {
|
||
const merged = { garage, reviews, prefs, aiConfig: {backend:aiConfig.backend,ollamaUrl:aiConfig.ollamaUrl,model:aiConfig.model}, ...updates };
|
||
await saveData(merged);
|
||
};
|
||
|
||
const toast_ = (msg) => { setToast(msg); setTimeout(() => setToast(null), 2400); };
|
||
|
||
const rSet = (k) => (e) => setRider(r => ({...r,[k]: e?.target ? e.target.value : e}));
|
||
const bSet = (k, v) => setBuild(b => ({...b,[k]:b[k]?.id===v?.id ? null : v}));
|
||
|
||
const wc = wCat(rider.weight);
|
||
const dc = dCat(rider.disc);
|
||
const defaults = getSetupDefaults(build, rider);
|
||
|
||
// Adapt setup defaults whenever build/rider changes
|
||
useEffect(() => {
|
||
const d = getSetupDefaults(build, rider);
|
||
setSetup(s => ({
|
||
...s,
|
||
fPsi: s.fPsi || '', fSag: d.fSag||s.fSag,
|
||
fReb: s.fReb || '', fLsc: s.fLsc || '', fHsc: s.fHsc || '',
|
||
fTok: s.fTok!=='' ? s.fTok : d.fTok||0,
|
||
sPsi: s.sPsi || '', sSag: d.sSag||s.sSag,
|
||
sReb: s.sReb || '', sLsc: s.sLsc || '', sHsc: s.sHsc || '',
|
||
sTok: s.sTok!=='' ? s.sTok : d.sTok||0,
|
||
fTirePsi: s.fTirePsi || '', rTirePsi: s.rTirePsi || '',
|
||
}));
|
||
}, [build.fork?.id, build.shock?.id, build.frontTire?.id, build.rearTire?.id, rider.weight, rider.disc, rider.level]);
|
||
|
||
const saveBuild = async () => {
|
||
if (!build.name.trim()) { toast_('Add a build name first'); return; }
|
||
const saved = { id: Date.now().toString(), ...build, rider: {...rider}, setup: {...setup}, date: new Date().toISOString() };
|
||
const updated = [saved, ...garage];
|
||
setGarage(updated);
|
||
await persist({ garage: updated });
|
||
toast_('Build saved to garage');
|
||
};
|
||
|
||
const loadGarageBuild = (gb) => {
|
||
setBuild({ frame:gb.frame, fork:gb.fork, shock:gb.shock, frontTire:gb.frontTire, rearTire:gb.rearTire, name:gb.name });
|
||
if (gb.setup) setSetup(gb.setup);
|
||
if (gb.rider) setRider(gb.rider);
|
||
setDetailBuild(null);
|
||
setTab('build');
|
||
toast_('Build loaded');
|
||
};
|
||
|
||
const deleteGarageBuild = async (id) => {
|
||
const updated = garage.filter(g => g.id !== id);
|
||
setGarage(updated);
|
||
await persist({ garage: updated });
|
||
toast_('Build deleted');
|
||
};
|
||
|
||
const buildSummary = () => {
|
||
const parts = [build.frame, build.fork, build.shock, build.frontTire, build.rearTire].filter(Boolean);
|
||
return parts.map(p => `${p.brand} ${p.model}`).join(', ') || 'No components selected';
|
||
};
|
||
|
||
const learnedContext = () => {
|
||
if (!reviews.length) return '';
|
||
const harsh = reviews.filter(r => r.suspFeel >= 7).length;
|
||
const soft = reviews.filter(r => r.suspFeel <= 3).length;
|
||
if (harsh > soft && harsh > 1) return '\n[RIDER PATTERN] Previous reviews indicate this rider consistently finds suspension too harsh. Recommend starting with softer rebound (more clicks open) and lower compression damping.';
|
||
if (soft > harsh && soft > 1) return '\n[RIDER PATTERN] Previous reviews indicate this rider often finds suspension too soft/wallowy. Recommend firmer base settings and consider extra volume spacers.';
|
||
return '';
|
||
};
|
||
|
||
const handleAiRec = async () => {
|
||
setAiLoad(true); setAiOut('');
|
||
const ctx = learnedContext();
|
||
const prompt = `Give me a complete suspension setup recommendation for this build:
|
||
|
||
RIDER: ${rider.weight}kg, ${rider.level}, ${rider.style}, rides ${rider.disc}
|
||
FRAME: ${build.frame ? `${build.frame.brand} ${build.frame.model} (${build.frame.travel}mm, ${build.frame.ha}° HA)` : 'Not selected'}
|
||
FORK: ${build.fork ? `${build.fork.brand} ${build.fork.model} (${build.fork.travels[1]}mm travel, ${build.fork.damper} damper)` : 'Not selected'}
|
||
SHOCK: ${build.shock ? `${build.shock.brand} ${build.shock.model} (${build.shock.type})` : 'Not selected'}
|
||
FRONT TIRE: ${build.frontTire ? `${build.frontTire.brand} ${build.frontTire.model} ${build.frontTire.w}"` : 'Not selected'}
|
||
REAR TIRE: ${build.rearTire ? `${build.rearTire.brand} ${build.rearTire.model} ${build.rearTire.w}"` : 'Not selected'}
|
||
${ctx}
|
||
|
||
Please give:
|
||
▸ FORK: Starting PSI, sag %, rebound clicks from closed, LSC, HSC (if applicable), volume tokens
|
||
▸ SHOCK: Starting PSI (or spring rate for coil), sag %, rebound, LSC, HSC, volume tokens
|
||
▸ TIRES: Front and rear starting PSI
|
||
▸ RATIONALE: Why these settings for this rider/build/discipline
|
||
▸ FIRST RIDE TIPS: What to feel for and how to adjust
|
||
▸ KNOWN ISSUES: Any common pitfalls for this specific component combo`;
|
||
|
||
try {
|
||
const resp = await callAI(prompt, AI_SYS, aiConfig);
|
||
setAiOut(resp);
|
||
} catch (e) { setAiOut('Error: ' + e.message); }
|
||
setAiLoad(false);
|
||
};
|
||
|
||
const handlePostRide = async () => {
|
||
setRevLoad(true); setRevOut('');
|
||
const selectedBuild = garage.find(g => g.id === review.buildId);
|
||
const bp = selectedBuild ? `\nBUILD: ${selectedBuild.name}\nFORK: ${selectedBuild.fork?.brand} ${selectedBuild.fork?.model}\nSHOCK: ${selectedBuild.shock?.brand} ${selectedBuild.shock?.model}
|
||
SETUP: Fork ${selectedBuild.setup?.fPsi||'?'}psi / ${selectedBuild.setup?.fSag||'?'}% sag / ${selectedBuild.setup?.fReb||'?'} reb clicks, Shock ${selectedBuild.setup?.sPsi||'?'}psi` : '';
|
||
const prompt = `POST-RIDE REVIEW ANALYSIS:
|
||
${bp}
|
||
|
||
RIDER FEEDBACK (scale 1-10):
|
||
- Suspension feel: ${review.suspFeel}/10 (1=too soft/wallowing, 5=perfect, 10=too harsh/rigid)
|
||
- Front grip: ${review.fGrip}/10
|
||
- Rear grip: ${review.rGrip}/10
|
||
- Rolling resistance: ${review.rollRes}/10 (10=fast rolling, 1=sluggish)
|
||
- Confidence level: ${review.confidence}/10
|
||
|
||
ISSUES REPORTED:
|
||
- Bottoming out: ${review.bottomOut ? 'YES' : 'No'}
|
||
- Harsh initial hit: ${review.harshHit ? 'YES' : 'No'}
|
||
- Wallowing/lack of support: ${review.wallow ? 'YES' : 'No'}
|
||
- Poor small-bump sensitivity: ${review.smallBump ? 'YES' : 'No'}
|
||
|
||
RIDER NOTES: ${review.notes || 'None'}
|
||
|
||
Based on this feedback, provide specific actionable adjustments:
|
||
▸ FORK ADJUSTMENTS: Exact changes to PSI, rebound clicks, compression clicks
|
||
▸ SHOCK ADJUSTMENTS: Exact changes to PSI, rebound, compression, tokens
|
||
▸ TIRE ADJUSTMENTS: Any PSI changes recommended
|
||
▸ PRIORITY: Which adjustment to make first
|
||
▸ NEXT RIDE: What to feel for after changes`;
|
||
|
||
try {
|
||
const resp = await callAI(prompt, AI_SYS, aiConfig);
|
||
setRevOut(resp);
|
||
// Learn from this review
|
||
const newReviews = [{ ...review, date: new Date().toISOString() }, ...reviews.slice(0,19)];
|
||
const newPrefs = { ...prefs,
|
||
harshTendency: newReviews.filter(r=>r.suspFeel>=7).length,
|
||
softTendency: newReviews.filter(r=>r.suspFeel<=3).length,
|
||
};
|
||
setReviews(newReviews);
|
||
setPrefs(newPrefs);
|
||
await persist({ reviews: newReviews, prefs: newPrefs });
|
||
} catch (e) { setRevOut('Error: ' + e.message); }
|
||
setRevLoad(false);
|
||
};
|
||
|
||
// ── RENDER: Build tab ─────────────────────────────────────────────────────
|
||
function renderBuild() {
|
||
const tires = TIRES;
|
||
return (
|
||
<div>
|
||
{/* Rider profile bar */}
|
||
<div className="riderBar">
|
||
<div className="field">
|
||
<label>Rider Weight (kg)</label>
|
||
<input type="number" min={40} max={130} value={rider.weight} onChange={e=>setRider(r=>({...r,weight:Number(e.target.value)}))} />
|
||
</div>
|
||
<div className="field">
|
||
<label>Discipline</label>
|
||
<select value={rider.disc} onChange={rSet('disc')}>
|
||
{DISC.map(d=><option key={d}>{d}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="field">
|
||
<label>Rider Level</label>
|
||
<select value={rider.level} onChange={rSet('level')}>
|
||
{LEVELS.map(l=><option key={l}>{l}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="field">
|
||
<label>Riding Style</label>
|
||
<select value={rider.style} onChange={rSet('style')}>
|
||
{STYLES_R.map(s=><option key={s}>{s}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bike viz */}
|
||
<div className="bike-viewer" style={{marginBottom:20}}>
|
||
<BikeVisualizer build={build} />
|
||
</div>
|
||
|
||
{/* Build name + save */}
|
||
<div className="flex mb">
|
||
<div className="field" style={{flex:1}}>
|
||
<label>Build Name</label>
|
||
<input placeholder="e.g. Enduro season 2025 / Local trails setup" value={build.name} onChange={e=>setBuild(b=>({...b,name:e.target.value}))} />
|
||
</div>
|
||
<div style={{display:'flex',alignItems:'flex-end'}}>
|
||
<button className="btn btn-p" onClick={saveBuild}>Save to Garage</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Frame */}
|
||
<div className="sH">Frame</div>
|
||
<div className="g3">
|
||
{FRAMES.map(f => (
|
||
<CompCard key={f.id} item={f} selected={build.frame?.id===f.id}
|
||
recommended={isRecFrame(f, rider)} onClick={()=>bSet('frame',f)}
|
||
extraTags={[`${f.travel}mm`, f.ha+'°', f.geo]} />
|
||
))}
|
||
</div>
|
||
|
||
{/* Fork */}
|
||
<div className="sH">Fork</div>
|
||
<div className="g3">
|
||
{FORKS.map(f => (
|
||
<CompCard key={f.id} item={f} selected={build.fork?.id===f.id}
|
||
recommended={isRecFork(f, rider)} onClick={()=>bSet('fork',f)}
|
||
extraTags={[f.travels.join('/')+'mm', f.damper]} />
|
||
))}
|
||
</div>
|
||
|
||
{/* Shock */}
|
||
<div className="sH">Rear Shock</div>
|
||
<div className="g3">
|
||
{SHOCKS.map(s => (
|
||
<CompCard key={s.id} item={s} selected={build.shock?.id===s.id}
|
||
recommended={isRecShock(s, rider)} onClick={()=>bSet('shock',s)}
|
||
extraTags={[s.type, s.hsc?'HSC':'', s.hsr?'HSR':''].filter(Boolean)} />
|
||
))}
|
||
</div>
|
||
|
||
{/* Tires */}
|
||
<div className="sH">Front Tire</div>
|
||
<div className="g4">
|
||
{tires.front.map(t => (
|
||
<CompCard key={t.id} item={{...t,desc:undefined}} selected={build.frontTire?.id===t.id}
|
||
recommended={isRecTire(t, rider, 'front')} onClick={()=>bSet('frontTire',t)}
|
||
extraTags={[t.w+'"', `Grip:${t.grip}`, `Roll:${t.roll}`]} />
|
||
))}
|
||
</div>
|
||
<div className="sH">Rear Tire</div>
|
||
<div className="g4">
|
||
{tires.rear.map(t => (
|
||
<CompCard key={t.id} item={{...t,desc:undefined}} selected={build.rearTire?.id===t.id}
|
||
recommended={isRecTire(t, rider, 'rear')} onClick={()=>bSet('rearTire',t)}
|
||
extraTags={[t.w+'"', `Grip:${t.grip}`, `Roll:${t.roll}`]} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── RENDER: Setup tab ─────────────────────────────────────────────────────
|
||
function renderSetup() {
|
||
const d = defaults;
|
||
const sp = (v, d) => v !== '' ? v : (d !== null && d !== undefined ? `≈ ${d}` : '');
|
||
return (
|
||
<div>
|
||
<div className="notice mb">
|
||
Settings below auto-populate from your build + rider profile. Edit any value. Click AI Tune to get full AI recommendation.
|
||
</div>
|
||
|
||
{/* Fork */}
|
||
{build.fork ? (
|
||
<>
|
||
<div className="sH">Fork — {build.fork.brand} {build.fork.model}</div>
|
||
<div className="g2">
|
||
<div className="field">
|
||
<label>Air Pressure (PSI)</label>
|
||
<input type="number" placeholder={d.fPsi ? `≈ ${d.fPsi} psi` : 'Select fork + enter rider weight'}
|
||
value={setup.fPsi} onChange={e=>setSetup(s=>({...s,fPsi:e.target.value}))} />
|
||
{d.fPsi && <span className="hint">Manufacturer range for your weight: {build.fork.psi[wc][0]}–{build.fork.psi[wc][1]} psi</span>}
|
||
</div>
|
||
<div className="field">
|
||
<label>Volume Tokens</label>
|
||
<input type="number" min={0} max={8} placeholder={`≈ ${d.fTok}`}
|
||
value={setup.fTok} onChange={e=>setSetup(s=>({...s,fTok:e.target.value}))} />
|
||
<span className="hint">More tokens = more progressive</span>
|
||
</div>
|
||
<div className="full">
|
||
<Sl label="Sag" value={setup.fSag} onChange={v=>setSetup(s=>({...s,fSag:v}))} min={15} max={40} unit="%" />
|
||
</div>
|
||
<Sl label="Rebound (clicks from closed)" value={setup.fReb===''?d.fReb||10:Number(setup.fReb)}
|
||
onChange={v=>setSetup(s=>({...s,fReb:v}))} min={0} max={20} />
|
||
<Sl label="Low-Speed Compression (clicks)" value={setup.fLsc===''?d.fLsc||5:Number(setup.fLsc)}
|
||
onChange={v=>setSetup(s=>({...s,fLsc:v}))} min={0} max={20} />
|
||
{build.fork.hsc && <Sl label="High-Speed Compression (clicks)" value={setup.fHsc===''?d.fHsc||3:Number(setup.fHsc)}
|
||
onChange={v=>setSetup(s=>({...s,fHsc:v}))} min={0} max={10} />}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="empty" style={{padding:'24px 0'}}><p>Select a fork in the Build tab</p></div>
|
||
)}
|
||
|
||
{/* Shock */}
|
||
{build.shock ? (
|
||
<>
|
||
<div className="sH">Rear Shock — {build.shock.brand} {build.shock.model}</div>
|
||
<div className="g2">
|
||
{build.shock.type === 'air' ? (
|
||
<div className="field">
|
||
<label>Air Pressure (PSI)</label>
|
||
<input type="number" placeholder={d.sPsi ? `≈ ${d.sPsi} psi` : 'PSI'}
|
||
value={setup.sPsi} onChange={e=>setSetup(s=>({...s,sPsi:e.target.value}))} />
|
||
{d.sPsi && <span className="hint">Range for your weight: {build.shock.psi[wc][0]}–{build.shock.psi[wc][1]} psi</span>}
|
||
</div>
|
||
) : (
|
||
<div className="field">
|
||
<label>Spring Rate</label>
|
||
<input readOnly value={build.shock.spring?.[wc] || ''} style={{color:'var(--sage)'}} />
|
||
<span className="hint">Consult spring chart for fine-tuning</span>
|
||
</div>
|
||
)}
|
||
<div className="field">
|
||
<label>Volume Tokens (air only)</label>
|
||
<input type="number" min={0} max={8} disabled={build.shock.type==='coil'} placeholder={`≈ ${d.sTok}`}
|
||
value={setup.sTok} onChange={e=>setSetup(s=>({...s,sTok:e.target.value}))} />
|
||
</div>
|
||
<div className="full">
|
||
<Sl label="Sag" value={setup.sSag} onChange={v=>setSetup(s=>({...s,sSag:v}))} min={20} max={42} unit="%" />
|
||
</div>
|
||
<Sl label="Rebound (clicks from closed)" value={setup.sReb===''?d.sReb||10:Number(setup.sReb)}
|
||
onChange={v=>setSetup(s=>({...s,sReb:v}))} min={0} max={20} />
|
||
<Sl label="Low-Speed Compression (clicks)" value={setup.sLsc===''?d.sLsc||5:Number(setup.sLsc)}
|
||
onChange={v=>setSetup(s=>({...s,sLsc:v}))} min={0} max={20} />
|
||
{build.shock.hsc && <Sl label="High-Speed Compression (clicks)" value={setup.sHsc===''?d.sHsc||3:Number(setup.sHsc)}
|
||
onChange={v=>setSetup(s=>({...s,sHsc:v}))} min={0} max={10} />}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="empty" style={{padding:'24px 0'}}><p>Select a shock in the Build tab</p></div>
|
||
)}
|
||
|
||
{/* Tires */}
|
||
{(build.frontTire || build.rearTire) && (
|
||
<>
|
||
<div className="sH">Tire Pressures</div>
|
||
<div className="g2">
|
||
{build.frontTire && (
|
||
<div className="field">
|
||
<label>Front — {build.frontTire.model} ({build.frontTire.w}")</label>
|
||
<input type="number" placeholder={d.fTirePsi ? `≈ ${d.fTirePsi} psi` : 'PSI'}
|
||
value={setup.fTirePsi} onChange={e=>setSetup(s=>({...s,fTirePsi:e.target.value}))} />
|
||
{d.fTirePsi && <span className="hint">Range: {build.frontTire.psi[wc][0]}–{build.frontTire.psi[wc][1]} psi</span>}
|
||
</div>
|
||
)}
|
||
{build.rearTire && (
|
||
<div className="field">
|
||
<label>Rear — {build.rearTire.model} ({build.rearTire.w}")</label>
|
||
<input type="number" placeholder={d.rTirePsi ? `≈ ${d.rTirePsi} psi` : 'PSI'}
|
||
value={setup.rTirePsi} onChange={e=>setSetup(s=>({...s,rTirePsi:e.target.value}))} />
|
||
{d.rTirePsi && <span className="hint">Range: {build.rearTire.psi[wc][0]}–{build.rearTire.psi[wc][1]} psi</span>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<div className="sH">Notes</div>
|
||
<div className="field mb">
|
||
<label>Setup notes / tuning log</label>
|
||
<textarea placeholder="Feel, conditions, what to try next..." value={setup.notes} onChange={e=>setSetup(s=>({...s,notes:e.target.value}))} />
|
||
</div>
|
||
|
||
<div className="divider" />
|
||
<div className="flex">
|
||
<button className="btn btn-p" onClick={handleAiRec} disabled={aiLoad}>
|
||
{aiLoad ? 'Analysing…' : '🤖 Get AI Recommendation'}
|
||
</button>
|
||
<button className="btn btn-sm" onClick={saveBuild}>Save Build</button>
|
||
</div>
|
||
{aiLoad && <div className="thinking"><div className="dot"/><div className="dot"/><div className="dot"/> Querying suspension database…</div>}
|
||
{aiOut && <div className="aiOut">{aiOut}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── RENDER: Post-ride review tab ──────────────────────────────────────────
|
||
function renderReview() {
|
||
return (
|
||
<div>
|
||
<div className="notice mb">
|
||
After each ride, rate your suspension feel. The AI will suggest precise adjustments, and the app learns your preferences over time.
|
||
{reviews.length > 0 && <span style={{color:'var(--fern)'}}> ({reviews.length} review{reviews.length>1?'s':''} stored — preferences being learned)</span>}
|
||
</div>
|
||
|
||
{/* Select build */}
|
||
{garage.length > 0 && (
|
||
<div className="field mb">
|
||
<label>Reviewed Build (from garage)</label>
|
||
<select value={review.buildId} onChange={e=>setReview(r=>({...r,buildId:e.target.value}))}>
|
||
<option value="">— Current build (unsaved) —</option>
|
||
{garage.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
<div className="sH">Suspension Feel</div>
|
||
<div className="g2" style={{gap:16}}>
|
||
<div className="full">
|
||
<div className="feelRow">
|
||
<span className="feelLabel">Overall Feel</span>
|
||
<input type="range" min={1} max={10} value={review.suspFeel} onChange={e=>setReview(r=>({...r,suspFeel:Number(e.target.value)}))} />
|
||
<span className="feelScore">{review.suspFeel}</span>
|
||
</div>
|
||
<div className="flex" style={{justifyContent:'space-between',fontSize:9,color:'var(--txt3)',letterSpacing:1,marginTop:2}}>
|
||
<span>TOO SOFT / WALLOWING</span><span>PERFECT</span><span>TOO HARSH</span>
|
||
</div>
|
||
</div>
|
||
{[['fGrip','Front Grip',1,10],['rGrip','Rear Grip',1,10],['rollRes','Rolling Speed',1,10],['confidence','Confidence',1,10]].map(([k,l,mn,mx])=>(
|
||
<div key={k} className="slW">
|
||
<div className="slH"><span style={{fontSize:9,letterSpacing:'1.5px',textTransform:'uppercase',color:'var(--txt3)',fontWeight:700}}>{l}</span>
|
||
<span className="slV">{review[k]}<span className="slU">/10</span></span>
|
||
</div>
|
||
<input type="range" min={mn} max={mx} value={review[k]} onChange={e=>setReview(r=>({...r,[k]:Number(e.target.value)}))} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="sH">Issues Experienced</div>
|
||
<div className="g2">
|
||
{[['bottomOut','Bottoming Out'],['harshHit','Harsh Initial Hit'],['wallow','Wallowing / No Support'],['smallBump','Poor Small-Bump Sensitivity']].map(([k,l])=>(
|
||
<label key={k} style={{display:'flex',gap:10,alignItems:'center',cursor:'pointer',padding:'10px 12px',background:'var(--bg3)',border:`1px solid ${review[k]?'var(--fern)':'var(--bdr)'}`,transition:'all .15s'}}>
|
||
<input type="checkbox" checked={review[k]} onChange={e=>setReview(r=>({...r,[k]:e.target.checked}))} style={{accentColor:'var(--fern)',width:14,height:14}} />
|
||
<span style={{fontSize:11,letterSpacing:1,textTransform:'uppercase',color:review[k]?'var(--sage)':'var(--txt3)'}}>{l}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
|
||
<div className="field mt mb">
|
||
<label>Notes (trail conditions, what stood out, etc.)</label>
|
||
<textarea placeholder="Felt great on loamy corners but bottomed out on the big rock drop at speed…" value={review.notes} onChange={e=>setReview(r=>({...r,notes:e.target.value}))} />
|
||
</div>
|
||
|
||
<div className="sH">Grip & Feel Summary</div>
|
||
<div style={{background:'var(--panel)',border:'1px solid var(--bdr)',padding:'14px 16px',marginBottom:16}}>
|
||
<MeterBar label="Front grip" value={review.fGrip} />
|
||
<MeterBar label="Rear grip" value={review.rGrip} />
|
||
<MeterBar label="Roll speed" value={review.rollRes} color="var(--amb)" />
|
||
<MeterBar label="Confidence" value={review.confidence} />
|
||
<MeterBar label="Susp. balance" value={10 - Math.abs(review.suspFeel - 5) * 2} />
|
||
</div>
|
||
|
||
<button className="btn btn-p" onClick={handlePostRide} disabled={revLoad}>
|
||
{revLoad ? 'Analysing ride data…' : '🤖 Get Improvement Recommendations'}
|
||
</button>
|
||
{revLoad && <div className="thinking"><div className="dot"/><div className="dot"/><div className="dot"/> Processing ride feedback…</div>}
|
||
{revOut && <div className="aiOut">{revOut}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── RENDER: Garage tab ────────────────────────────────────────────────────
|
||
function renderGarage() {
|
||
if (detailBuild) {
|
||
const gb = detailBuild;
|
||
const sp = gb.setup;
|
||
return (
|
||
<div>
|
||
<button className="btn btn-sm mb" onClick={()=>setDetailBuild(null)}>← Back</button>
|
||
<div style={{fontFamily:'var(--fD)',fontSize:'clamp(24px,6vw,36px)',letterSpacing:2,textTransform:'uppercase',marginBottom:8}}>{gb.name}</div>
|
||
<div className="gPills" style={{marginBottom:12}}>
|
||
{gb.rider && <><span className="pill pill-g">{gb.rider.disc}</span><span className="pill">{gb.rider.weight}kg</span><span className="pill">{gb.rider.level}</span></>}
|
||
<span className="pill" style={{color:'var(--txt4)'}}>{new Date(gb.date).toLocaleDateString()}</span>
|
||
</div>
|
||
<div className="g2 mb">
|
||
{gb.frame && <div className="spB"><div className="spL">Frame</div><div className="spV" style={{fontSize:16}}>{gb.frame.brand} {gb.frame.model}</div></div>}
|
||
{gb.fork && <div className="spB"><div className="spL">Fork</div><div className="spV" style={{fontSize:16}}>{gb.fork.brand} {gb.fork.model}</div></div>}
|
||
{gb.shock && <div className="spB"><div className="spL">Shock</div><div className="spV" style={{fontSize:16}}>{gb.shock.brand} {gb.shock.model}</div></div>}
|
||
</div>
|
||
{sp && (
|
||
<>
|
||
<div className="sH">Suspension Settings</div>
|
||
<div className="specs">
|
||
{sp.fPsi && <SpB label="Fork PSI" value={sp.fPsi} unit=" psi" />}
|
||
<SpB label="Fork Sag" value={sp.fSag} unit="%" />
|
||
{sp.fReb && <SpB label="Fork Reb" value={sp.fReb} unit=" clk" />}
|
||
{sp.fLsc && <SpB label="Fork LSC" value={sp.fLsc} unit=" clk" />}
|
||
{sp.sPsi && <SpB label="Shock PSI" value={sp.sPsi} unit=" psi" />}
|
||
<SpB label="Shock Sag" value={sp.sSag} unit="%" />
|
||
{sp.sReb && <SpB label="Shock Reb" value={sp.sReb} unit=" clk" />}
|
||
{sp.sLsc && <SpB label="Shock LSC" value={sp.sLsc} unit=" clk" />}
|
||
{sp.fTirePsi && <SpB label="F Tire" value={sp.fTirePsi} unit=" psi" />}
|
||
{sp.rTirePsi && <SpB label="R Tire" value={sp.rTirePsi} unit=" psi" />}
|
||
</div>
|
||
{sp.notes && <div style={{background:'var(--bg3)',border:'1px solid var(--bdr)',borderLeft:'3px solid var(--moss)',padding:'12px 14px',fontSize:12,lineHeight:1.7,color:'var(--txt2)',whiteSpace:'pre-wrap',marginTop:10}}>{sp.notes}</div>}
|
||
</>
|
||
)}
|
||
<div className="flex mt2">
|
||
<button className="btn btn-p" onClick={()=>loadGarageBuild(gb)}>Load Build</button>
|
||
<button className="btn btn-d" onClick={()=>{deleteGarageBuild(gb.id);setDetailBuild(null)}}>Delete</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!garage.length) return (
|
||
<div className="empty">
|
||
<div className="empty-i">🚵</div>
|
||
<h3>Garage is empty</h3>
|
||
<p>Build and save your first setup from the Build tab.</p>
|
||
</div>
|
||
);
|
||
return (
|
||
<div>
|
||
<div style={{fontSize:10,letterSpacing:2,color:'var(--txt3)',textTransform:'uppercase',marginBottom:12}}>
|
||
{garage.length} saved build{garage.length>1?'s':''}
|
||
</div>
|
||
<div className="gGrid">
|
||
{garage.map(gb => (
|
||
<div key={gb.id} className="gCard" onClick={()=>setDetailBuild(gb)}>
|
||
<div className="gBike">{gb.name}</div>
|
||
<div className="gSub">{gb.rider?.disc} · {gb.rider?.weight}kg · {gb.rider?.level}</div>
|
||
<div className="gPills">
|
||
{gb.fork && <span className="pill pill-g">{gb.fork.model}</span>}
|
||
{gb.shock && <span className="pill pill-g">{gb.shock.model}</span>}
|
||
{gb.frame && <span className="pill">{gb.frame.model}</span>}
|
||
</div>
|
||
{gb.setup && (
|
||
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:'3px 8px',fontSize:11,color:'var(--txt3)'}}>
|
||
{gb.setup.fPsi && <span>Fork: <span style={{color:'var(--sage)'}}>{gb.setup.fPsi} psi</span></span>}
|
||
{gb.setup.sPsi && <span>Shock: <span style={{color:'var(--sage)'}}>{gb.setup.sPsi} psi</span></span>}
|
||
{gb.setup.fSag && <span>F-Sag: <span style={{color:'var(--sage)'}}>{gb.setup.fSag}%</span></span>}
|
||
{gb.setup.sSag && <span>R-Sag: <span style={{color:'var(--sage)'}}>{gb.setup.sSag}%</span></span>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── RENDER: AI & Settings tab ─────────────────────────────────────────────
|
||
function renderAI() {
|
||
return (
|
||
<div>
|
||
{/* AI Backend settings */}
|
||
<div style={{background:'var(--panel)',border:'1px solid var(--bdr)',padding:16,marginBottom:20}}>
|
||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:showAiSettings?14:0}}>
|
||
<div style={{fontFamily:'var(--fD)',fontSize:16,letterSpacing:2,textTransform:'uppercase'}}>
|
||
AI Backend: <span style={{color:'var(--sage)'}}>{aiConfig.backend === 'anthropic' ? 'Claude (Anthropic API)' : `Ollama — ${aiConfig.model}`}</span>
|
||
</div>
|
||
<button className="btn btn-sm" onClick={()=>setShowAiSettings(s=>!s)}>{showAiSettings?'Close':'Configure'}</button>
|
||
</div>
|
||
{showAiSettings && (
|
||
<div className="g2" style={{gap:10}}>
|
||
<div className="field">
|
||
<label>Backend</label>
|
||
<select value={aiConfig.backend} onChange={e=>{const v=e.target.value;setAiConfig(c=>({...c,backend:v}));persist({aiConfig:{...aiConfig,backend:v}})}}>
|
||
<option value="anthropic">Anthropic API (Claude) — Works here</option>
|
||
<option value="ollama">Ollama (local) — Requires local deployment</option>
|
||
</select>
|
||
</div>
|
||
{aiConfig.backend === 'ollama' && (
|
||
<>
|
||
<div className="field">
|
||
<label>Ollama URL</label>
|
||
<input value={aiConfig.ollamaUrl} onChange={e=>setAiConfig(c=>({...c,ollamaUrl:e.target.value}))} placeholder="http://localhost:11434" />
|
||
</div>
|
||
<div className="field">
|
||
<label>Model</label>
|
||
<input value={aiConfig.model} onChange={e=>setAiConfig(c=>({...c,model:e.target.value}))} placeholder="llama3.1:8b" />
|
||
</div>
|
||
<div className="full">
|
||
<div className="notice">
|
||
⚠️ Ollama runs locally and cannot be called from this Claude.ai sandbox. To use Ollama, download this code and run it locally (e.g. with Vite/CRA). Recommended models:<br/>
|
||
<strong style={{color:'var(--sage)'}}>llama3.1:8b</strong> — Best reasoning for setups (default)<br/>
|
||
<strong style={{color:'var(--sage)'}}>mistral:7b-instruct</strong> — Faster, good structured output<br/>
|
||
<strong style={{color:'var(--sage)'}}>qwen2.5:7b</strong> — Strong technical reasoning<br/>
|
||
Install: <code style={{color:'var(--amb)'}}>ollama pull llama3.1:8b</code>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Preferences summary */}
|
||
{(prefs.harshTendency > 0 || prefs.softTendency > 0) && (
|
||
<div className="notice mb">
|
||
🧠 <strong>Learned preferences:</strong> Based on {reviews.length} post-ride review{reviews.length>1?'s':''}:
|
||
{prefs.harshTendency > 1 && ` You frequently find suspension too harsh (${prefs.harshTendency}×) — AI will bias toward softer settings.`}
|
||
{prefs.softTendency > 1 && ` You frequently find suspension too soft (${prefs.softTendency}×) — AI will bias toward firmer settings.`}
|
||
{prefs.harshTendency <= 1 && prefs.softTendency <= 1 && ' Your suspension feel is well-balanced across rides.'}
|
||
</div>
|
||
)}
|
||
|
||
{/* Current build summary for AI */}
|
||
<div style={{background:'var(--panel)',border:'1px solid var(--bdr)',padding:16,marginBottom:16}}>
|
||
<div style={{fontFamily:'var(--fD)',fontSize:15,letterSpacing:2,textTransform:'uppercase',color:'var(--txt2)',marginBottom:10}}>Current Build</div>
|
||
<div style={{fontSize:11,color:'var(--txt3)',lineHeight:2}}>
|
||
{build.frame ? <div>🗂 Frame: <span style={{color:'var(--txt2)'}}>{build.frame.brand} {build.frame.model} ({build.frame.travel}mm / {build.frame.ha}°)</span></div> : <div style={{color:'var(--txt4)'}}>No frame selected</div>}
|
||
{build.fork ? <div>🔀 Fork: <span style={{color:'var(--txt2)'}}>{build.fork.brand} {build.fork.model} ({build.fork.travels[1]}mm / {build.fork.damper})</span></div> : <div style={{color:'var(--txt4)'}}>No fork selected</div>}
|
||
{build.shock ? <div>🌀 Shock: <span style={{color:'var(--txt2)'}}>{build.shock.brand} {build.shock.model} ({build.shock.type})</span></div> : <div style={{color:'var(--txt4)'}}>No shock selected</div>}
|
||
{build.frontTire && <div>⬆ F Tire: <span style={{color:'var(--txt2)'}}>{build.frontTire.brand} {build.frontTire.model} {build.frontTire.w}"</span></div>}
|
||
{build.rearTire && <div>⬇ R Tire: <span style={{color:'var(--txt2)'}}>{build.rearTire.brand} {build.rearTire.model} {build.rearTire.w}"</span></div>}
|
||
</div>
|
||
<div style={{marginTop:10,fontSize:11,color:'var(--txt3)'}}>Rider: {rider.weight}kg · {rider.disc} · {rider.level} · {rider.style}</div>
|
||
</div>
|
||
|
||
<button className="btn btn-p" onClick={handleAiRec} disabled={aiLoad || (!build.fork && !build.shock)}>
|
||
{aiLoad ? 'Querying AI…' : '🤖 Generate Full Setup Recommendation'}
|
||
</button>
|
||
{(!build.fork && !build.shock) && <span style={{marginLeft:10,fontSize:10,color:'var(--txt3)'}}>Select at least a fork or shock first</span>}
|
||
{aiLoad && <div className="thinking"><div className="dot"/><div className="dot"/><div className="dot"/> Cross-referencing community data & manufacturer specs…</div>}
|
||
{aiOut && <div className="aiOut">{aiOut}</div>}
|
||
|
||
{/* Recent reviews */}
|
||
{reviews.length > 0 && (
|
||
<>
|
||
<div className="sH mt2">Recent Post-Ride Reviews</div>
|
||
{reviews.slice(0,5).map((r,i) => (
|
||
<div key={i} style={{background:'var(--bg3)',border:'1px solid var(--bdr)',padding:'10px 14px',marginBottom:6}}>
|
||
<div style={{display:'flex',justifyContent:'space-between',marginBottom:6}}>
|
||
<span style={{fontSize:10,color:'var(--txt3)',letterSpacing:1}}>{new Date(r.date).toLocaleDateString()}</span>
|
||
<span style={{fontSize:10,color:r.suspFeel<=3?'var(--amb)':r.suspFeel>=7?'var(--red)':'var(--fern)'}}>Feel: {r.suspFeel}/10</span>
|
||
</div>
|
||
<MeterBar label="Front grip" value={r.fGrip} />
|
||
<MeterBar label="Rear grip" value={r.rGrip} />
|
||
{r.notes && <div style={{fontSize:10,color:'var(--txt3)',marginTop:4,fontStyle:'italic'}}>"{r.notes.slice(0,80)}{r.notes.length>80?'…':''}"</div>}
|
||
</div>
|
||
))}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── ROOT ──────────────────────────────────────────────────────────────────
|
||
return (
|
||
<>
|
||
<style>{STYLES}</style>
|
||
<div className="app">
|
||
<div className="hdr">
|
||
<div className="logo">
|
||
<div className="logo-leaf">L</div>
|
||
<h1>LOAM</h1>
|
||
</div>
|
||
<div className="hdr-sub">MTB Build & Tune</div>
|
||
</div>
|
||
|
||
<div className="tabs">
|
||
{[['build','🔧 Build'],['setup','⚙️ Setup'],['review','📊 Post-Ride'],['garage','🏠 Garage'],['ai','🤖 AI Tune']].map(([t,l]) => (
|
||
<button key={t} className={`tab${tab===t?' on':''}`} onClick={()=>{setDetailBuild(null);setTab(t)}}>{l}</button>
|
||
))}
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="empty"><div className="thinking"><div className="dot"/><div className="dot"/><div className="dot"/> Loading garage…</div></div>
|
||
) : (
|
||
<>
|
||
{tab==='build' && renderBuild()}
|
||
{tab==='setup' && renderSetup()}
|
||
{tab==='review' && renderReview()}
|
||
{tab==='garage' && renderGarage()}
|
||
{tab==='ai' && renderAI()}
|
||
</>
|
||
)}
|
||
</div>
|
||
{toast && <div className="toast">{toast}</div>}
|
||
</>
|
||
);
|
||
}
|