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:
+69
-26
@@ -4,7 +4,11 @@
|
|||||||
// elements (#map, #speedGraph) that don't exist on this page.
|
// elements (#map, #speedGraph) that don't exist on this page.
|
||||||
|
|
||||||
const deviceStatus = document.getElementById('dash-device-status');
|
const deviceStatus = document.getElementById('dash-device-status');
|
||||||
|
const importStatusBar = document.getElementById('dash-import-status-bar');
|
||||||
const importStatus = document.getElementById('dash-import-status');
|
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 scanBtn = document.getElementById('dash-scan-btn');
|
||||||
const importBtn = document.getElementById('dash-import-btn');
|
const importBtn = document.getElementById('dash-import-btn');
|
||||||
const fileSummary = document.getElementById('dash-file-summary');
|
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 importSelectedBtn = document.getElementById('dash-import-selected-btn');
|
||||||
const deleteAllBtn = document.getElementById('dash-delete-all-btn');
|
const deleteAllBtn = document.getElementById('dash-delete-all-btn');
|
||||||
let importInProgress = false;
|
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) {
|
function fetchJson(url, options) {
|
||||||
return fetch(url, options).then(response => response.json().then(data => {
|
return fetch(url, options).then(response => response.json().then(data => {
|
||||||
@@ -73,8 +107,10 @@ function renderDashcamFiles(summary) {
|
|||||||
row.dataset.path = file.path;
|
row.dataset.path = file.path;
|
||||||
row.dataset.size = file.size;
|
row.dataset.size = file.size;
|
||||||
row.dataset.modified = file.modified_at;
|
row.dataset.modified = file.modified_at;
|
||||||
|
row.className = 'dash-row';
|
||||||
|
|
||||||
const selection = document.createElement('td');
|
const selection = document.createElement('td');
|
||||||
|
selection.className = 'dash-col-check';
|
||||||
const checkbox = document.createElement('input');
|
const checkbox = document.createElement('input');
|
||||||
checkbox.type = 'checkbox';
|
checkbox.type = 'checkbox';
|
||||||
checkbox.className = 'form-check-input';
|
checkbox.className = 'form-check-input';
|
||||||
@@ -83,27 +119,38 @@ function renderDashcamFiles(summary) {
|
|||||||
selection.appendChild(checkbox);
|
selection.appendChild(checkbox);
|
||||||
row.appendChild(selection);
|
row.appendChild(selection);
|
||||||
|
|
||||||
row.appendChild(createCell(file.path));
|
const pathCell = createCell(file.path);
|
||||||
row.appendChild(createCell(YarisUI.formatBytes(file.size)));
|
pathCell.className = 'dash-col-file';
|
||||||
row.appendChild(createCell(new Date(file.modified_at * 1000).toLocaleString()));
|
row.appendChild(pathCell);
|
||||||
|
|
||||||
const actions = document.createElement('td');
|
const sizeCell = createCell(YarisUI.formatBytes(file.size));
|
||||||
actions.className = 'data-list-actions';
|
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');
|
const importButton = document.createElement('button');
|
||||||
importButton.type = '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.dataset.importPath = file.path;
|
||||||
importButton.innerHTML = '<i class="fas fa-download me-1"></i>Import';
|
importButton.innerHTML = '<i class="fas fa-download"></i><span class="btn-label"> Import</span>';
|
||||||
actions.appendChild(importButton);
|
actionsDiv.appendChild(importButton);
|
||||||
|
|
||||||
const deleteButton = document.createElement('button');
|
const deleteButton = document.createElement('button');
|
||||||
deleteButton.type = '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.deletePath = file.path;
|
||||||
deleteButton.dataset.confirm = `Permanently delete "${file.path}" from the dashcam?`;
|
deleteButton.dataset.confirm = `Permanently delete "${file.path}" from the dashcam?`;
|
||||||
deleteButton.innerHTML = '<i class="fas fa-trash me-1"></i>Delete';
|
deleteButton.innerHTML = '<i class="fas fa-trash"></i><span class="btn-label"> Delete</span>';
|
||||||
actions.appendChild(deleteButton);
|
actionsDiv.appendChild(deleteButton);
|
||||||
row.appendChild(actions);
|
actionsTd.appendChild(actionsDiv);
|
||||||
|
row.appendChild(actionsTd);
|
||||||
fileListBody.appendChild(row);
|
fileListBody.appendChild(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,25 +200,23 @@ function describeImportResult(summary) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function importDashcamFiles(paths) {
|
function importDashcamFiles(paths) {
|
||||||
importStatus.textContent = `Importing ${paths.length} selected video(s)...`;
|
showImportProgress(`Importing ${paths.length} video(s)…`, paths.length);
|
||||||
setImportBusy(true);
|
setImportBusy(true);
|
||||||
return fetchJson('/api/dash/import', {
|
return fetchJson('/api/dash/import', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ paths }),
|
body: JSON.stringify({ paths }),
|
||||||
}).then(summary => {
|
}).then(summary => {
|
||||||
importStatus.textContent = describeImportResult(summary);
|
showImportResult(describeImportResult(summary));
|
||||||
paths.forEach(path => {
|
paths.forEach(path => {
|
||||||
const checkbox = Array.from(
|
const checkbox = Array.from(
|
||||||
fileListBody.querySelectorAll('[data-select-path]')
|
fileListBody.querySelectorAll('[data-select-path]')
|
||||||
).find(candidate => candidate.dataset.selectPath === path);
|
).find(candidate => candidate.dataset.selectPath === path);
|
||||||
if (checkbox) {
|
if (checkbox) checkbox.checked = false;
|
||||||
checkbox.checked = false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
return summary;
|
return summary;
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
importStatus.textContent = `Selected import failed: ${err.message}`;
|
showImportResult(`Selected import failed: ${err.message}`);
|
||||||
return null;
|
return null;
|
||||||
}).finally(() => setImportBusy(false));
|
}).finally(() => setImportBusy(false));
|
||||||
}
|
}
|
||||||
@@ -202,29 +247,27 @@ function scanForDashcam() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function importDashcam() {
|
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);
|
setImportBusy(true);
|
||||||
|
|
||||||
fetch('/api/dash/import', { method: 'POST' })
|
fetch('/api/dash/import', { method: 'POST' })
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(summary => {
|
.then(summary => {
|
||||||
if (summary.status !== 'ok') {
|
if (summary.status !== 'ok') {
|
||||||
importStatus.textContent = summary.message || ('Import failed: ' + summary.status);
|
showImportResult(summary.message || ('Import failed: ' + summary.status));
|
||||||
return;
|
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) {
|
if (summary.errors && summary.errors.length > 0) {
|
||||||
text += ` Errors: ${summary.errors.join('; ')}`;
|
text += ` Errors: ${summary.errors.join('; ')}`;
|
||||||
}
|
}
|
||||||
importStatus.textContent = text;
|
showImportResult(text);
|
||||||
refreshDashcamFiles();
|
refreshDashcamFiles();
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
importStatus.textContent = 'Error while importing: ' + err;
|
showImportResult('Error while importing: ' + err);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => setImportBusy(false));
|
||||||
setImportBusy(false);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
scanBtn.addEventListener('click', scanForDashcam);
|
scanBtn.addEventListener('click', scanForDashcam);
|
||||||
|
|||||||
@@ -95,6 +95,7 @@
|
|||||||
|
|
||||||
files.forEach(function (file) {
|
files.forEach(function (file) {
|
||||||
const row = document.createElement("tr");
|
const row = document.createElement("tr");
|
||||||
|
row.className = "dash-row";
|
||||||
row.dataset.path = file.path;
|
row.dataset.path = file.path;
|
||||||
|
|
||||||
// Thumbnail cell — also contains filename for mobile view
|
// Thumbnail cell — also contains filename for mobile view
|
||||||
|
|||||||
+16
-26
@@ -275,41 +275,31 @@ body {
|
|||||||
.dash-col-file { max-width: 14rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.dash-col-file { max-width: 14rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile: reflow table rows into flex cards */
|
/* Mobile: reflow .dash-row into a flex card — thumb + name on left, buttons on right */
|
||||||
@media (max-width: 575.98px) {
|
@media (max-width: 575.98px) {
|
||||||
/* Hide table header entirely */
|
/* Hide table header */
|
||||||
#local-video-list thead,
|
#local-video-list thead,
|
||||||
#dashcam-file-list thead { display: none; }
|
#dashcam-file-list thead { display: none; }
|
||||||
|
|
||||||
/* Each row becomes a horizontal flex card */
|
/* Row becomes flex */
|
||||||
#local-video-list tbody tr,
|
.dash-row { display: flex !important; align-items: center; gap: .5rem;
|
||||||
#dashcam-file-list tbody tr {
|
border-bottom: 1px solid var(--bs-border-color); padding: .25rem .5rem; }
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
border-bottom: 1px solid var(--bs-border-color);
|
|
||||||
padding: .25rem .5rem;
|
|
||||||
gap: .5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide all cells except thumb and actions */
|
/* Hide all cells by default */
|
||||||
#local-video-list tbody td,
|
.dash-row > td { display: none !important; }
|
||||||
#dashcam-file-list tbody td { display: none; }
|
|
||||||
|
|
||||||
/* Show only thumb cell and actions cell */
|
/* Show only first cell (thumb or checkbox) and actions */
|
||||||
#local-video-list tbody td:first-child,
|
.dash-row > td:first-child,
|
||||||
#local-video-list tbody .dash-actions-td,
|
.dash-row > .dash-actions-td { display: flex !important; align-items: center; }
|
||||||
#dashcam-file-list tbody td:first-child,
|
|
||||||
#dashcam-file-list tbody .dash-actions-td { display: flex; align-items: center; }
|
|
||||||
|
|
||||||
/* Thumb cell takes remaining space */
|
/* First cell takes remaining space */
|
||||||
#local-video-list tbody td:first-child,
|
.dash-row > td:first-child { flex: 1; min-width: 0; }
|
||||||
#dashcam-file-list tbody td:first-child { flex: 1; min-width: 0; }
|
|
||||||
|
|
||||||
/* Actions cell stays at natural width, never shrinks */
|
/* Actions never shrink */
|
||||||
.dash-actions-td { flex-shrink: 0; white-space: nowrap; }
|
.dash-row > .dash-actions-td { flex-shrink: 0; }
|
||||||
|
|
||||||
/* Filename label below thumb */
|
/* Filename under thumbnail on mobile */
|
||||||
.dash-thumb-name { display: block !important; font-size: .7rem; }
|
.dash-thumb-name { display: block !important; font-size: .7rem; margin-top: .2rem; }
|
||||||
|
|
||||||
/* Shrink thumbnail */
|
/* Shrink thumbnail */
|
||||||
.local-video-thumbnail { width: 4.5rem; }
|
.local-video-thumbnail { width: 4.5rem; }
|
||||||
|
|||||||
+8
-4
@@ -18,6 +18,14 @@
|
|||||||
<i class="fas fa-download me-1"></i>Import all
|
<i class="fas fa-download me-1"></i>Import all
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="dash-import-status-bar" class="card-footer py-2 d-flex align-items-center gap-2" hidden>
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||||
|
<span id="dash-import-status" class="small text-muted flex-grow-1">Importing…</span>
|
||||||
|
<span id="dash-import-eta" class="small text-muted text-nowrap"></span>
|
||||||
|
</div>
|
||||||
|
<div id="dash-import-result-bar" class="card-footer py-2 small text-muted" hidden>
|
||||||
|
<span id="dash-import-result"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ── Files on dashcam (collapsible) ─────────────────────────────────── #}
|
{# ── Files on dashcam (collapsible) ─────────────────────────────────── #}
|
||||||
@@ -59,10 +67,6 @@
|
|||||||
"Connect the dashcam in USB storage mode, then refresh this list."
|
"Connect the dashcam in USB storage mode, then refresh this list."
|
||||||
) }}
|
) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer py-2 d-flex align-items-center gap-2">
|
|
||||||
<i class="fas fa-info-circle text-muted small"></i>
|
|
||||||
<span id="dash-import-status" class="small text-muted">No import has been run yet.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user