Spike 50 parts board: photos, offline mode, mobile viewer, login page

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
flomiandClaude Sonnet 5.5 committed 2026-10-24 11:31:46 +02:00
commit 9b9516026b
17 files changed
+1088

No files matched your search

+705
View File
@@ -0,0 +1,705 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>Spike 50 Parts Board</title>
<link rel="manifest" href="manifest.webmanifest" crossorigin="use-credentials">
<meta name="theme-color" content="#1d5fc4">
<meta name="mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Spike 50"><link rel="apple-touch-icon" href="icon-192.png">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Condensed:wght@500;600;700&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
<style>
/* Layout: workshop parts board — drawing on the left, the numbered parts list beside it, status colour on every marker */
:root{
--bg:#eef1f4; --panel:#ffffff; --ink:#18212c; --muted:#5d6977; --line:#d5dbe2; --soft:#e4e9ef;
--accent:#1d5fc4; --accent-ink:#ffffff;
--have:#1f8a4c; --miss:#c8332b; --ord:#c27a0e; --skip:#7a8796; --none:#9aa6b3;
--have-bg:#e3f3e9; --miss-bg:#fbe6e4; --ord-bg:#fbf0dc; --skip-bg:#eceff2;
--f-display:"IBM Plex Sans Condensed","Arial Narrow",system-ui,sans-serif;
--f-body:"IBM Plex Sans",system-ui,-apple-system,"Segoe UI",sans-serif;
--f-mono:"IBM Plex Mono",ui-monospace,"SF Mono",Menlo,monospace;
}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){
--bg:#12171d; --panel:#1a212a; --ink:#e6ebf0; --muted:#98a4b2; --line:#2c3642; --soft:#232c37;
--accent:#5b97ef; --accent-ink:#0d1520;
--have:#4cc27c; --miss:#ef6b62; --ord:#e7a93e; --skip:#8d99a7; --none:#6b7785;
--have-bg:#173226; --miss-bg:#3a1d1c; --ord-bg:#382b13; --skip-bg:#232b34; color-scheme:dark}}
:root[data-theme="dark"]{
--bg:#12171d; --panel:#1a212a; --ink:#e6ebf0; --muted:#98a4b2; --line:#2c3642; --soft:#232c37;
--accent:#5b97ef; --accent-ink:#0d1520;
--have:#4cc27c; --miss:#ef6b62; --ord:#e7a93e; --skip:#8d99a7; --none:#6b7785;
--have-bg:#173226; --miss-bg:#3a1d1c; --ord-bg:#382b13; --skip-bg:#232b34; color-scheme:dark}
*{box-sizing:border-box}
[hidden]{display:none!important}
html{color-scheme:light}
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--f-body);font-size:14px;line-height:1.45;padding-left:max(16px,env(safe-area-inset-left,0px));padding-right:max(16px,env(safe-area-inset-right,0px));padding-block:16px 40px}
.wrap{max-width:1400px;margin:0 auto;display:grid;gap:14px}
h1,h2,h3{font-family:var(--f-display);margin:0;text-wrap:balance}
.mono{font-family:var(--f-mono);font-variant-numeric:tabular-nums}
button{font:inherit;color:inherit;cursor:pointer}
button:focus-visible,a:focus-visible,input:focus-visible,textarea:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
a{color:var(--accent)}
/* header */
.head{display:flex;flex-wrap:wrap;gap:12px 24px;align-items:end;justify-content:space-between}
.head h1{font-size:30px;font-weight:700;letter-spacing:-.01em}
.plate{display:flex;flex-wrap:wrap;gap:6px 14px;color:var(--muted);font-size:13px}
.plate b{color:var(--ink);font-weight:500}
.tally{display:flex;gap:6px;flex-wrap:wrap}
.chip{display:inline-flex;align-items:center;gap:6px;border-radius:999px;padding:3px 10px;font-size:12.5px;font-weight:500;background:var(--soft);white-space:nowrap}
.dot{width:9px;height:9px;border-radius:50%;flex:none;background:var(--none)}
.s-have .dot,.dot.s-have{background:var(--have)} .s-miss .dot,.dot.s-miss{background:var(--miss)} .s-ord .dot,.dot.s-ord{background:var(--ord)} .s-skip .dot,.dot.s-skip{background:var(--skip)}
.bar{height:6px;border-radius:3px;background:var(--soft);overflow:hidden;display:flex}
.bar i{display:block;height:100%}
/* tabs */
.tabs{display:flex;gap:6px;overflow-x:auto;padding-bottom:4px;scrollbar-width:thin}
.tab{flex:none;border:1px solid var(--line);background:var(--panel);border-radius:8px;padding:8px 12px;display:grid;gap:5px;min-width:150px;text-align:left}
.tab .t{font-family:var(--f-display);font-weight:600;font-size:14.5px}
.tab .m{font-size:12px;color:var(--muted);display:flex;justify-content:space-between;gap:8px}
.tab[aria-selected="true"]{border-color:var(--accent);box-shadow:inset 0 -3px 0 var(--accent)}
.tab.shop .t{color:var(--miss)}
/* main */
.main{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(320px,1fr);gap:14px;align-items:start}
@media (max-width:900px){.main{grid-template-columns:1fr}}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;min-width:0}
.dhead{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:space-between;padding:10px 12px;border-bottom:1px solid var(--line)}
.dhead h2{font-size:18px}
.tools{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
.btn{border:1px solid var(--line);background:var(--panel);border-radius:7px;padding:5px 10px;font-size:13px}
.btn.on{background:var(--accent);color:var(--accent-ink);border-color:var(--accent)}
.btn.primary{background:var(--accent);color:var(--accent-ink);border-color:var(--accent);font-weight:500}
.viewport{overflow:auto;max-height:72vh;background:#fff;border-radius:0 0 10px 10px;position:relative}
.stage{position:relative;transform-origin:0 0}
.stage img{display:block;width:100%;height:auto;max-width:none;user-select:none;-webkit-user-drag:none}
.stage.editing{cursor:crosshair}
.pin{position:absolute;transform:translate(-50%,-50%);width:30px;height:24px;border-radius:12px;border:2.5px solid var(--none);background:rgba(255,255,255,.15);padding:0;touch-action:none}
.pin.s-have{border-color:#1f8a4c;background:rgba(31,138,76,.22)} .pin.s-miss{border-color:#c8332b;background:rgba(200,51,43,.25)}
.pin.s-ord{border-color:#c27a0e;background:rgba(194,122,14,.25)} .pin.s-skip{border-color:#7a8796;background:rgba(122,135,150,.18)}
.pin.s-none{border-color:#1d5fc4;border-style:dashed;background:rgba(29,95,196,.06)}
.pin.sel{box-shadow:0 0 0 3px #1d5fc4,0 0 0 6px rgba(29,95,196,.35);z-index:3}
.pin.orphan{border-color:#8a5cc8;border-style:dotted}
.stage.editing .pin{cursor:grab}
.nodraw{padding:28px 20px;display:grid;gap:12px;justify-items:start;color:var(--muted);max-width:60ch}
.nodraw h3{color:var(--ink);font-size:17px}
.note{font-size:12.5px;color:var(--muted);padding:8px 12px;border-top:1px solid var(--line)}
/* list */
.lhead{padding:10px 12px;border-bottom:1px solid var(--line);display:grid;gap:8px}
.filters{display:flex;gap:4px;flex-wrap:wrap}
.filters .btn{padding:3px 9px;font-size:12.5px}
input[type=search],textarea{width:100%;border:1px solid var(--line);background:var(--bg);color:var(--ink);border-radius:7px;padding:6px 9px;font:inherit}
.list{max-height:72vh;overflow:auto}
.row{display:grid;grid-template-columns:44px minmax(0,1fr) auto;gap:10px;align-items:center;padding:8px 12px;border-bottom:1px solid var(--soft);width:100%;background:none;border-left:0;border-right:0;border-top:0;text-align:left}
.row:hover{background:var(--soft)}
.row.sel{background:var(--soft);box-shadow:inset 3px 0 0 var(--accent)}
.ref{font-family:var(--f-mono);font-weight:500;font-size:13px;border:2px solid var(--none);border-radius:10px;text-align:center;padding:1px 0}
.row.s-have .ref{border-color:var(--have)} .row.s-miss .ref{border-color:var(--miss)} .row.s-ord .ref{border-color:var(--ord)} .row.s-skip .ref{border-color:var(--skip)}
.rname{font-weight:500;overflow-wrap:anywhere}
.rsub{font-size:12px;color:var(--muted)}
.stat{font-size:12px;color:var(--muted);white-space:nowrap}
.detail{padding:12px;border-bottom:1px solid var(--line);background:var(--soft);display:grid;gap:10px}
.sbtns{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}
.sbtns button{border:1.5px solid var(--line);background:var(--panel);border-radius:8px;padding:7px 4px;font-weight:500;font-size:13px}
.sbtns button[data-s="have"][aria-pressed="true"]{background:var(--have-bg);border-color:var(--have);color:var(--have)}
.sbtns button[data-s="miss"][aria-pressed="true"]{background:var(--miss-bg);border-color:var(--miss);color:var(--miss)}
.sbtns button[data-s="ord"][aria-pressed="true"]{background:var(--ord-bg);border-color:var(--ord);color:var(--ord)}
.sbtns button[data-s="skip"][aria-pressed="true"]{background:var(--skip-bg);border-color:var(--skip);color:var(--skip)}
.vars{display:grid;gap:4px}
.var{display:grid;grid-template-columns:auto minmax(0,1fr);gap:2px 10px;background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:6px 8px;font-size:12.5px}
.var .o{font-family:var(--f-mono)}
.var .d{overflow-wrap:anywhere}
.tags{grid-column:1/-1;display:flex;gap:4px;flex-wrap:wrap}
.tag{font-size:11px;border-radius:4px;padding:1px 6px;background:var(--soft);color:var(--muted)}
.tag.good{background:var(--have-bg);color:var(--have)} .tag.bad{background:var(--miss-bg);color:var(--miss)} .tag.mine{background:var(--accent);color:var(--accent-ink)}
.var.no{opacity:.55}
.lbl{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);font-weight:600}
.shop{padding:14px;display:grid;gap:16px}
.shop table{width:100%;border-collapse:collapse;font-size:13px}
.shop th,.shop td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--soft);vertical-align:top}
.shop th{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
.tblwrap{overflow-x:auto}
.empty{padding:20px 12px;color:var(--muted)}
.toast{position:fixed;left:50%;bottom:calc(16px + env(safe-area-inset-bottom,0px));transform:translateX(-50%);background:var(--ink);color:var(--bg);padding:7px 14px;border-radius:8px;font-size:13px;z-index:20}
.savebadge{font-size:12px;color:var(--muted)}
.confirm{display:flex;gap:6px;align-items:center;flex-wrap:wrap;font-size:12.5px}
@media (prefers-reduced-motion:no-preference){.pin{transition:box-shadow .15s}}
.sub{display:grid;gap:6px}
.item{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;align-items:start;background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:6px 8px;font-size:13px}
.item .when{font-size:11.5px;color:var(--muted)}
.item p{margin:0;white-space:pre-wrap;overflow-wrap:anywhere}
.item a{overflow-wrap:anywhere}
.x{border:0;background:none;color:var(--muted);font-size:16px;line-height:1;padding:2px 4px;border-radius:4px}
.x:hover{color:var(--miss);background:var(--miss-bg)}
.addrow{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(0,1fr) auto;gap:6px}
@media (max-width:480px){.addrow{grid-template-columns:1fr}}
input[type=url],input[type=text]{width:100%;border:1px solid var(--line);background:var(--bg);color:var(--ink);border-radius:7px;padding:6px 9px;font:inherit}
/* photos, viewer, mobile */
.pin::after{content:"";position:absolute;inset:-8px}
.pgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:6px}
.pgrid.big{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px}
.ph{position:relative;border:1px solid var(--line);border-radius:8px;overflow:hidden;padding:0;background:var(--soft);aspect-ratio:1}
.ph img{width:100%;height:100%;object-fit:cover;display:block}
.ph span{position:absolute;left:0;right:0;bottom:0;padding:14px 6px 4px;font-size:11.5px;color:#fff;background:linear-gradient(transparent,rgba(0,0,0,.7));text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ph .pend{position:absolute;top:4px;right:4px;background:var(--ord);color:#fff;border-radius:6px;padding:0 5px;font-size:10.5px}
.gup{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;align-items:center}
.tab.photos .t{color:var(--accent)}
#viewer{position:fixed;inset:0;z-index:50;background:#0b0e12;color:#fff;display:grid;grid-template-rows:auto minmax(0,1fr) auto}
#viewer[hidden]{display:none}
.vbar{display:flex;gap:6px;align-items:center;padding:calc(8px + env(safe-area-inset-top,0px)) 10px 8px;background:#111820}
.vbar button,.vnav{border:1px solid #33404f;background:#1b2530;color:#fff;border-radius:8px;min-width:42px;height:40px;padding:0 12px;font-size:16px}
.vbar .vt{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px;padding:0 6px}
.vcanvas{position:relative;overflow:hidden;touch-action:none;user-select:none;-webkit-user-select:none}
.vstage{position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform}
.vstage.white{background:#fff}
.vstage img{display:block;width:100%;height:100%;pointer-events:none;-webkit-user-drag:none}
.vstage .pin{transform:translate(-50%,-50%) scale(var(--k,1));width:34px;height:26px;border-width:3px}
.vstage .pin .lab{position:absolute;left:50%;bottom:calc(100% + 4px);transform:translateX(-50%);background:#18212c;color:#fff;font:600 13px var(--f-mono);padding:1px 7px;border-radius:6px;white-space:nowrap}
.vnav{position:absolute;top:50%;transform:translateY(-50%);width:44px;height:64px;padding:0;background:rgba(17,24,32,.7);z-index:2}
.vnav.prev{left:6px}.vnav.next{right:6px}
.vbottom{background:var(--panel);color:var(--ink);padding:8px 10px calc(10px + env(safe-area-inset-bottom,0px));max-height:42vh;overflow:auto;display:grid;gap:8px}
.vbottom .cap{font-size:14px;overflow-wrap:anywhere}
.vchips{display:flex;gap:5px;overflow-x:auto;padding-bottom:2px;scrollbar-width:thin}
.vchip{flex:none;min-width:40px;height:36px;border-radius:18px;border:2.5px solid var(--none);background:var(--soft);font:500 13px var(--f-mono);padding:0 8px}
.vchip.s-have{border-color:var(--have)}.vchip.s-miss{border-color:var(--miss)}.vchip.s-ord{border-color:var(--ord)}.vchip.s-skip{border-color:var(--skip)}
.vchip.sel{background:var(--accent);color:var(--accent-ink);border-color:var(--accent)}
.vsel{display:grid;gap:6px}
.vsel .nm{font-weight:600;overflow-wrap:anywhere}
.vsel .nm .mono{color:var(--accent)}
.vbottom .sbtns button{padding:11px 2px}
@media (max-width:700px){
body{padding-inline:max(10px,env(safe-area-inset-left,0px));font-size:15px}
.head h1{font-size:22px}
.plate{font-size:12px;gap:2px 10px}
.tabs{position:sticky;top:0;z-index:5;background:var(--bg);padding:6px 0;margin-top:-6px}
.tab{min-width:112px;padding:7px 10px}
.row{padding:12px;grid-template-columns:40px minmax(0,1fr) auto}
.sbtns button{padding:12px 2px;font-size:14px}
.btn{padding:9px 12px;font-size:14px}
.filters .btn{padding:7px 11px;font-size:13px}
input[type=search],input[type=url],input[type=text],textarea{font-size:16px;padding:9px 10px}
.viewport{max-height:56vh}
.list{max-height:none}
.x{font-size:22px;padding:6px 10px}
.dhead h2{font-size:17px}
}
</style>
</head>
<body>
<div class="wrap">
<header class="head">
<div style="display:grid;gap:6px">
<h1>Rieju Spike 50 · Parts Board</h1>
<div class="plate">
<span>Model <b>Spike 50 (RR/E2/4)</b></span><span>First reg. <b>2005</b></span>
<span>Engine <b>Minarelli AM6</b></span><span>Frame no. <b class="mono">…550038</b></span><span>Colour <b>Blue</b></span>
</div>
</div>
<div style="display:grid;gap:6px;min-width:min(100%,320px)">
<div class="tally" id="tally"></div>
<div class="bar" id="bar"></div>
<div style="display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap;align-items:center"><span class="savebadge" id="savebadge">Loading saved progress…</span><a class="savebadge" href="api/export">Download backup</a></div>
</div>
</header>
<nav class="tabs" id="tabs" role="tablist" aria-label="Drawing groups"></nav>
<main id="main"></main>
</div>
<div class="toast" id="toast" hidden></div>
<div id="viewer" hidden role="dialog" aria-modal="true" aria-label="Image viewer">
<div class="vbar"><button data-v="close" aria-label="Close">✕</button><span class="vt" id="vtitle"></span>
<button data-v="zout" aria-label="Zoom out">−</button><button data-v="zin" aria-label="Zoom in">+</button><button data-v="fit">Fit</button></div>
<div class="vcanvas" id="vcanvas"><div class="vstage" id="vstage" hidden></div>
<button class="vnav prev" data-v="prev" aria-label="Previous" hidden>‹</button><button class="vnav next" data-v="next" aria-label="Next" hidden>›</button></div>
<div class="vbottom" id="vbottom"></div>
</div>
<script>
const GROUPS = [{"key":"chassis","title":"Frame & swingarm","refs":[{"r":"1","v":[{"o":"0/000.010.1001","d":"LIMITED EDITION RR CHASSIS (BAST.>=74239)","a":true,"c":""},{"o":"0/000.010.0550","d":"COMPLETE PAINTED CHASSIS","a":false,"c":""},{"o":"0/000.010.1010","d":"LIMITED EDITION RR CHASSIS (BAST.<=74238)","a":false,"c":""},{"o":"0/000.010.1002","d":"LIMITED EDITION RR CHASSIS 2005","a":true,"c":""}]},{"r":"2","v":[{"o":"0/000.020.0510","d":"SWINGING ARM GREY WITHOUT TIE ROD","a":true,"c":""},{"o":"0/000.020.5100","d":"LIMITED EDITION RR BASCULE 2005","a":true,"c":""},{"o":"0/000.020.0550","d":"COMPLETE PAINTED BASCULE","a":true,"c":""}]},{"r":"3","v":[{"o":"0/000.440.5000B","d":"PLASTIC WASHER M6 6.2x14x1.2","a":true,"c":""},{"o":"0/000.980.0581","d":"DISCONTINUED","a":false,"c":""}]},{"r":"4","v":[{"o":"0/000.980.0580","d":"DISCONTINUED","a":false,"c":""}]},{"r":"5","v":[{"o":"0/000.730.0700","d":"BRAKE LEVER","a":false,"c":""}]},{"r":"6","v":[{"o":"0/000.210.0510","d":"COMPLETE KICKSTAND","a":false,"c":""},{"o":"0/000.210.0540","d":"COMPLETE KICKSTAND","a":false,"c":""}]},{"r":"7","v":[{"o":"0/000.980.0541","d":"ENGINE BRACKET 2005","a":true,"c":""},{"o":"0/000.980.0540","d":"ENGINE BRACKET","a":true,"c":""}]},{"r":"8","v":[{"o":"0/000.340.5101B","d":"QUICK NUT M6 (bag of 5)","a":true,"c":""},{"o":"0/000.380.0710","d":"CHAIN TURNBUCKLE ONLY","a":true,"c":""}]},{"r":"9","v":[{"o":"0/000.740.0040","d":"REAR FOOT SUPPORTS","a":false,"c":""},{"o":"0/000.740.0210","d":"REAR FOOT SUPPORTS","a":true,"c":""}]},{"r":"10","v":[{"o":"0/000.380.5000","d":"CHAIN ADJUSTER R/H","a":false,"c":""},{"o":"0/000.380.0690","d":"COMPLETE CHAIN TURNBUCKLE","a":false,"c":""}]},{"r":"11","v":[{"o":"0/000.340.0170B","d":"NUT M6 SELF-LOCKING (bag of 5)","a":true,"c":""},{"o":"0/000.460.0670B","d":"SCREW M8x25 (bag of 5)","a":true,"c":""}]},{"r":"12","v":[{"o":"0/000.440.0160B","d":"WASHER 8.5x17x1.25 (bag of 5)","a":true,"c":""}]},{"r":"13","v":[{"o":"0/000.460.0600B","d":"SCREW M6x16","a":true,"c":""},{"o":"0/000.460.0622","d":"SCREW M6x35","a":true,"c":""}]},{"r":"14","v":[{"o":"0/000.340.0180B","d":"NUT M8 SELF-LOCKING (bag of 5)","a":true,"c":""},{"o":"0/000.380.5001","d":"CHAIN ADJUSTER L/H","a":false,"c":""}]},{"r":"15","v":[{"o":"0/000.440.0210","d":"SPRING WASHER","a":true,"c":""}]},{"r":"16","v":[{"o":"0/000.740.0061","d":"RIGHT FOOTREST","a":false,"c":""}]},{"r":"17","v":[{"o":"0/000.740.0071","d":"LEFT FOOTREST","a":false,"c":""}]},{"r":"18","v":[{"o":"0/000.360.0720","d":"FRONT RUBBER TANK MOUNT","a":true,"c":""}]},{"r":"19","v":[{"o":"0/000.360.0090","d":"RUBBER TANK MOUNT","a":false,"c":""}]},{"r":"20","v":[{"o":"0/000.680.0060","d":"CLAMP","a":true,"c":""},{"o":"0/000.680.0070","d":"CABLE TIE","a":true,"c":""}]},{"r":"21","v":[{"o":"0/000.360.0700","d":"RUBBER TANK HALF-SHAFT","a":true,"c":""}]},{"r":"22","v":[{"o":"0/000.360.0790","d":"RUBBER CHAIN SHOE","a":false,"c":""},{"o":"0/000.360.5001","d":"CHAIN SLIDER","a":true,"c":""}]},{"r":"23","v":[{"o":"0/000.710.0300","d":"CHAIN GUIDE","a":false,"c":""},{"o":"0/000.710.0310","d":"COMPLETE CHAIN GUIDE","a":false,"c":""}]},{"r":"24","v":[{"o":"0/000.360.0370","d":"SWING ARM BUSH","a":true,"c":""}]},{"r":"25","v":[{"o":"0/000.190.0460","d":"SWINGARM AXLE","a":false,"c":""}]},{"r":"26","v":[{"o":"0/000.980.0741","d":"REAR BRAKE CALIPER BRACKET","a":false,"c":""},{"o":"0/000.980.0742","d":"REAR BRAKE CALIPER BRACKET J.JUAN","a":true,"c":""},{"o":"0/000.980.0740","d":"REAR BRAKE CALIPER BRACKET","a":false,"c":""}]},{"r":"27","v":[{"o":"0/000.470.0201","d":"LEFT FOOTREST SPRING (FRAME>701877)","a":true,"c":""}]},{"r":"28","v":[{"o":"0/000.360.0792","d":"CHAIN ROLLER","a":true,"c":""}]},{"r":"29","v":[{"o":"0/000.470.0010","d":"KICKSTAND RETURN SPRING","a":false,"c":""}]},{"r":"30","v":[{"o":"0/000.470.0760","d":"REAR BRAKE LEVER SPRING","a":true,"c":""}]},{"r":"31","v":[{"o":"0/000.340.0180B","d":"NUT M8 SELF-LOCKING (bag of 5)","a":true,"c":""}]},{"r":"32","v":[{"o":"0/000.480.0731","d":"KICKSTAND SPACER","a":true,"c":""}]},{"r":"33","v":[{"o":"0/000.480.0210","d":"RIDER FOOTREST SPACER","a":true,"c":""}]},{"r":"34","v":[{"o":"0/000.460.0471","d":"SCREW M8x45","a":false,"c":""}]},{"r":"35","v":[{"o":"0/000.460.0490","d":"SCREW M8x55 DIN 933","a":true,"c":""}]},{"r":"36","v":[{"o":"0/000.480.0645","d":"CHAIN ROLLER INNER BUSHING","a":true,"c":""}]},{"r":"37","v":[{"o":"0/000.380.0700","d":"WHEEL STOP TURNBUCKLE","a":true,"c":""}]},{"r":"38","v":[{"o":"0/000.440.0130B","d":"WASHER 6.5x17x1.5 (bag of 5)","a":true,"c":""}]},{"r":"39","v":[{"o":"0/000.340.0186","d":"REAR AXLE NUT","a":true,"c":""}]},{"r":"40","v":[{"o":"0/000.360.0700","d":"RUBBER TANK HALF-SHAFT","a":true,"c":""}]},{"r":"41","v":[{"o":"0/000.470.0200","d":"RIGHT FOOTREST SPRING (FRAME>701877)","a":true,"c":""}]},{"r":"42","v":[{"o":"0/000.390.1695","d":"DECAL SET BLUE 2005","a":true,"c":"C"},{"o":"0/000.390.1693","d":"DECAL SET RED 2005","a":true,"c":"A"},{"o":"0/000.390.1694","d":"DECAL SET BLACK 2005","aLine truncated
const IMAGES = {"chassis":"img/chassis.webp","suspension":"img/suspension.webp","wheels":"img/wheels.webp","plastics":"img/plastics.webp","electrics":"img/electrics.webp","exhaust-radiator-tank-airfilter":"img/exhaust-radiator-tank-airfilter.webp","motord":"img/motord.webp"};
const NOTES = {
chassis:"Factory catalogue sheet 1 (titled Spike 2000; the same sheet covers 2002–2005 Spikes).",
suspension:"Heads-up: around the rear shock the drawing and the parts list number things differently. The drawing calls the shock 51 and the linkage 44–50; the parts list puts the shock under 44. Read the description before ordering.",
wheels:"Some tyre and tube lines in this list are the enduro RR sizes. Your papers say 100/80-17 front and 130/70-17 rear.",
motord:"This is the engine sheet that matches your bike (PHBN 16 carb, fitted from frame 73600 on). Rieju lists four earlier AM6 versions separately.",
plastics:"Colour variant C in the catalogue is the 2005 blue. Parts tagged “your colour” are the ones to look for.",
};
const FRAME = 550038;
const SITE = "https://www.oemmotorparts.com/en/model/rieju/spike-50/2002/drawing/";
const STATUS = {have:"Have",miss:"Missing",ord:"Ordered",skip:"Not needed"};
// ---------- fitment helpers ----------
function fits(desc){
const m = desc.match(/(BAST\.?|FRAME|Chassis|SPIKE)\s*(<=|>=|<|>)\s*(\d+)/i);
if(!m) return null;
const n=+m[3], op=m[2];
return op==="<="?FRAME<=n: op===">="?FRAME>=n: op==="<"?FRAME<n: FRAME>n;
}
function variantTags(v){
const t=[]; const f=fits(v.d);
if(f===true) t.push(["good","Fits frame 550038"]);
if(f===false) t.push(["bad","Not for your frame"]);
if(v.c==="C") t.push(["mine","Your colour (blue 2005)"]);
else if(v.c && v.c!=="") t.push(["", "Colour option "+v.c]);
t.push(v.a?["good","Orderable new"]:["","Discontinued / unavailable"]);
return t;
}
function bestName(ref){
const vs=ref.v;
const pick = vs.find(v=>v.c==="C") || vs.find(v=>fits(v.d)===true) || vs.find(v=>fits(v.d)!==false && !/DISCONTINUED/.test(v.d)) || vs[0];
return pick.d.replace(/\s*\((BAST|FRAME|Chassis|SPIKE)[^)]*\)/i,"").toLowerCase().replace(/^\w|\s\w/g,s=>s.toUpperCase()).replace(/\bL\/h\b/g,"L/H").replace(/\bR\/h\b/g,"R/H");
}
// ---------- state ----------
const EMPTY=()=>({parts:{}, links:{}, comments:{}, drawings:{}, photos:{}});
let state = EMPTY();
let current=GROUPS[0].key, selRef=null, filter="all", query="", zoom=1, editing=false, confirmBulk=false, online=false;
const pid=(g,r)=>g+"__"+r;
const LS="spike50-board-v1";
function statusOf(g,r){return (state.parts[pid(g,r)]||{}).s||"none"}
async function api(method,url,body,isRaw){
const opt={method,headers:{}};
if(body!==undefined){ if(isRaw){ opt.body=body; opt.headers["Content-Type"]=body.type; } else { opt.body=JSON.stringify(body); opt.headers["Content-Type"]="application/json"; } }
let res;
try{ res=await fetch(url,opt); }catch(_){ const er=new Error("You’re offline"); er.offline=true; throw er; }
if(res.status===401){ location.href="/login"; throw new Error("Please sign in again"); }
const data=await res.json().catch(()=>({}));
if(!res.ok) throw new Error(data.error||("Server answered "+res.status));
return data;
}
// ---------- offline storage + change queue (IndexedDB) ----------
const idb=new Promise((ok,no)=>{ setTimeout(()=>no(new Error("storage timeout")),3000); try{ const r=indexedDB.open("spike-board",1); r.onupgradeneeded=()=>{ r.result.createObjectStore("q",{autoIncrement:true}); r.result.createObjectStore("kv"); }; r.onsuccess=()=>ok(r.result); r.onerror=()=>no(r.error); }catch(e){ no(e); } });
const idbDo=(store,mode,fn)=>idb.then(db=>new Promise((ok,no)=>{ const t=db.transaction(store,mode); const out=fn(t.objectStore(store)); t.oncomplete=()=>ok(out&&"result" in out?out.result:undefined); t.onerror=()=>no(t.error); }));
const kvGet=k=>idbDo("kv","readonly",s=>s.get(k));
const kvSet=(k,v)=>idbDo("kv","readwrite",s=>s.put(v,k));
const qAdd=item=>idbDo("q","readwrite",s=>s.add(item));
const qDel=k=>idbDo("q","readwrite",s=>s.delete(k));
const qAll=()=>idb.then(db=>new Promise((ok,no)=>{ const t=db.transaction("q"); const s=t.objectStore("q"); const ks=s.getAllKeys(), vs=s.getAll(); t.oncomplete=()=>ok(ks.result.map((k,i)=>Object.assign({k},vs.result[i]))); t.onerror=()=>no(t.error); }));
let cacheTimer=null;
function saveCache(){ clearTimeout(cacheTimer); cacheTimer=setTimeout(()=>{ kvSet("state",JSON.parse(JSON.stringify(state))).catch(()=>{}); },150); }
let pendingN=0, tmpId=-1;
const stamp=()=>new Date().toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"});
function badgeNow(){ setBadge(online ? (pendingN?`Syncing ${pendingN} saved change${pendingN>1?"s":""}…`:"Saved on your server · "+stamp()) : (pendingN?`Offline · ${pendingN} change${pendingN>1?"s":""} saved on this phone, will sync later`:"Offline · showing the copy saved on this device")); }
// send a change; if there's no connection keep it on the device and apply it locally
async function send(method,url,body,apply){
try{ return await api(method,url,body); }
catch(e){
if(!e.offline) throw e;
try{ await qAdd({method,url,body}); }catch(_){ throw e; }
online=false; pendingN++; if(apply) apply(); saveCache(); badgeNow(); render();
return {queued:true};
}
}
let flushing=false;
async function flushQueue(){
if(flushing) return pendingN; flushing=true;
try{
for(const it of await qAll()){
try{ await api(it.method,it.url,it.body); await qDel(it.k); }
catch(e){ if(e.offline) break; await qDel(it.k); toast("A change saved offline was rejected: "+e.message); }
}
pendingN=(await qAll()).length;
}catch(_){ pendingN=0; }
finally{ flushing=false; }
return pendingN;
}
function setBadge(t){ $("#savebadge").textContent=t; }
async function reload(){
await flushQueue();
if(pendingN){ online=false; badgeNow(); render(); return; }
try{ const s=await api("GET","api/state"); state=Object.assign(EMPTY(),s); online=true; saveCache(); badgeNow(); }
catch(e){ online=false; badgeNow(); }
render();
}
const writing={};
async function savePart(g,r,patch){
const id=pid(g,r); const cur=Object.assign({}, state.parts[id]||{}, patch);
state.parts[id]=cur; if(patch.s!==undefined) render();
saveCache();
try{ const r=await (writing[id]=(writing[id]||Promise.resolve()).catch(()=>{}).then(()=>send("PUT","api/parts/"+id,{s:cur.s||"none",note:cur.note||""}))); if(!r.queued){ online=true; badgeNow(); } }
catch(e){ toast("Couldn’t save: "+e.message); setBadge("Last change not saved. Check the server."); }
}
let pinTimer=null;
function savePins(g){
clearTimeout(pinTimer);
pinTimer=setTimeout(async()=>{
try{ saveCache(); await send("PUT","api/drawings/"+g+"/pins",{pins:(state.drawings[g]||{}).pins||null}); }catch(e){ toast("Couldn’t save marker positions: "+e.message); }
},400);
}
function pinsFor(g){ const d=state.drawings[g]; if(d&&d.pins) return d.pins; const G=GROUPS.find(x=>x.key===g); return G.pins||{}; }
function imageFor(g){ const d=state.drawings[g]; if(d&&d.image) return d.image; return IMAGES[g]||null; }
const linksOf=(g,r)=>state.links[pid(g,r)]||[];
const commentsOf=(g,r)=>state.comments[pid(g,r)]||[];
function when(ms){ return new Date(ms).toLocaleString([], {day:"2-digit",month:"2-digit",year:"numeric",hour:"2-digit",minute:"2-digit"}); }
function host(u){ try{ return new URL(u).hostname.replace(/^www\./,""); }catch(_){ return u; } }
// ---------- render ----------
const $=s=>document.querySelector(s);
function counts(keys){
const c={have:0,miss:0,ord:0,skip:0,none:0,total:0};
for(const G of GROUPS){ if(keys && !keys.includes(G.key)) continue; for(const r of G.refs){ c[statusOf(G.key,r.r)]++; c.total++; } }
return c;
}
function renderHead(){
const c=counts();
$("#tally").innerHTML = ["have","miss","ord","skip","none"].map(s=>`<span class="chip s-${s}"><span class="dot"></span>${s==="none"?"Unchecked":STATUS[s]} <b class="mono">${c[s]}</b></span>`).join("");
$("#bar").innerHTML = ["have","skip","ord","miss"].map(s=>`<i style="width:${c[s]/c.total*100}%;background:var(--${s})"></i>`).join("");
}
function renderTabs(){
const shopN=counts().miss+counts().ord;
$("#tabs").innerHTML = GROUPS.map(G=>{
const c=counts([G.key]); const done=c.have+c.skip;
return `<button class="tab" role="tab" aria-selected="${current===G.key}" data-g="${G.key}">
<span class="t">${G.title}</span>
<span class="bar">${["have","skip","ord","miss"].map(s=>`<i style="width:${c[s]/c.total*100}%;background:var(--${s})"></i>`).join("")}</span>
<span class="m"><span>${done}/${c.total} done</span><span>${c.miss?`<span style="color:var(--miss)">${c.miss} missing</span>`:(imageFor(G.key)?"drawing":"no drawing yet")}</span></span></button>`;
}).join("") + `<button class="tab photos" role="tab" aria-selected="${current==="gallery"}" data-g="gallery"><span class="t">Photos</span><span class="m"><span>${Object.values(state.photos||{}).reduce((n,l)=>n+l.length,0)} photos</span><span></span></span></button>`
+ `<button class="tab shop" role="tab" aria-selected="${current==="shop"}" data-g="shop"><span class="t">Shopping list</span><span class="m"><span>${shopN} parts to get</span><span></span></span></button>`;
}
function visibleRefs(G){
return G.refs.filter(r=>{
const s=statusOf(G.key,r.r);
if(filter!=="all" && s!==filter) return false;
if(query){ const q=query.toLowerCase(); if(!(r.r===q || r.v.some(v=>v.o.toLowerCase().includes(q)||v.d.toLowerCase().includes(q)))) return false; }
return true;
});
}
function renderMain(){
if(current==="shop") return renderShop();
if(current==="gallery") return renderGallery();
const G=GROUPS.find(x=>x.key===current);
const img=imageFor(G.key); const pins=pinsFor(G.key);
const refset=new Set(G.refs.map(r=>r.r));
let drawing;
if(img){
const pinHtml=Object.entries(pins).flatMap(([r,list])=>list.map((p,i)=>{
const orphan=!refset.has(r); const s=orphan?"none":statusOf(G.key,r);
return `<button class="pin s-${s}${selRef===r?" sel":""}${orphan?" orphan":""}" style="left:${p[0]}%;top:${p[1]}%" data-r="${r}" data-i="${i}" aria-label="Part ${r}${orphan?" (not in parts list)":", "+(STATUS[s]||"unchecked")}"></button>`;
})).join("");
drawing = `<div class="viewport" id="vp"><div class="stage${editing?" editing":""}" id="stage" style="width:${zoom*100}%"><img src="${img}" alt="Exploded drawing: ${G.title}" draggable="false">${pinHtml}</div></div>`;
} else {
drawing = `<div class="nodraw"><h3>No drawing for this group yet</h3>
<p>Open the factory drawing for this group, save it as an image, then add it here. You can then click each number on the drawing to place a marker.</p>
<div class="tools"><a class="btn" href="${SITE+G.key}" target="_blank" rel="noopener">Open drawing on OEM Motorparts</a>
${online?`<label class="btn primary" for="upl">Upload drawing</label><input type="file" id="upl" accept="image/png,image/jpeg,image/webp,image/gif" hidden>`:""}</div></div>`;
}
const c=counts([G.key]);
const rows=visibleRefs(G).map(r=>{
const s=statusOf(G.key,r.r); const note=(state.parts[pid(G.key,r.r)]||{}).note;
const onDraw = pins[r.r]?"":" · not on drawing";
const nl=linksOf(G.key,r.r).length, nc=commentsOf(G.key,r.r).length;
const extra=(nl?` · ${nl} link${nl>1?"s":""}`:"")+(nc?` · ${nc} comment${nc>1?"s":""}`:"");
let html=`<button class="row s-${s}${selRef===r.r?" sel":""}" data-r="${r.r}"><span class="ref">${r.r}</span>
<span style="min-width:0"><span class="rname">${esc(bestName(r))}</span><br><span class="rsub mono">${r.v.length>1?r.v.length+" variants":esc(r.v[0].o)}${img?onDraw:""}${extra}${note?" · "+esc(note):""}</span></span>
<span class="stat"><span class="dot s-${s}" style="display:inline-block;vertical-align:middle"></span> ${s==="none"?"":STATUS[s]}</span></button>`;
if(selRef===r.r) html+=detailHtml(G,r);
return html;
}).join("") || `<div class="empty">No parts match this filter.</div>`;
const orphanSel = selRef && !refset.has(selRef) ? `<div class="detail"><b>Number ${esc(selRef)} on the drawing</b><span class="rsub">This number isn’t in the 2002–2005 parts list. It may be a kit number or a part that only existed on the 2000 model. Check it on the website before ordering.</span>${editing?`<button class="btn" data-act="delpin">Remove this marker</button>`:""}</div>`:"";
$("#main").innerHTML = `<div class="main">
<section class="card">
<div class="dhead"><h2>${G.title}</h2>
<div class="tools">${img?`<button class="btn primary" data-act="fs">⤢ Full screen</button><button class="btn" data-z="-1" aria-label="Zoom out">−</button><span class="mono" style="min-width:3.5em;text-align:center">${Math.round(zoom*100)}%</span><button class="btn" data-z="1" aria-label="Zoom in">+</button>
<button class="btn${editing?" on":""}" data-act="edit">${editing?"Done moving markers":"Move markers"}</button>
${online?`<label class="btn" for="upl">Replace drawing</label><input type="file" id="upl" accept="image/png,image/jpeg,image/webp,image/gif" hidden>`:""}`:""}
<a class="btn" href="${SITE+G.key}" target="_blank" rel="noopener">Source</a></div></div>
${drawing}
${editing?`<div class="note"><b>Marker mode:</b> drag a marker to move it. Select a part in the list, then click the drawing to add a marker for it.</div>`:""}
${NOTES[G.key]?`<div class="note">${NOTES[G.key]}</div>`:""}
</section>
<section class="card">
<div class="lhead">
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap"><span class="lbl">${c.total} numbered parts</span>
${c.none? (confirmBulk?`<span class="confirm">Mark ${c.none} unchecked parts as Have? <button class="btn primary" data-act="bulkyes">Yes</button><button class="btn" data-act="bulkno">Cancel</button></span>`:`<button class="btn" data-act="bulk">Mark the rest as Have</button>`):""}</div>
<input type="search" id="q" placeholder="Search number, part name or OEM no." value="${esc(query)}">
<div class="filters">${[["all","All"],["none","Unchecked"],["miss","Missing"],["ord","Ordered"],["have","Have"],["skip","Not needed"]].map(([k,l])=>`<button class="btn${filter===k?" on":""}" data-f="${k}">${l}</button>`).join("")}</div>
</div>
${orphanSel}
<div class="list" id="list">${rows}</div>
</section></div>`;
if(selRef){ const el=document.querySelector(`.row.sel`); if(el && el.scrollIntoViewIfNeeded) el.scrollIntoViewIfNeeded(); }
}
function detailHtml(G,r){
const p=state.parts[pid(G.key,r.r)]||{}; const s=p.s||"none";
return `<div class="detail" data-detail="${r.r}">
<div class="sbtns">${Object.entries(STATUS).map(([k,l])=>`<button data-s="${k}" aria-pressed="${s===k}">${l}</button>`).join("")}</div>
<div class="vars"><span class="lbl">Catalogue entries for no. ${r.r}</span>${r.v.map(v=>{const f=fits(v.d); return `<div class="var${f===false||(v.c&&v.c!=="C"&&G.key==="plastics")?" no":""}">
<a class="o" href="https://www.oemmotorparts.com/en/search?filter=${encodeURIComponent(v.o)}" target="_blank" rel="noopener">${esc(v.o)}</a><a class="o" style="font-size:12px;opacity:.8" title="Search Google for this number" href="https://www.google.com/search?q=${encodeURIComponent('"'+v.o+'" Rieju Spike 50')}" target="_blank" rel="noopener">Google</a><span class="d">${esc(v.d)}</span>
<span class="tags">${variantTags(v).map(([c,t])=>`<span class="tag ${c}">${t}</span>`).join("")}</span></div>`}).join("")}</div>
<label class="lbl" for="note">Note</label>
<textarea id="note" rows="2" placeholder="Where it is, condition, where you ordered it…">${esc(p.note||"")}</textarea>
<div class="sub"><span class="lbl">Links</span>
${linksOf(G.key,r.r).map(l=>`<div class="item"><span><a href="${esc(l.url)}" target="_blank" rel="noopener noreferrer">${esc(l.label||host(l.url))}</a><br><span class="when">${esc(host(l.url))} · added ${when(l.at)}</span></span>${l.id>0?`<button class="x" data-dellink="${l.id}" aria-label="Remove link">×</button>`:`<span class="when">waiting</span>`}</div>`).join("")}
<form class="addrow" data-form="link"><input type="url" id="lurl" placeholder="https://… (eBay listing, shop, forum post)" required><input type="text" id="llabel" placeholder="Label (optional)"><button class="btn primary" type="submit">Add link</button></form>
</div>
<div class="sub"><span class="lbl">Comments</span>
${commentsOf(G.key,r.r).map(c=>`<div class="item"><span><p>${esc(c.text)}</p><span class="when">${when(c.at)}</span></span>${c.id>0?`<button class="x" data-delcomment="${c.id}" aria-label="Delete comment">×</button>`:`<span class="when">waiting</span>`}</div>`).join("")}
<form class="sub" data-form="comment"><textarea id="ctext" rows="2" placeholder="Write a comment, e.g. “Ordered from Motostion, 12 € + shipping”" required></textarea><div><button class="btn primary" type="submit">Add comment</button></div></form>
</div>
<div class="sub"><span class="lbl">Photos</span>
<div class="pgrid">${photoItems(pid(G.key,r.r)).map((p,i)=>`<button class="ph" data-ph="${pid(G.key,r.r)}|${i}" aria-label="Open photo"><img loading="lazy" src="${p.thumb}" alt="">${p.pending?`<i class="pend">waiting</i>`:""}</button>`).join("")}</div>
<div><label class="btn" for="pupl">+ Add photo</label><input type="file" id="pupl" accept="image/*" multiple hidden></div>
</div>
${editing&&pinsFor(G.key)[r.r]?`<button class="btn" data-act="delpin">Remove this part’s markers</button>`:""}
</div>`;
}
function renderShop(){
const sections=GROUPS.map(G=>{
const items=G.refs.filter(r=>["miss","ord"].includes(statusOf(G.key,r.r)));
if(!items.length) return "";
return `<div><h3 style="font-size:16px;margin-bottom:6px">${G.title}</h3><div class="tblwrap"><table><thead><tr><th>No.</th><th>Part</th><th>OEM number(s)</th><th>Status</th><th>Note &amp; links</th></tr></thead><tbody>${items.map(r=>{
const p=state.parts[pid(G.key,r.r)]||{};
const good=r.v.filter(v=>fits(v.d)!==false && (G.key!=="plastics"||!v.c||v.c==="C"));
return `<tr><td class="mono">${r.r}</td><td>${esc(bestName(r))}</td><td class="mono">${(good.length?good:r.v).map(v=>`<a href="https://www.oemmotorparts.com/en/search?filter=${encodeURIComponent(v.o)}" target="_blank" rel="noopener">${esc(v.o)}</a>${v.a?"":" <span class=\"tag\">unavailable</span>"}`).join("<br>")}</td><td><span class="chip s-${p.s}"><span class="dot"></span>${STATUS[p.s]}</span></td><td>${esc(p.note||"")}${linksOf(G.key,r.r).map(l=>`<br><a href="${esc(l.url)}" target="_blank" rel="noopener noreferrer">${esc(l.label||host(l.url))}</a>`).join("")}</td></tr>`}).join("")}</tbody></table></div></div>`;
}).join("");
$("#main").innerHTML=`<section class="card shop"><div style="display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap;align-items:center"><h2 style="font-size:20px">Shopping list</h2><button class="btn primary" data-act="copy">Copy list</button></div>
<p class="rsub" style="margin:0">Everything marked Missing or Ordered. Variants that don’t fit frame 550038 or aren’t your colour are left out. Unavailable numbers are worth searching on eBay and at breakers.</p>
${sections||`<div class="empty">Nothing marked Missing yet. Open a drawing group and mark the parts you don’t have.</div>`}</section>`;
}
function render(){ renderHead(); renderTabs(); renderMain(); }
function esc(s){return String(s).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;"}[c]))}
function toast(t){const el=$("#toast"); el.textContent=t; el.hidden=false; clearTimeout(toast.t); toast.t=setTimeout(()=>el.hidden=true,2600);}
// ---------- events ----------
document.addEventListener("click",async e=>{
if(e.target.closest("#stage") && !editing && !e.target.closest(".pin")){ openDrawing(current); return; }
const t=e.target.closest("button,[data-g]"); if(!t) return;
if(t.dataset.ph){ const [sc,i]=t.dataset.ph.split("|"); openPhotos(photoItems(sc),+i); return; }
if(t.dataset.g){ current=t.dataset.g; selRef=null; editing=false; confirmBulk=false; zoom=1; render(); try{localStorage.setItem(LS+"-tab",current)}catch(_){} return; }
if(t.classList.contains("pin")){ if(dragMoved) return; selRef=t.dataset.r; filter="all"; render(); return; }
if(t.classList.contains("row")){ selRef = selRef===t.dataset.r?null:t.dataset.r; render(); return; }
if(t.dataset.s && t.closest(".detail")){ const cur=statusOf(current,selRef); savePart(current,selRef,{s: cur===t.dataset.s?"none":t.dataset.s}); return; }
if(t.dataset.f){ filter=t.dataset.f; render(); return; }
if(t.dataset.z){ zoom=Math.min(3,Math.max(1,zoom+ (+t.dataset.z)*0.5)); render(); return; }
if(t.dataset.dellink){ const id=+t.dataset.dellink; try{ const r=await send("DELETE","api/links/"+id,undefined,()=>{ for(const k in state.links) state.links[k]=state.links[k].filter(l=>l.id!==id); }); if(!r.queued) await reload(); }catch(err){ toast("Couldn’t remove: "+err.message); } return; }
if(t.dataset.delcomment){ const id=+t.dataset.delcomment; try{ const r=await send("DELETE","api/comments/"+id,undefined,()=>{ for(const k in state.comments) state.comments[k]=state.comments[k].filter(c=>c.id!==id); }); if(!r.queued) await reload(); }catch(err){ toast("Couldn’t delete: "+err.message); } return; }
const a=t.dataset.act;
if(a==="fs"){ openDrawing(current); return; }
if(a==="edit"){ editing=!editing; render(); }
if(a==="bulk"){ confirmBulk=true; render(); }
if(a==="bulkno"){ confirmBulk=false; render(); }
if(a==="bulkyes"){ confirmBulk=false; const G=GROUPS.find(x=>x.key===current); for(const r of G.refs){ if(statusOf(G.key,r.r)==="none") await savePart(G.key,r.r,{s:"have"}); } toast("Marked as Have"); }
if(a==="delpin"){ const p=Object.assign({},pinsFor(current)); delete p[selRef]; state.drawings[current]=Object.assign({},state.drawings[current],{pins:p}); savePins(current); render(); }
if(a==="copy"){ const txt=GROUPS.flatMap(G=>G.refs.filter(r=>["miss","ord"].includes(statusOf(G.key,r.r))).map(r=>{const good=r.v.filter(v=>fits(v.d)!==false&&(G.key!=="plastics"||!v.c||v.c==="C"));return `${G.title} #${r.r}\t${bestName(r)}\t${(good.length?good:r.v).map(v=>v.o).join(" / ")}\t${STATUS[statusOf(G.key,r.r)]}`;})).join("\n");
try{ await navigator.clipboard.writeText(txt); toast("Copied"); }catch(_){ toast("Copy didn’t work here. Select the table and copy it instead."); } }
});
document.addEventListener("input",e=>{
if(e.target.id==="q"){ query=e.target.value.trim(); const pos=e.target.selectionStart; renderMain(); const q=$("#q"); q.focus(); q.setSelectionRange(pos,pos); }
});
document.addEventListener("change",async e=>{
if(e.target.id==="note"){ savePart(current,selRef,{note:e.target.value}); }
if((e.target.id==="pupl"||e.target.id==="gupl") && e.target.files.length){
const files=[...e.target.files], scope=e.target.id==="gupl"?"gallery":pid(current,selRef), cap=e.target.id==="gupl"?($("#gcap")||{}).value:"";
e.target.value=""; await addPhotos(files,scope,cap);
}
if(e.target.id==="upl" && e.target.files[0]){
toast("Uploading…");
try{ await api("POST","api/drawings/"+current+"/image",e.target.files[0],true); await reload(); editing=true; render(); toast("Drawing added. Select a part, then click its number on the drawing."); }
catch(err){ toast("Upload failed: "+err.message); }
}
});
document.addEventListener("submit",async e=>{
const f=e.target.closest("form[data-form]"); if(!f) return; e.preventDefault();
const id=pid(current,selRef);
try{
let r;
if(f.dataset.form==="link"){ const b={url:$("#lurl").value,label:$("#llabel").value};
r=await send("POST","api/parts/"+id+"/links",b,()=>{ (state.links[id]=state.links[id]||[]).push({id:tmpId--,url:b.url,label:b.label,at:Date.now()}); }); toast(r.queued?"Link saved offline":"Link added"); }
else { const b={text:$("#ctext").value};
r=await send("POST","api/parts/"+id+"/comments",b,()=>{ (state.comments[id]=state.comments[id]||[]).push({id:tmpId--,text:b.text,at:Date.now()}); }); toast(r.queued?"Comment saved offline":"Comment added"); }
if(!r.queued) await reload();
}catch(err){ toast(err.message); }
});
// marker drag + place
let drag=null, dragMoved=false;
document.addEventListener("pointerdown",e=>{
if(!editing) return; const pin=e.target.closest(".pin"); const stage=e.target.closest("#stage"); if(!stage) return;
const rect=stage.getBoundingClientRect();
if(pin){ drag={pin,r:pin.dataset.r,i:+pin.dataset.i,rect}; dragMoved=false; pin.setPointerCapture(e.pointerId); e.preventDefault(); return; }
if(selRef){ const x=(e.clientX-rect.left)/rect.width*100, y=(e.clientY-rect.top)/rect.height*100;
const p=JSON.parse(JSON.stringify(pinsFor(current))); (p[selRef]=p[selRef]||[]).push([+x.toFixed(2),+y.toFixed(2)]);
state.drawings[current]=Object.assign({},state.drawings[current],{pins:p}); savePins(current); render(); }
else toast("Select a part in the list first.");
});
document.addEventListener("pointermove",e=>{ if(!drag) return; dragMoved=true;
const x=Math.min(100,Math.max(0,(e.clientX-drag.rect.left)/drag.rect.width*100)), y=Math.min(100,Math.max(0,(e.clientY-drag.rect.top)/drag.rect.height*100));
drag.pin.style.left=x+"%"; drag.pin.style.top=y+"%"; drag.x=x; drag.y=y; });
document.addEventListener("pointerup",()=>{ if(!drag) return;
if(dragMoved && drag.x!=null){ const p=JSON.parse(JSON.stringify(pinsFor(current))); if(p[drag.r]&&p[drag.r][drag.i]) p[drag.r][drag.i]=[+drag.x.toFixed(2),+drag.y.toFixed(2)];
state.drawings[current]=Object.assign({},state.drawings[current],{pins:p}); savePins(current); }
else { selRef=drag.r; render(); }
drag=null; setTimeout(()=>dragMoved=false,0); });
// ---------- photos ----------
function partLabel(k){ const [g,r]=k.split("__"); const G=GROUPS.find(x=>x.key===g); return G?`${G.title} #${r}`:k; }
function photoItems(scope){
const mk=(k,p)=>Object.assign({},p,{scope:k,label:k==="gallery"?"":partLabel(k)});
if(scope==="parts") return Object.entries(state.photos||{}).filter(([k])=>k!=="gallery").flatMap(([k,l])=>l.map(p=>mk(k,p)));
return ((state.photos||{})[scope]||[]).map(p=>mk(scope,p));
}
async function prepPhoto(file){
const bmp=await createImageBitmap(file,{imageOrientation:"from-image"});
const mk=(max,q)=>{ const k=Math.min(1,max/Math.max(bmp.width,bmp.height)); const c=document.createElement("canvas"); c.width=Math.round(bmp.width*k); c.height=Math.round(bmp.height*k); c.getContext("2d").drawImage(bmp,0,0,c.width,c.height); return c.toDataURL("image/jpeg",q); };
const out={image:mk(2000,.85),thumb:mk(400,.75)}; if(bmp.close) bmp.close(); return out;
}
async function addPhotos(files,scope,caption){
let n=0, queued=false;
toast(`Adding ${files.length} photo${files.length>1?"s":""}…`);
for(const f of files){
try{
const d=await prepPhoto(f); const body={scope,caption:caption||"",image:d.image,thumb:d.thumb};
const r=await send("POST","api/photos",body,()=>{ (state.photos[scope]=state.photos[scope]||[]).unshift({id:tmpId--,caption:body.caption,at:Date.now(),src:d.image,thumb:d.thumb,pending:true}); });
if(r.queued) queued=true; n++;
}catch(e){ toast("Couldn’t add “"+f.name+"”: "+e.message); }
}
if(!queued) await reload(); else render();
if(n) toast(queued?`${n} photo${n>1?"s":""} saved on this phone, will upload when online`:`${n} photo${n>1?"s":""} added`);
}
let gq="";
function renderGallery(){
const all=photoItems("gallery"); const list=gq?all.filter(p=>p.caption.toLowerCase().includes(gq)):all;
const parts=photoItems("parts");
const tile=(p,key,i)=>`<button class="ph" data-ph="${key}|${i}" aria-label="Open photo"><img loading="lazy" src="${p.thumb}" alt="${esc(p.caption)}">${p.pending?`<i class="pend">waiting</i>`:""}${(p.label||p.caption)?`<span>${esc(p.label?p.label+(p.caption?" · "+p.caption:""):p.caption)}</span>`:""}</button>`;
$("#main").innerHTML=`<section class="card shop">
<div><h2 style="font-size:20px">Photos &amp; disassembly</h2><p class="rsub" style="margin:4px 0 0">Shared photos of the bike: what you took apart, condition, how things fit. Tap a photo to open and zoom. Photos of single parts are added inside the part (open a part, then “Add photo”).</p></div>
<div class="gup"><input type="text" id="gcap" placeholder="Caption for the new photos (optional), e.g. “Engine out, left side”"><label class="btn primary" for="gupl">+ Add photos</label><input type="file" id="gupl" accept="image/*" multiple hidden></div>
${all.length>6?`<input type="search" id="gq" placeholder="Search captions" value="${esc(gq)}">`:""}
${list.length?`<div class="pgrid big">${list.map((p,i)=>tile(p,"gallery",all.indexOf(p))).join("")}</div>`:`<div class="empty">${all.length?"No photos match.":"No photos yet. Add the first one."}</div>`}
${parts.length?`<div><h3 style="font-size:16px;margin-bottom:8px">Photos attached to parts</h3><div class="pgrid big">${parts.map((p,i)=>tile(p,"parts",i)).join("")}</div></div>`:""}
</section>`;
}
document.addEventListener("input",e=>{ if(e.target.id==="gq"){ gq=e.target.value.trim().toLowerCase(); const pos=e.target.selectionStart; renderGallery(); const q=$("#gq"); q.focus(); q.setSelectionRange(pos,pos); } });
// ---------- full-screen viewer (photos + drawings): pinch, drag, wheel, double-tap ----------
const vw={open:false,s:1,tx:0,ty:0,nw:0,nh:0,fw:0,fh:0,cw:0,ch:0,mode:"",list:[],i:0};
const vEl={};
const clamp=(v,a,b)=>Math.min(b,Math.max(a,v));
function vwClamp(){ const w=vw.fw*vw.s,h=vw.fh*vw.s;
vw.tx=w<=vw.cw?(vw.cw-w)/2:clamp(vw.tx,vw.cw-w,0); vw.ty=h<=vw.ch?(vw.ch-h)/2:clamp(vw.ty,vw.ch-h,0); }
function vwApply(){ vwClamp(); vEl.st.style.transform=`translate(${vw.tx}px,${vw.ty}px) scale(${vw.s})`; vEl.st.style.setProperty("--k",1/vw.s); }
function vwLayout(){ vw.cw=vEl.cv.clientWidth; vw.ch=vEl.cv.clientHeight; if(!vw.nw||!vw.cw) return;
const base=Math.min(vw.cw/vw.nw,vw.ch/vw.nh); vw.fw=vw.nw*base; vw.fh=vw.nh*base;
vEl.st.style.width=vw.fw+"px"; vEl.st.style.height=vw.fh+"px"; vwApply(); }
function vwZoomAt(ns,cx,cy){ ns=clamp(ns,1,8); const px=(cx-vw.tx)/vw.s, py=(cy-vw.ty)/vw.s; vw.s=ns; vw.tx=cx-px*ns; vw.ty=cy-py*ns; vwApply(); }
function vwZoomCentre(f){ vwZoomAt(vw.s*f,vw.cw/2,vw.ch/2); }
function vwFocusPin(r){ const pl=(pinsFor(current)[r]||[])[0]; if(!pl) return;
const ns=Math.max(vw.s,3); const px=pl[0]/100*vw.fw, py=pl[1]/100*vw.fh; vw.s=clamp(ns,1,8); vw.tx=vw.cw/2-px*vw.s; vw.ty=vw.ch/2-py*vw.s; vwApply(); }
function vwLoadImage(src,white,after){
vw.nw=0; vEl.st.hidden=true; vw.s=1;
const im=new Image(); im.alt=""; im.draggable=false;
im.onload=()=>{ vw.nw=im.naturalWidth; vw.nh=im.naturalHeight;
vEl.st.querySelector("img")?.remove(); vEl.st.prepend(im); vEl.st.classList.toggle("white",!!white);
vEl.st.hidden=false; vwLayout(); if(after) after(); };
im.onerror=()=>{ toast("Couldn’t load this image (offline and not saved yet?)"); };
im.src=src;
}
function vwPinsHtml(){
const G=GROUPS.find(x=>x.key===current); const refset=new Set(G.refs.map(r=>r.r));
return Object.entries(pinsFor(current)).flatMap(([r,list])=>list.map((p,i)=>{
const orphan=!refset.has(r); const st=orphan?"none":statusOf(current,r);
return `<button class="pin s-${st}${selRef===r?" sel":""}${orphan?" orphan":""}" style="left:${p[0]}%;top:${p[1]}%" data-vr="${r}" aria-label="Part ${r}">${selRef===r&&i===0?`<span class="lab">${r}</span>`:""}</button>`; })).join("");
}
function vwDrawingSheet(){
const G=GROUPS.find(x=>x.key===current); const pins=pinsFor(current);
const refs=G.refs.filter(r=>pins[r.r]);
const chips=refs.map(r=>`<button class="vchip s-${statusOf(current,r.r)}${selRef===r.r?" sel":""}" data-vr="${r.r}" data-fly="1">${r.r}</button>`).join("");
const R=selRef&&G.refs.find(r=>r.r===selRef);
const sel=R?`<div class="vsel"><div class="nm"><span class="mono">#${R.r}</span> ${esc(bestName(R))}</div>
<div class="sbtns">${Object.entries(STATUS).map(([k,l])=>`<button data-vs="${k}" aria-pressed="${statusOf(current,R.r)===k}" data-s="${k}">${l}</button>`).join("")}</div>
<div class="tools"><button class="btn" data-v="details">Notes, links &amp; photos</button><a class="btn" target="_blank" rel="noopener" href="https://www.oemmotorparts.com/en/search?filter=${encodeURIComponent(R.v[0].o)}">Find part</a></div></div>`
:`<div class="rsub">Tap a marker on the drawing, or a number below, to select a part. Pinch or double-tap to zoom.</div>`;
vEl.bt.innerHTML=`${sel}<div class="vchips">${chips||'<span class="rsub">No markers on this drawing yet.</span>'}</div>`;
vEl.st.querySelectorAll(".pin").forEach(n=>n.remove()); vEl.st.insertAdjacentHTML("beforeend",vwPinsHtml());
vEl.ti.textContent=G.title;
}
function vwPhotoSheet(){
const p=vw.list[vw.i]; if(!p) return;
vEl.ti.textContent=`${vw.i+1} / ${vw.list.length}`;
vEl.bt.innerHTML=`<div class="cap">${esc(p.label||"")}${p.label&&p.caption?" · ":""}${esc(p.caption||"")}${!p.label&&!p.caption?'<span class="rsub">No caption</span>':""}${p.at?` <span class="rsub">· ${when(p.at)}</span>`:""}</div>
${p.pending?'<div class="rsub">Waiting to upload.</div>':`<div class="tools"><button class="btn" data-v="cap">Edit caption</button><button class="btn" data-v="del">Delete photo</button></div>`}`;
vEl.pv.hidden=vEl.nx.hidden=vw.list.length<2;
}
function vwShowPhoto(){ const p=vw.list[vw.i]; vwLoadImage(p.src,false); vwPhotoSheet(); }
function openViewer(){
Object.assign(vEl,{root:$("#viewer"),cv:$("#vcanvas"),st:$("#vstage"),bt:$("#vbottom"),ti:$("#vtitle"),pv:document.querySelector(".vnav.prev"),nx:document.querySelector(".vnav.next")});
if(!vEl.ro){ vEl.ro=new ResizeObserver(()=>vwLayout()); vEl.ro.observe(vEl.cv); }
vEl.root.hidden=false; vw.open=true; document.body.style.overflow="hidden";
try{ history.pushState({v:1},""); }catch(_){}
}
function openDrawing(g){ const img=imageFor(g); if(!img) return; vw.mode="drawing"; openViewer(); vEl.st.querySelectorAll(".pin").forEach(n=>n.remove());
vEl.pv.hidden=vEl.nx.hidden=true; vwLoadImage(img,true,()=>{ vwDrawingSheet(); }); vwDrawingSheet(); }
function openPhotos(list,i){ if(!list.length) return; vw.mode="photos"; vw.list=list; vw.i=clamp(i,0,list.length-1); openViewer(); vEl.st.querySelectorAll(".pin").forEach(n=>n.remove()); vwShowPhoto(); }
function hideViewer(){ if(!vw.open) return; vw.open=false; vEl.root.hidden=true; document.body.style.overflow=""; vEl.st.innerHTML=""; }
function closeViewer(){ if(history.state&&history.state.v){ history.back(); } else hideViewer(); }
window.addEventListener("popstate",()=>hideViewer());
function vwSelect(r,fly){ selRef=r; filter="all"; vwDrawingSheet(); if(fly) vwFocusPin(r); render(); }
function vwStep(d){ if(vw.mode!=="photos"||vw.list.length<2) return; vw.i=(vw.i+d+vw.list.length)%vw.list.length; vwShowPhoto(); }
async function vwDelete(){
const p=vw.list[vw.i]; if(!p||p.id<0||!confirm("Delete this photo for everyone?")) return;
try{ const r=await send("DELETE","api/photos/"+p.id,undefined,()=>{ for(const k in state.photos) state.photos[k]=state.photos[k].filter(x=>x.id!==p.id); });
vw.list.splice(vw.i,1); if(!vw.list.length){ closeViewer(); } else { vw.i=Math.min(vw.i,vw.list.length-1); vwShowPhoto(); }
if(!r.queued) await reload(); else render(); }
catch(e){ toast("Couldn’t delete: "+e.message); }
}
async function vwCaption(){
const p=vw.list[vw.i]; const c=prompt("Caption",p.caption||""); if(c===null) return;
try{ const r=await send("PUT","api/photos/"+p.id,{caption:c},()=>{ const q=(state.photos[p.scope]||[]).find(x=>x.id===p.id); if(q) q.caption=c; });
p.caption=c; vwPhotoSheet(); if(!r.queued) await reload(); else render(); }
catch(e){ toast("Couldn’t save: "+e.message); }
}
document.addEventListener("click",async e=>{
const v=e.target.closest("#viewer"); if(!v) return; e.stopPropagation();
if(vw.moved){ return; }
const b=e.target.closest("button"); if(!b) return;
if(b.dataset.vr){ vwSelect(b.dataset.vr,!!b.dataset.fly); return; }
if(b.dataset.vs){ const R=selRef; const cur=statusOf(current,R); await savePart(current,R,{s:cur===b.dataset.vs?"none":b.dataset.vs}); vwDrawingSheet(); return; }
switch(b.dataset.v){
case "close": closeViewer(); break;
case "zin": vwZoomCentre(1.6); break;
case "zout": vwZoomCentre(1/1.6); break;
case "fit": vw.s=1; vwApply(); break;
case "prev": vwStep(-1); break;
case "next": vwStep(1); break;
case "del": vwDelete(); break;
case "cap": vwCaption(); break;
case "details": { const r=selRef; closeViewer(); setTimeout(()=>{ selRef=r; render(); },50); break; }
}
},true);
(function(){
const cv=document.getElementById("vcanvas"); const ptrs=new Map(); let g=null, lastTap=0, lastX=0, lastY=0;
const pt=e=>{ const r=cv.getBoundingClientRect(); return {x:e.clientX-r.left,y:e.clientY-r.top}; };
const begin=()=>{ const a=[...ptrs.values()];
if(a.length===1) g={t:"pan",x:a[0].x,y:a[0].y,tx:vw.tx,ty:vw.ty,sx:a[0].x,sy:a[0].y};
else if(a.length>=2){ g={t:"pinch",d:Math.hypot(a[0].x-a[1].x,a[0].y-a[1].y)||1,s:vw.s,mx:(a[0].x+a[1].x)/2,my:(a[0].y+a[1].y)/2,tx:vw.tx,ty:vw.ty}; } else g=null; };
cv.addEventListener("pointerdown",e=>{ if(e.target.closest(".vnav")) return; ptrs.set(e.pointerId,pt(e)); vw.moved=false; begin(); });
window.addEventListener("pointermove",e=>{ if(!ptrs.has(e.pointerId)||!g) return; const p=pt(e); ptrs.set(e.pointerId,p); const a=[...ptrs.values()];
if(g.t==="pan"&&a.length===1){ const dx=p.x-g.sx, dy=p.y-g.sy; if(Math.abs(dx)+Math.abs(dy)>8) vw.moved=true; vw.tx=g.tx+(p.x-g.x); vw.ty=g.ty+(p.y-g.y); vwApply(); }
else if(g.t==="pinch"&&a.length>=2){ vw.moved=true; const d=Math.hypot(a[0].x-a[1].x,a[0].y-a[1].y), mx=(a[0].x+a[1].x)/2, my=(a[0].y+a[1].y)/2;
const ns=clamp(g.s*d/g.d,1,8); const px=(g.mx-g.tx)/g.s, py=(g.my-g.ty)/g.s; vw.s=ns; vw.tx=mx-px*ns; vw.ty=my-py*ns; vwApply(); } });
const up=e=>{ if(!ptrs.has(e.pointerId)) return; const p=ptrs.get(e.pointerId); ptrs.delete(e.pointerId);
if(g&&g.t==="pan"&&ptrs.size===0){
const dx=p.x-g.sx, dy=p.y-g.sy;
if(vw.mode==="photos"&&vw.s<1.05&&Math.abs(dx)>70&&Math.abs(dx)>Math.abs(dy)*1.5){ vwStep(dx<0?1:-1); }
else if(!vw.moved&&e.pointerType==="touch"&&!e.target.closest(".pin")){ const now=Date.now();
if(now-lastTap<320&&Math.hypot(p.x-lastX,p.y-lastY)<30){ vwZoomAt(vw.s>1.2?1:2.8,p.x,p.y); lastTap=0; } else { lastTap=now; lastX=p.x; lastY=p.y; } }
}
begin(); setTimeout(()=>{ if(!ptrs.size) vw.moved=false; },60); };
window.addEventListener("pointerup",up); window.addEventListener("pointercancel",up);
cv.addEventListener("wheel",e=>{ e.preventDefault(); const p=pt(e); vwZoomAt(vw.s*Math.exp(-e.deltaY*0.0018),p.x,p.y); },{passive:false});
cv.addEventListener("dblclick",e=>{ if(e.target.closest(".pin")) return; const p=pt(e); vwZoomAt(vw.s>1.2?1:2.8,p.x,p.y); });
window.addEventListener("keydown",e=>{ if(!vw.open) return; if(e.key==="Escape") closeViewer(); if(e.key==="ArrowLeft") vwStep(-1); if(e.key==="ArrowRight") vwStep(1); if(e.key==="+"||e.key==="=") vwZoomCentre(1.4); if(e.key==="-") vwZoomCentre(1/1.4); });
})();
// ---------- boot ----------
try{ const t=localStorage.getItem(LS+"-tab"); if(t && (t==="shop"||t==="gallery"||GROUPS.some(g=>g.key===t))) current=t; }catch(_){}
if("serviceWorker" in navigator) navigator.serviceWorker.register("sw.js").catch(()=>{});
window.addEventListener("online",()=>reload());
window.addEventListener("offline",()=>{ online=false; badgeNow(); });
render();
(async()=>{ try{ const c=await kvGet("state"); if(c&&!online) state=Object.assign(EMPTY(),c); pendingN=(await qAll()).length; }catch(_){} render(); reload(); })();
// pick up changes made on another device when you come back to the tab
document.addEventListener("visibilitychange",()=>{ if(!document.hidden && !drag && !document.activeElement.matches("textarea,input")) reload(); });
</script>
</body>
</html>