Files
yaris-obd/server/templates/dashcam.html
T
Mathieu MoranandCopilot c4dfcbaf25 Phase 24: remote trip + dashcam portal
- Add Flask-Login browser session auth (separate from device X-Api-Key auth)
  - modules/user_auth.py: User model, require_login decorator, bcrypt helpers
  - /login with CSRF token, /logout
  - scripts/create_user.py to provision admin user

- Add server-side trip computation from tracking_points
  - db.compute_trips_for_device(): gap > 5 min = new trip, haversine distance,
    upserts into trips table, auto-triggered in bg thread after each upload batch
  - db.list_trips(), db.get_trip_route(): same JSON shape as Pi API
  - db.get_overview_stats(): aggregated totals for overview page
  - db.list_dashcam_files(), db.get_dashcam_file(): browse/stream helpers

- Add web portal to server/app.py
  - Overview page (/): stat cards + recent trips + recent clips
  - Trip history (/history): reuses Pi history.js + data-list.js unchanged
    via /api/trips + /api/trip/<id> compatibility aliases
  - Dashcam archive (/dashcam): file list + inline video player
  - /api/v1/portal/dashcam/stream/<id>: Range-request streaming, path traversal guard
  - /api/geocode: same Nominatim→reverse_geocoder offline fallback as Pi

- Add server templates (server/templates/)
  - base.html: shared shell with 'Remote server' badge in sidebar + mobile topbar
  - login.html, index.html, history.html, dashcam.html, components.html

- DB schema: users + trips tables added to init.sql (fresh deploys) +
  migrate_phase24.sql (existing deployments)

- Infrastructure: Dockerfile uses repo-root build context to copy shared
  static/ assets; docker-compose updated; SECRET_KEY + DEVICE_NAME in .env;
  requirements: Flask-Login, bcrypt, reverse_geocoder

Co-authored-by: Copilot <[email protected]>
2026-08-10 03:58:45 -04:00

157 lines
6.8 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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>
&nbsp;·&nbsp;
<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">
<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>
</td>
</tr>
`;
}).join('');
table.hidden = false;
} catch (e) {
loading.hidden = true;
errEl.textContent = 'Failed to load files: ' + e.message;
errEl.hidden = false;
}
}
loadFiles();
</script>
{% endblock %}