- Confirmation modal in server base.html (window.confirmDelete promise helper) - Trip delete: trash button in Replay column (server-only via data-deletable on trip-list div), DELETE /api/v1/portal/trip/<id>, removes computed trip only (raw tracking_points kept) - Dashcam delete: trash button next to download, DELETE /api/v1/portal/dashcam/<id>, removes from DB + disk (best-effort file removal) - history.js: delete button gated on data-deletable='1' attribute; click handler intercepts before replay logic - db.py: delete_trip(), delete_dashcam_file() helpers Co-authored-by: Copilot <[email protected]>
184 lines
8.0 KiB
HTML
184 lines
8.0 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}Dashcam archive – Yaris OBD Remote{% endblock %}
|
||
|
||
{% block content %}
|
||
<h3 class="text-dark mb-3 fw-bold">Dashcam archive</h3>
|
||
|
||
<div class="row g-3">
|
||
{# ── File list ────────────────────────────────────────────────────── #}
|
||
<div class="col-12 col-xl-5">
|
||
<div class="card shadow-sm">
|
||
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||
<h6 class="text-primary fw-bold m-0">Uploaded clips</h6>
|
||
<span id="file-count" class="badge bg-secondary small">…</span>
|
||
</div>
|
||
<div class="card-body p-0">
|
||
<div id="files-loading" class="text-center text-muted py-4">
|
||
<span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span>
|
||
Loading…
|
||
</div>
|
||
<div id="files-error" class="alert alert-danger m-3" role="alert" hidden></div>
|
||
<div class="table-responsive">
|
||
<table class="table table-hover mb-0 small" id="files-table" hidden>
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th>File</th>
|
||
<th class="text-nowrap">Size</th>
|
||
<th class="text-nowrap d-none d-sm-table-cell">Uploaded</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="files-body"></tbody>
|
||
</table>
|
||
</div>
|
||
<div id="files-empty" class="text-muted text-center py-4 small" hidden>
|
||
<i class="fas fa-inbox fa-2x mb-2 d-block text-muted"></i>
|
||
No dashcam files uploaded yet.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{# ── Video player ─────────────────────────────────────────────────── #}
|
||
<div class="col-12 col-xl-7">
|
||
<div class="card shadow-sm">
|
||
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||
<h6 class="text-primary fw-bold m-0">Preview</h6>
|
||
<a id="btn-download" class="btn btn-outline-secondary btn-sm" href="#" download hidden>
|
||
<i class="fas fa-download fa-fw me-1"></i> Download
|
||
</a>
|
||
</div>
|
||
<div class="card-body p-0">
|
||
<div id="player-placeholder" class="text-muted text-center py-5 small">
|
||
<i class="fas fa-film fa-2x mb-2 d-block"></i>
|
||
Select a clip to preview
|
||
</div>
|
||
<video id="video-player" class="w-100" controls preload="metadata" hidden
|
||
style="background:#000;max-height:420px;"></video>
|
||
<div id="player-meta" class="px-3 py-2 border-top small text-muted" hidden>
|
||
<span id="player-filename"></span>
|
||
·
|
||
<span id="player-size"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block scripts %}
|
||
<script>
|
||
function fmtSize(b) {
|
||
if (b >= 1e9) return (b / 1e9).toFixed(2) + ' GB';
|
||
if (b >= 1e6) return (b / 1e6).toFixed(1) + ' MB';
|
||
return (b / 1e3).toFixed(0) + ' KB';
|
||
}
|
||
function fmtDate(iso) {
|
||
return new Date(iso).toLocaleString('fr-CA', {
|
||
year: 'numeric', month: 'short', day: 'numeric',
|
||
hour: '2-digit', minute: '2-digit'
|
||
});
|
||
}
|
||
|
||
let currentFileId = null;
|
||
|
||
function playFile(id, path, sizeBytes) {
|
||
const player = document.getElementById('video-player');
|
||
const placeholder = document.getElementById('player-placeholder');
|
||
const meta = document.getElementById('player-meta');
|
||
const btnDownload = document.getElementById('btn-download');
|
||
const name = path.split('/').pop();
|
||
|
||
currentFileId = id;
|
||
const streamUrl = `/api/v1/portal/dashcam/stream/${id}`;
|
||
player.src = streamUrl;
|
||
player.hidden = false;
|
||
placeholder.hidden = true;
|
||
meta.hidden = false;
|
||
document.getElementById('player-filename').textContent = name;
|
||
document.getElementById('player-size').textContent = fmtSize(sizeBytes);
|
||
btnDownload.href = streamUrl;
|
||
btnDownload.download = name;
|
||
btnDownload.hidden = false;
|
||
player.play().catch(() => {});
|
||
|
||
// Highlight selected row
|
||
document.querySelectorAll('#files-body tr').forEach(r => r.classList.remove('table-active'));
|
||
const row = document.querySelector(`#files-body tr[data-file-id="${id}"]`);
|
||
if (row) row.classList.add('table-active');
|
||
}
|
||
|
||
async function loadFiles() {
|
||
const loading = document.getElementById('files-loading');
|
||
const table = document.getElementById('files-table');
|
||
const empty = document.getElementById('files-empty');
|
||
const errEl = document.getElementById('files-error');
|
||
const countEl = document.getElementById('file-count');
|
||
|
||
try {
|
||
const res = await fetch('/api/v1/portal/dashcam');
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const files = await res.json();
|
||
loading.hidden = true;
|
||
countEl.textContent = files.length;
|
||
if (!files.length) { empty.hidden = false; return; }
|
||
const tbody = document.getElementById('files-body');
|
||
tbody.innerHTML = files.map(f => {
|
||
const name = f.relative_path.split('/').pop();
|
||
const isVid = /\.(mp4|mov|avi|mkv|ts|h264)$/i.test(name);
|
||
return `
|
||
<tr data-file-id="${f.id}" onclick="playFile(${f.id}, '${f.relative_path.replace(/'/g,"\\'")}', ${f.size_bytes})"
|
||
style="cursor:pointer;">
|
||
<td class="text-truncate" style="max-width:200px;" title="${f.relative_path}">${name}</td>
|
||
<td class="text-nowrap">${fmtSize(f.size_bytes)}</td>
|
||
<td class="text-nowrap d-none d-sm-table-cell">${fmtDate(f.uploaded_at)}</td>
|
||
<td class="text-end text-nowrap">
|
||
<a href="/api/v1/portal/dashcam/stream/${f.id}" download="${name}"
|
||
class="btn btn-outline-secondary btn-sm py-0 px-1" title="Download"
|
||
onclick="event.stopPropagation()">
|
||
<i class="fas fa-download fa-fw"></i>
|
||
</a>
|
||
<button class="btn btn-outline-danger btn-sm py-0 px-1 ms-1" title="Delete"
|
||
onclick="event.stopPropagation(); deleteFile(${f.id}, '${name.replace(/'/g,"\\'")}')">
|
||
<i class="fas fa-trash fa-fw"></i>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
table.hidden = false;
|
||
} catch (e) {
|
||
loading.hidden = true;
|
||
errEl.textContent = 'Failed to load files: ' + e.message;
|
||
errEl.hidden = false;
|
||
}
|
||
}
|
||
|
||
loadFiles();
|
||
|
||
async function deleteFile(id, name) {
|
||
const confirmed = await window.confirmDelete(`Delete "${name}"? This removes the file from disk permanently.`);
|
||
if (!confirmed) return;
|
||
const res = await fetch(`/api/v1/portal/dashcam/${id}`, { method: 'DELETE' });
|
||
if (!res.ok) { alert('Delete failed.'); return; }
|
||
// Clear player if this file was playing
|
||
const player = document.getElementById('video-player');
|
||
if (currentFileId === id) {
|
||
player.pause();
|
||
player.src = '';
|
||
player.hidden = true;
|
||
document.getElementById('player-placeholder').hidden = false;
|
||
document.getElementById('player-meta').hidden = true;
|
||
document.getElementById('btn-download').hidden = true;
|
||
currentFileId = null;
|
||
}
|
||
// Remove row
|
||
const row = document.querySelector(`#files-body tr[data-file-id="${id}"]`);
|
||
if (row) row.remove();
|
||
const countEl = document.getElementById('file-count');
|
||
countEl.textContent = parseInt(countEl.textContent) - 1;
|
||
}
|
||
</script>
|
||
{% endblock %}
|