destogod/app/index.html
type-two f293885cd0 Show the spreadsheet column mapping instead of guessing at it
Importing a sheet guessed which column was the controller name and which was
the sign text, then silently acted on the guess. On a sheet whose headers it
did not recognise, the guess fell through to "first column", which is usually
the row number — so the destinations came out named 1, 2, 3.

Now the import opens a preview: the two columns are dropdowns, seeded with the
guess and showing a sample of each, above a table of exactly what will be
created. Nothing is imported until it looks right.

The guessing itself is better too:
  - a top row of words above rows containing numbers is treated as a header
    even when none of its labels are recognised
  - with no recognisable columns, the column carrying the longest words wins
  - one recognised column stands in for the other, since a destination's name
    is usually exactly what the sign shows, rather than picking at random
  - a truncated name no longer keeps a trailing space, which would also stop it
    matching an existing destination on a later import

Also adds custom sign sizes, for boards that are not one of the three fitted
models. Widths snap to a multiple of 8 since the record stores width in bytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:42:27 +10:00

238 lines
11 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DestoGod — Bus Destination Sign Editor</title>
<style>
:root{
--bg:#14161a; --panel:#1c1f26; --panel2:#232732; --line:#31374a;
--ink:#e8ecf5; --dim:#98a2b8; --amber:#ffb000; --amber-dim:#3a2a05;
--accent:#4a9eff; --good:#3ddc84; --warn:#ffb74d; --bad:#ff6b6b;
--r:10px;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0; background:var(--bg); color:var(--ink);
font:14px/1.45 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
display:flex; flex-direction:column; overflow:hidden;
}
button,input,select,textarea{font:inherit;color:inherit}
button{
background:var(--panel2); border:1px solid var(--line); color:var(--ink);
padding:8px 14px; border-radius:8px; cursor:pointer; white-space:nowrap;
}
button:hover:not(:disabled){background:#2c3242; border-color:#465073}
button:disabled{opacity:.4; cursor:default}
button.primary{background:var(--accent); border-color:var(--accent); color:#08131f; font-weight:650}
button.primary:hover:not(:disabled){background:#68b0ff}
button.ghost{background:transparent}
button.danger:hover:not(:disabled){background:#3a2226; border-color:#7d3b43; color:#ffb3b3}
input[type=text],input[type=number],select,textarea{
background:#12141a; border:1px solid var(--line); border-radius:8px; padding:8px 10px; width:100%;
}
input:focus,select:focus,textarea:focus{outline:2px solid var(--accent); outline-offset:-1px; border-color:transparent}
label{display:block; font-size:12px; color:var(--dim); margin-bottom:5px; font-weight:600; letter-spacing:.02em}
/* ---------- top bar ---------- */
header{
display:flex; align-items:center; gap:14px; padding:12px 18px;
background:var(--panel); border-bottom:1px solid var(--line); flex:0 0 auto; flex-wrap:wrap;
}
.brand{display:flex; align-items:baseline; gap:9px; margin-right:4px}
.brand b{font-size:19px; letter-spacing:-.02em}
.brand span{font-size:11px; color:var(--dim)}
.field{display:flex; align-items:center; gap:7px}
.field label{margin:0}
.field input,.field select{width:auto; min-width:120px}
.spacer{flex:1}
/* ---------- layout ---------- */
main{flex:1; display:grid; grid-template-columns:290px 1fr; min-height:0}
#sidebar{
background:var(--panel); border-right:1px solid var(--line);
display:flex; flex-direction:column; min-height:0;
}
.side-head{
display:flex; align-items:center; justify-content:space-between; gap:8px;
padding:11px 12px; border-bottom:1px solid var(--line);
}
.side-head h2{margin:0; font-size:12px; text-transform:uppercase; letter-spacing:.08em; color:var(--dim)}
#list{overflow-y:auto; flex:1; padding:6px}
.item{
display:flex; align-items:center; gap:9px; padding:7px 9px; border-radius:8px;
cursor:pointer; border:1px solid transparent;
}
.item:hover{background:var(--panel2)}
.item.sel{background:#20304a; border-color:#37517d}
.item .num{font-size:10px; color:var(--dim); width:20px; text-align:right; flex:0 0 auto; font-variant-numeric:tabular-nums}
.item .body{min-width:0; flex:1}
.item .nm{font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-weight:600}
.item canvas{display:block; image-rendering:pixelated; margin-top:3px; opacity:.85}
.item .tag{font-size:10px; color:var(--dim)}
.item .tag.warn{color:var(--warn)}
.item .del{
flex:0 0 auto; width:24px; height:24px; padding:0; line-height:1; font-size:16px;
border-radius:6px; background:transparent; border:1px solid transparent; color:var(--dim);
opacity:0; transition:opacity .12s ease;
}
.item:hover .del,.item.sel .del{opacity:1}
.item .del:hover{background:#3a2226; border-color:#7d3b43; color:#ffb3b3}
.empty{padding:26px 16px; text-align:center; color:var(--dim); font-size:13px}
#toast button{
margin-left:12px; padding:3px 11px; font-size:12px; font-weight:650;
background:var(--accent); border-color:var(--accent); color:#08131f;
}
/* ---------- editor ---------- */
#editor{overflow-y:auto; padding:20px 24px; min-height:0}
.wrap{max-width:900px; margin:0 auto}
.card{background:var(--panel); border:1px solid var(--line); border-radius:var(--r); padding:18px; margin-bottom:16px}
.card > h3{margin:0 0 14px; font-size:12px; text-transform:uppercase; letter-spacing:.08em; color:var(--dim)}
.row{display:flex; gap:12px; flex-wrap:wrap}
.row > *{flex:1; min-width:150px}
/* ---------- LED preview ---------- */
.led-stage{
background:#000; border:1px solid #2a2f3d; border-radius:8px; padding:14px;
display:flex; justify-content:center; overflow:hidden;
}
.led-stage canvas{display:block; image-rendering:pixelated}
.meta{display:flex; gap:14px; flex-wrap:wrap; margin-top:11px; font-size:12px; color:var(--dim)}
.meta b{color:var(--ink); font-weight:600}
.pill{
display:inline-flex; align-items:center; gap:5px; padding:2px 9px; border-radius:20px;
font-size:11px; font-weight:650; border:1px solid;
}
.pill.ok{color:var(--good); border-color:#235b3c; background:#12251b}
.pill.scroll{color:var(--warn); border-color:#6b4d1c; background:#241c0c}
.pill.bad{color:var(--bad); border-color:#6b2b2b; background:#241111}
/* ---------- pages ---------- */
.page{border:1px solid var(--line); border-radius:9px; padding:14px; margin-bottom:11px; background:var(--panel2)}
.page-head{display:flex; align-items:center; gap:10px; margin-bottom:11px}
.page-head .lbl{font-size:11px; text-transform:uppercase; letter-spacing:.07em; color:var(--dim); font-weight:700}
.seg{display:inline-flex; border:1px solid var(--line); border-radius:8px; overflow:hidden}
.seg button{border:0; border-radius:0; padding:7px 13px; background:transparent; font-size:13px}
.seg button.on{background:var(--accent); color:#08131f; font-weight:650}
.hint{font-size:12px; color:var(--dim); margin-top:7px}
.hint.warn{color:var(--warn)}
/* ---------- modal / drop ---------- */
dialog{
border:1px solid var(--line); background:var(--panel); color:var(--ink);
border-radius:var(--r); padding:0; max-width:560px; width:92vw;
}
dialog::backdrop{background:#000a; backdrop-filter:blur(2px)}
dialog .dlg-body{padding:20px}
dialog h3{margin:0 0 6px; font-size:16px}
dialog p{margin:0 0 14px; color:var(--dim); font-size:13px}
dialog .dlg-foot{display:flex; justify-content:flex-end; gap:9px; padding:14px 20px; border-top:1px solid var(--line)}
#map-preview{margin-top:16px; border:1px solid var(--line); border-radius:8px; overflow:auto; max-height:230px}
#map-preview table{border-collapse:collapse; width:100%; font-size:12px}
#map-preview th{
position:sticky; top:0; background:var(--panel2); text-align:left; padding:7px 10px;
font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:var(--dim); white-space:nowrap;
}
#map-preview td{padding:6px 10px; border-top:1px solid var(--line); white-space:nowrap}
#map-preview .led{color:var(--amber); font-weight:600}
#map-preview .nm{font-weight:650}
#map-count{margin-top:9px; font-size:12px; color:var(--dim)}
textarea{min-height:170px; resize:vertical; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:13px}
#drop{
position:fixed; inset:0; background:#0b0e14e6; display:none; place-items:center;
z-index:99; font-size:20px; color:var(--accent); border:3px dashed var(--accent); border-radius:14px;
}
#drop.on{display:grid}
#toast{
position:fixed; bottom:20px; left:50%; transform:translateX(-50%) translateY(80px);
background:var(--panel2); border:1px solid var(--line); padding:11px 18px; border-radius:9px;
transition:transform .22s ease, opacity .22s ease; opacity:0; z-index:100; font-size:13px; max-width:80vw;
}
#toast.on{transform:translateX(-50%) translateY(0); opacity:1}
#toast.err{border-color:#7d3b43; color:#ffc9c9}
.welcome{max-width:560px; margin:8vh auto; text-align:center}
.welcome h1{font-size:26px; margin:0 0 10px}
.welcome p{color:var(--dim); margin:0 0 22px}
.welcome .btns{display:flex; gap:10px; justify-content:center; flex-wrap:wrap}
kbd{background:#12141a; border:1px solid var(--line); border-bottom-width:2px; border-radius:5px; padding:1px 6px; font-size:11px}
</style>
</head>
<body>
<header>
<div class="brand"><b>DestoGod</b><span>bus destination signs</span></div>
<div class="field"><label for="company">Company</label><input type="text" id="company" maxlength="16" placeholder="Company"></div>
<div class="field"><label for="model">Bus / sign</label><select id="model"></select></div>
<div class="field" id="custom-size" hidden>
<input type="number" id="cust-w" min="8" max="1024" step="8" title="Sign width in pixels" style="width:78px">
<span style="color:var(--dim)">×</span>
<input type="number" id="cust-h" min="8" max="64" step="8" title="Sign height in pixels" style="width:66px">
<span style="color:var(--dim);font-size:12px">pixels</span>
</div>
<div class="spacer"></div>
<button id="btn-open">Open file…</button>
<button id="btn-sheet" title="Import destinations from Excel or CSV">Import spreadsheet…</button>
<button id="btn-export-tp5">Save project (.tp5)</button>
<button class="primary" id="btn-export">Export for bus (.td5)</button>
<input type="file" id="file" accept=".td5,.tp5,.xlsx,.xlsm,.csv" hidden>
</header>
<main>
<aside id="sidebar">
<div class="side-head">
<h2>Destinations (<span id="count">0</span>)</h2>
<div style="display:flex;gap:6px">
<button class="ghost" id="btn-bulk" title="Add many at once">Paste list</button>
<button class="ghost" id="btn-add" title="Add one">+</button>
<button class="ghost danger" id="btn-clear" title="Delete every destination (you can undo)">Clear</button>
</div>
</div>
<div id="list"></div>
</aside>
<section id="editor"></section>
</main>
<div id="drop">Drop a .td5, .tp5, spreadsheet or CSV to open</div>
<div id="toast"></div>
<dialog id="mapdlg">
<div class="dlg-body">
<h3>Import from spreadsheet</h3>
<p>Check the two columns below are the right ones, then import. Everything can still be edited afterwards.</p>
<div class="row">
<div>
<label for="map-name">Name on the driver's controller</label>
<select id="map-name"></select>
</div>
<div>
<label for="map-text">Text shown on the sign</label>
<select id="map-text"></select>
</div>
</div>
<div id="map-preview"></div>
<div id="map-count"></div>
</div>
<div class="dlg-foot">
<button id="map-cancel" class="ghost">Cancel</button>
<button id="map-ok" class="primary">Import</button>
</div>
</dialog>
<dialog id="bulk">
<div class="dlg-body">
<h3>Paste a list of destinations</h3>
<p>One per line. This is the fast way to build a whole list — far quicker than adding them one at a time.</p>
<textarea id="bulk-text" placeholder="SCHOOL BUS&#10;CHARTER&#10;RAIL BUS&#10;CARMEL COLLEGE"></textarea>
</div>
<div class="dlg-foot">
<button id="bulk-cancel" class="ghost">Cancel</button>
<button id="bulk-ok" class="primary">Add them</button>
</div>
</dialog>
<script type="module" src="./main.mjs"></script>
</body>
</html>