Add lightweight Tauri shell, vendor offline libs, fix bugs and race conditions
- Add tauri/ subfolder: a Tauri-based desktop shell that loads the same index.html as the Electron app, producing a ~2MB installer (vs ~71MB for Electron) by using the OS WebView2 instead of bundled Chromium. Includes sync-frontend.js to keep tauri/dist/ in sync with the canonical index.html and vendor/ at build time (no UI duplication/drift). - Vendor lucide, jsPDF, and jspdf-autotable locally under vendor/ instead of loading from CDNs, so the app works fully offline in both shells. - Fix server.js path traversal vulnerability (sanitize/contain request paths). - Fix exportPDF() using stale/edited piece data that could produce Infinity/NaN in generated PDFs; add a recalculate guard. - Fix race conditions: overlapping copy/save-defaults button revert timers, and FileReader callbacks for logo/CSV import that could apply out of order. - Minor cleanups: explicit array index parsing, hasResult() helper, consistent event binding for NMFC search results, cached DOM lookups. - Document the dual-shell architecture and vendoring conventions in CLAUDE.md. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+55
-11
@@ -3,9 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Freight Calculator Pro</title>
|
||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
|
||||
<!-- Vendored locally (vendor/) so the app works fully offline — see CLAUDE.md -->
|
||||
<script src="vendor/lucide.js"></script>
|
||||
<script src="vendor/jspdf.umd.min.js"></script>
|
||||
<script src="vendor/jspdf.plugin.autotable.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #2563eb; --primary-hover: #1d4ed8; --bg-color: #f1f5f9;
|
||||
@@ -472,6 +473,13 @@ let pendingBranding = null;
|
||||
// Working pieces array (ephemeral, not persisted)
|
||||
let pieces = [{ L:'', W:'', H:'', Weight:'' }];
|
||||
|
||||
// Race-condition guards: track in-flight timers/readers so rapid repeat
|
||||
// actions don't let a stale callback clobber a newer one.
|
||||
let copyRevertTimer = null;
|
||||
let saveDefaultsRevertTimer = null;
|
||||
let logoReadToken = 0;
|
||||
let csvImportToken = 0;
|
||||
|
||||
function saveState() { localStorage.setItem('freightCalcState', JSON.stringify(appState)); }
|
||||
|
||||
// ─── Init ──────────────────────────────────────────────────────────────────────
|
||||
@@ -549,8 +557,10 @@ document.getElementById('setting-color').oninput = e => {
|
||||
};
|
||||
document.getElementById('setting-logo').onchange = e => {
|
||||
if (!e.target.files[0]) return;
|
||||
const myToken = ++logoReadToken;
|
||||
const reader = new FileReader();
|
||||
reader.onload = evt => {
|
||||
if (myToken !== logoReadToken) return; // a newer file selection superseded this read
|
||||
appState.branding.logo = evt.target.result;
|
||||
const preview = document.getElementById('logo-preview');
|
||||
preview.src = evt.target.result; preview.style.display = 'block';
|
||||
@@ -574,8 +584,12 @@ document.getElementById('save-defaults-btn').onclick = () => {
|
||||
};
|
||||
saveState();
|
||||
const btn = document.getElementById('save-defaults-btn');
|
||||
if (saveDefaultsRevertTimer) clearTimeout(saveDefaultsRevertTimer);
|
||||
btn.innerHTML = '<i data-lucide="check"></i> Saved!'; lucide.createIcons();
|
||||
setTimeout(() => { btn.innerHTML = '<i data-lucide="save"></i> Save Default'; lucide.createIcons(); }, 1800);
|
||||
saveDefaultsRevertTimer = setTimeout(() => {
|
||||
btn.innerHTML = '<i data-lucide="save"></i> Save Default'; lucide.createIcons();
|
||||
saveDefaultsRevertTimer = null;
|
||||
}, 1800);
|
||||
};
|
||||
document.getElementById('apply-defaults-btn').onclick = () => {
|
||||
const d = appState.defaults;
|
||||
@@ -697,14 +711,18 @@ function exportHistoryCSV() {
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `freight-history-${new Date().toISOString().slice(0,10)}.csv`;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 100);
|
||||
// Defer revocation generously so slower systems/rapid consecutive exports
|
||||
// have time to actually start the download before the blob URL dies.
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
}
|
||||
|
||||
// Import history from CSV
|
||||
function importHistoryCSV(e) {
|
||||
const file = e.target.files[0]; if (!file) return;
|
||||
const myToken = ++csvImportToken;
|
||||
const reader = new FileReader();
|
||||
reader.onload = evt => {
|
||||
if (myToken !== csvImportToken) return; // a newer import selection superseded this read
|
||||
const lines = evt.target.result.trim().split('\n').slice(1); // skip header
|
||||
const imported = [];
|
||||
lines.forEach(line => {
|
||||
@@ -782,7 +800,7 @@ function renderPieces() {
|
||||
});
|
||||
// Bind input events
|
||||
container.querySelectorAll('input[type="number"]').forEach(el => {
|
||||
el.addEventListener('input', e => { pieces[e.target.dataset.idx][e.target.dataset.field] = e.target.value; });
|
||||
el.addEventListener('input', e => { pieces[parseInt(e.target.dataset.idx, 10)][e.target.dataset.field] = e.target.value; });
|
||||
el.addEventListener('keydown', e => { if (e.key === 'Enter') calculateFreightClass(); });
|
||||
});
|
||||
// Bind delete buttons
|
||||
@@ -801,7 +819,10 @@ function addPiece() {
|
||||
// Focus first input of new row
|
||||
const rows = document.querySelectorAll('.piece-row');
|
||||
const lastRow = rows[rows.length-1];
|
||||
if (lastRow) lastRow.querySelector('input') && lastRow.querySelector('input').focus();
|
||||
if (lastRow) {
|
||||
const firstInput = lastRow.querySelector('input');
|
||||
if (firstInput) firstInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Validation & Clear ───────────────────────────────────────────────────────
|
||||
@@ -884,9 +905,14 @@ function calculateFreightClass() {
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
// Returns true once a calculation has produced a result (i.e. not the placeholder '--')
|
||||
function hasResult() {
|
||||
return document.getElementById('class-result').textContent !== '--';
|
||||
}
|
||||
|
||||
// ─── Copy to clipboard ────────────────────────────────────────────────────────
|
||||
function copyResult() {
|
||||
if (document.getElementById('class-result').textContent === '--') return;
|
||||
if (!hasResult()) return;
|
||||
const cls = document.getElementById('class-result').textContent;
|
||||
const density = document.getElementById('density-result').textContent;
|
||||
const du = appState.units.dim, wu = appState.units.wt;
|
||||
@@ -894,19 +920,34 @@ function copyResult() {
|
||||
const text = `Freight Class: ${cls}\nDensity: ${density} lb/ft³\n${pLines}`;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const btn = document.getElementById('copy-btn');
|
||||
if (copyRevertTimer) clearTimeout(copyRevertTimer);
|
||||
btn.classList.add('copied');
|
||||
btn.innerHTML = '<i data-lucide="check"></i> Copied!'; lucide.createIcons();
|
||||
setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = '<i data-lucide="copy"></i> Copy'; lucide.createIcons(); }, 2000);
|
||||
copyRevertTimer = setTimeout(() => {
|
||||
btn.classList.remove('copied');
|
||||
btn.innerHTML = '<i data-lucide="copy"></i> Copy'; lucide.createIcons();
|
||||
copyRevertTimer = null;
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── PDF Export ───────────────────────────────────────────────────────────────
|
||||
function exportPDF() {
|
||||
if (document.getElementById('class-result').textContent === '--') {
|
||||
if (!hasResult()) {
|
||||
alert('Please calculate a freight class before exporting a PDF.');
|
||||
return;
|
||||
}
|
||||
if (typeof window.jspdf === 'undefined') { alert('PDF library not loaded. Please check your internet connection.'); return; }
|
||||
// Guard against stale/edited piece data producing Infinity/NaN in the PDF
|
||||
// (e.g. user calculates, then clears a dimension before exporting without recalculating).
|
||||
const piecesValidForExport = pieces.every(p => {
|
||||
const L = parseFloat(p.L), Wd = parseFloat(p.W), H = parseFloat(p.H), Wt = parseFloat(p.Weight);
|
||||
return L > 0 && Wd > 0 && H > 0 && Wt > 0;
|
||||
});
|
||||
if (!piecesValidForExport) {
|
||||
alert('Piece values have changed since the last calculation. Please recalculate before exporting a PDF.');
|
||||
return;
|
||||
}
|
||||
const { jsPDF } = window.jspdf;
|
||||
const doc = new jsPDF({ unit:'mm', format:'letter' });
|
||||
const W = doc.internal.pageSize.getWidth();
|
||||
@@ -1031,7 +1072,7 @@ function nmfcSearch(query) {
|
||||
}
|
||||
let html = '<table><thead><tr><th>NMFC Code</th><th>Description</th><th>Std Class</th></tr></thead><tbody>';
|
||||
results.forEach(n => {
|
||||
html += `<tr onclick="nmfcSelect(${n.cls})">
|
||||
html += `<tr data-cls="${n.cls}">
|
||||
<td style="font-family:monospace;font-size:0.85rem">${n.code}</td>
|
||||
<td>${n.desc}</td>
|
||||
<td><span class="nmfc-class-badge">${n.cls}</span></td>
|
||||
@@ -1039,6 +1080,9 @@ function nmfcSearch(query) {
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
container.querySelectorAll('tbody tr').forEach(row => {
|
||||
row.addEventListener('click', () => nmfcSelect(parseFloat(row.dataset.cls)));
|
||||
});
|
||||
}
|
||||
function nmfcSelect(cls) {
|
||||
// Highlight the matching row in the density reference table
|
||||
|
||||
Reference in New Issue
Block a user