diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a1e98d2 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +DATABASE_URL="file:./dev.db" +OLLAMA_URL="http://localhost:11434" +OLLAMA_MODEL="llama3.1:8b" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d96737f --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +BSU/ +node_modules/ +.next/ +dist/ +*.log +.env +prisma/dev.db +data/*.db +data/*.json +public/images/user/*.png +tmp/ diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..a08cee8 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,10 @@ +import nextVitals from "eslint-config-next/core-web-vitals"; + +export default [ + ...nextVitals, + { + rules: { + "@next/next/no-img-element": "off", + }, + }, +]; diff --git a/loam-mtb-app.jsx b/loam-mtb-app.jsx deleted file mode 100644 index 873aa55..0000000 --- a/loam-mtb-app.jsx +++ /dev/null @@ -1,1309 +0,0 @@ -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 ; - }); - - const hasAny = frame || fork || shock || frontTire || rearTire; - - return ( - - - - - - - - - - - - - {/* Shadow */} - - {/* Ground */} - - - {/* ── REAR WHEEL ── */} - - - - - - {spokes(RW[0],RW[1],15,WR-rtw/2-9)} - - - - {/* ── FRONT WHEEL ── */} - - - - - {spokes(FW[0],FW[1],13,WR-ftw/2-9)} - - - - {/* ── CHAIN STAYS ── */} - - - - {/* ── SEAT STAYS ── */} - - - - {/* ── REAR SHOCK ── */} - {isCoil ? ( - <> - - {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 ; - })} - - ) : ( - <> - - - - - )} - - - - {/* ── DOWN TUBE ── */} - - - {/* ── TOP TUBE ── */} - - - {/* ── SEAT TUBE ── */} - - - {/* ── HEAD TUBE ── */} - - - {/* ── BB ── */} - - - - - - - - {/* ── FORK upper stanchions ── */} - - - {/* Fork crown */} - - {/* Fork lower legs */} - - - {/* Axle */} - - - {/* ── SEAT POST ── */} - - {/* SADDLE */} - - - - - {/* ── STEM + BAR ── */} - - - - - {/* ── LABELS ── */} - {fork && {fork.brand} {fork.model}} - {shock && {shock.brand} {shock.model}} - {frame && {frame.brand} {frame.model}} - {frontTire && {frontTire.model}} - {rearTire && {rearTire.model}} - - {!hasAny && ( - - SELECT COMPONENTS TO BUILD YOUR BIKE - - )} - - ); -} - -// ══════════════════════════════════════════════════════════════════════════════ -// SUB-COMPONENTS -// ══════════════════════════════════════════════════════════════════════════════ - -function Sl({ label, value, onChange, min, max, step=1, unit='' }) { - return ( -
-
- {label} - {value}{unit} -
- onChange(Number(e.target.value))} /> -
- ); -} - -function SpB({ label, value, unit }) { - return ( -
-
{label}
-
{value ?? '—'}{unit && {unit}}
-
- ); -} - -function CompCard({ item, selected, recommended, onClick, extraTags=[] }) { - return ( -
- {selected &&
} -
{item.brand}
-
{item.model}
- {item.desc &&
{item.desc}
} - {item.notes &&
{item.notes}
} -
- {recommended && ★ Recommended} - {extraTags.map((t,i) => {t})} -
-
- ); -} - -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 ( -
- {label} -
- {value}/{max} -
- ); -} - -// ══════════════════════════════════════════════════════════════════════════════ -// 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 ( -
- {/* Rider profile bar */} -
-
- - setRider(r=>({...r,weight:Number(e.target.value)}))} /> -
-
- - -
-
- - -
-
- - -
-
- - {/* Bike viz */} -
- -
- - {/* Build name + save */} -
-
- - setBuild(b=>({...b,name:e.target.value}))} /> -
-
- -
-
- - {/* Frame */} -
Frame
-
- {FRAMES.map(f => ( - bSet('frame',f)} - extraTags={[`${f.travel}mm`, f.ha+'°', f.geo]} /> - ))} -
- - {/* Fork */} -
Fork
-
- {FORKS.map(f => ( - bSet('fork',f)} - extraTags={[f.travels.join('/')+'mm', f.damper]} /> - ))} -
- - {/* Shock */} -
Rear Shock
-
- {SHOCKS.map(s => ( - bSet('shock',s)} - extraTags={[s.type, s.hsc?'HSC':'', s.hsr?'HSR':''].filter(Boolean)} /> - ))} -
- - {/* Tires */} -
Front Tire
-
- {tires.front.map(t => ( - bSet('frontTire',t)} - extraTags={[t.w+'"', `Grip:${t.grip}`, `Roll:${t.roll}`]} /> - ))} -
-
Rear Tire
-
- {tires.rear.map(t => ( - bSet('rearTire',t)} - extraTags={[t.w+'"', `Grip:${t.grip}`, `Roll:${t.roll}`]} /> - ))} -
-
- ); - } - - // ── RENDER: Setup tab ───────────────────────────────────────────────────── - function renderSetup() { - const d = defaults; - const sp = (v, d) => v !== '' ? v : (d !== null && d !== undefined ? `≈ ${d}` : ''); - return ( -
-
- Settings below auto-populate from your build + rider profile. Edit any value. Click AI Tune to get full AI recommendation. -
- - {/* Fork */} - {build.fork ? ( - <> -
Fork — {build.fork.brand} {build.fork.model}
-
-
- - setSetup(s=>({...s,fPsi:e.target.value}))} /> - {d.fPsi && Manufacturer range for your weight: {build.fork.psi[wc][0]}–{build.fork.psi[wc][1]} psi} -
-
- - setSetup(s=>({...s,fTok:e.target.value}))} /> - More tokens = more progressive -
-
- setSetup(s=>({...s,fSag:v}))} min={15} max={40} unit="%" /> -
- setSetup(s=>({...s,fReb:v}))} min={0} max={20} /> - setSetup(s=>({...s,fLsc:v}))} min={0} max={20} /> - {build.fork.hsc && setSetup(s=>({...s,fHsc:v}))} min={0} max={10} />} -
- - ) : ( -

Select a fork in the Build tab

- )} - - {/* Shock */} - {build.shock ? ( - <> -
Rear Shock — {build.shock.brand} {build.shock.model}
-
- {build.shock.type === 'air' ? ( -
- - setSetup(s=>({...s,sPsi:e.target.value}))} /> - {d.sPsi && Range for your weight: {build.shock.psi[wc][0]}–{build.shock.psi[wc][1]} psi} -
- ) : ( -
- - - Consult spring chart for fine-tuning -
- )} -
- - setSetup(s=>({...s,sTok:e.target.value}))} /> -
-
- setSetup(s=>({...s,sSag:v}))} min={20} max={42} unit="%" /> -
- setSetup(s=>({...s,sReb:v}))} min={0} max={20} /> - setSetup(s=>({...s,sLsc:v}))} min={0} max={20} /> - {build.shock.hsc && setSetup(s=>({...s,sHsc:v}))} min={0} max={10} />} -
- - ) : ( -

Select a shock in the Build tab

- )} - - {/* Tires */} - {(build.frontTire || build.rearTire) && ( - <> -
Tire Pressures
-
- {build.frontTire && ( -
- - setSetup(s=>({...s,fTirePsi:e.target.value}))} /> - {d.fTirePsi && Range: {build.frontTire.psi[wc][0]}–{build.frontTire.psi[wc][1]} psi} -
- )} - {build.rearTire && ( -
- - setSetup(s=>({...s,rTirePsi:e.target.value}))} /> - {d.rTirePsi && Range: {build.rearTire.psi[wc][0]}–{build.rearTire.psi[wc][1]} psi} -
- )} -
- - )} - -
Notes
-
- -