fix(dashcam): proper mobile row reflow + import status at top with ETA

- yaris.css: use .dash-row class on all rows; only td:first-child and
  .dash-actions-td visible on mobile (<576px); buttons never overflow
- dash.js: add dash-row class + dash-actions-td wrapper div to dashcam
  file rows; import status moved to card footer at top with spinner + ETA
- local-videos.js: add dash-row class to video rows
- dash.html: import status bar (spinner + ETA + result) in device card
  at top; removed old footer import status from files card

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-10 00:46:05 -04:00
co-authored by Copilot
parent 9f0f53a59c
commit ba760a04d6
4 changed files with 94 additions and 56 deletions
+69 -26
View File
@@ -4,7 +4,11 @@
// elements (#map, #speedGraph) that don't exist on this page.
const deviceStatus = document.getElementById('dash-device-status');
const importStatusBar = document.getElementById('dash-import-status-bar');
const importStatus = document.getElementById('dash-import-status');
const importEta = document.getElementById('dash-import-eta');
const importResultBar = document.getElementById('dash-import-result-bar');
const importResult = document.getElementById('dash-import-result');
const scanBtn = document.getElementById('dash-scan-btn');
const importBtn = document.getElementById('dash-import-btn');
const fileSummary = document.getElementById('dash-file-summary');
@@ -14,6 +18,36 @@ const selectAllBtn = document.getElementById('dash-select-all-btn');
const importSelectedBtn = document.getElementById('dash-import-selected-btn');
const deleteAllBtn = document.getElementById('dash-delete-all-btn');
let importInProgress = false;
let importStartTime = null;
let importTotal = 0;
let importEtaInterval = null;
function showImportProgress(msg, total) {
importTotal = total || 0;
importStartTime = Date.now();
importStatus.textContent = msg;
importEta.textContent = '';
importStatusBar.hidden = false;
importResultBar.hidden = true;
// Update ETA every second based on elapsed time vs total
clearInterval(importEtaInterval);
if (importTotal > 0) {
importEtaInterval = setInterval(() => {
const elapsed = (Date.now() - importStartTime) / 1000;
// rough heuristic: ~3s per video on average
const estTotal = importTotal * 3;
const remaining = Math.max(0, Math.round(estTotal - elapsed));
importEta.textContent = remaining > 0 ? `~${remaining}s remaining` : '';
}, 1000);
}
}
function showImportResult(msg) {
clearInterval(importEtaInterval);
importStatusBar.hidden = true;
importResult.textContent = msg;
importResultBar.hidden = false;
}
function fetchJson(url, options) {
return fetch(url, options).then(response => response.json().then(data => {
@@ -73,8 +107,10 @@ function renderDashcamFiles(summary) {
row.dataset.path = file.path;
row.dataset.size = file.size;
row.dataset.modified = file.modified_at;
row.className = 'dash-row';
const selection = document.createElement('td');
selection.className = 'dash-col-check';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'form-check-input';
@@ -83,27 +119,38 @@ function renderDashcamFiles(summary) {
selection.appendChild(checkbox);
row.appendChild(selection);
row.appendChild(createCell(file.path));
row.appendChild(createCell(YarisUI.formatBytes(file.size)));
row.appendChild(createCell(new Date(file.modified_at * 1000).toLocaleString()));
const pathCell = createCell(file.path);
pathCell.className = 'dash-col-file';
row.appendChild(pathCell);
const actions = document.createElement('td');
actions.className = 'data-list-actions';
const sizeCell = createCell(YarisUI.formatBytes(file.size));
sizeCell.className = 'dash-col-size';
row.appendChild(sizeCell);
const dateCell = createCell(new Date(file.modified_at * 1000).toLocaleString());
dateCell.className = 'dash-col-date';
row.appendChild(dateCell);
const actionsTd = document.createElement('td');
actionsTd.className = 'dash-actions-td';
const actionsDiv = document.createElement('div');
actionsDiv.className = 'data-list-actions';
const importButton = document.createElement('button');
importButton.type = 'button';
importButton.className = 'btn btn-sm btn-outline-primary';
importButton.className = 'btn btn-sm btn-outline-primary btn-icon-mobile';
importButton.dataset.importPath = file.path;
importButton.innerHTML = '<i class="fas fa-download me-1"></i>Import';
actions.appendChild(importButton);
importButton.innerHTML = '<i class="fas fa-download"></i><span class="btn-label"> Import</span>';
actionsDiv.appendChild(importButton);
const deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.className = 'btn btn-sm btn-outline-danger';
deleteButton.className = 'btn btn-sm btn-outline-danger btn-icon-mobile';
deleteButton.dataset.deletePath = file.path;
deleteButton.dataset.confirm = `Permanently delete "${file.path}" from the dashcam?`;
deleteButton.innerHTML = '<i class="fas fa-trash me-1"></i>Delete';
actions.appendChild(deleteButton);
row.appendChild(actions);
deleteButton.innerHTML = '<i class="fas fa-trash"></i><span class="btn-label"> Delete</span>';
actionsDiv.appendChild(deleteButton);
actionsTd.appendChild(actionsDiv);
row.appendChild(actionsTd);
fileListBody.appendChild(row);
});
@@ -153,25 +200,23 @@ function describeImportResult(summary) {
}
function importDashcamFiles(paths) {
importStatus.textContent = `Importing ${paths.length} selected video(s)...`;
showImportProgress(`Importing ${paths.length} video(s)…`, paths.length);
setImportBusy(true);
return fetchJson('/api/dash/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths }),
}).then(summary => {
importStatus.textContent = describeImportResult(summary);
showImportResult(describeImportResult(summary));
paths.forEach(path => {
const checkbox = Array.from(
fileListBody.querySelectorAll('[data-select-path]')
).find(candidate => candidate.dataset.selectPath === path);
if (checkbox) {
checkbox.checked = false;
}
if (checkbox) checkbox.checked = false;
});
return summary;
}).catch(err => {
importStatus.textContent = `Selected import failed: ${err.message}`;
showImportResult(`Selected import failed: ${err.message}`);
return null;
}).finally(() => setImportBusy(false));
}
@@ -202,29 +247,27 @@ function scanForDashcam() {
}
function importDashcam() {
importStatus.textContent = 'Importing... this can take a while for large videos.';
showImportProgress('Importing all videos… this can take a while.', 0);
setImportBusy(true);
fetch('/api/dash/import', { method: 'POST' })
.then(response => response.json())
.then(summary => {
if (summary.status !== 'ok') {
importStatus.textContent = summary.message || ('Import failed: ' + summary.status);
showImportResult(summary.message || ('Import failed: ' + summary.status));
return;
}
let text = `Found ${summary.found} video(s), copied ${summary.copied}, skipped ${summary.skipped} (already imported).`;
let text = `Found ${summary.found} video(s), copied ${summary.copied}, skipped ${summary.skipped}.`;
if (summary.errors && summary.errors.length > 0) {
text += ` Errors: ${summary.errors.join('; ')}`;
}
importStatus.textContent = text;
showImportResult(text);
refreshDashcamFiles();
})
.catch(err => {
importStatus.textContent = 'Error while importing: ' + err;
showImportResult('Error while importing: ' + err);
})
.finally(() => {
setImportBusy(false);
});
.finally(() => setImportBusy(false));
}
scanBtn.addEventListener('click', scanForDashcam);