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:
commit
9b9516026b
17 files changed
+1088
No files matched your search
@@ -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 & 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=>({"&":"&","<":"<",">":">","\"":"""}[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 & 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 & 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>
|
||||
Reference in new issue
Block a user