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>
238 lines
11 KiB
HTML
238 lines
11 KiB
HTML
<!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 CHARTER RAIL BUS 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>
|