Creation nouvelle interface Docker Vehicule et Multimedia
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
FROM nginx:stable-alpine
|
||||
COPY ui/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY ui/index.html ui/styles.css ui/app.js /usr/share/nginx/html/
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,181 @@
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const all = selector => document.querySelectorAll(selector);
|
||||
const storage = {
|
||||
get(key, fallback = '') { try { return localStorage.getItem(`caria.${key}`) ?? fallback; } catch { return fallback; } },
|
||||
set(key, value) { try { localStorage.setItem(`caria.${key}`, value); return true; } catch { return false; } }
|
||||
};
|
||||
function toast(message) { $('#toast').textContent = message; $('#toast').hidden = false; clearTimeout(toast.timer); toast.timer = setTimeout(() => $('#toast').hidden = true, 4000); }
|
||||
function navigate() {
|
||||
const view = ['home', 'drive', 'music', 'stream'].includes(location.hash.slice(1)) ? location.hash.slice(1) : 'home';
|
||||
all('.view').forEach(section => section.hidden = section.id !== view);
|
||||
all('[data-view]').forEach(link => {
|
||||
const active = link.dataset.view === view;
|
||||
link.classList.toggle('active', active);
|
||||
if (active) link.setAttribute('aria-current', 'page'); else link.removeAttribute('aria-current');
|
||||
});
|
||||
document.title = `CARIA · ${$(`[data-view="${view}"]`).textContent.trim()}`;
|
||||
}
|
||||
window.addEventListener('hashchange', navigate);
|
||||
navigate();
|
||||
|
||||
function clock() {
|
||||
const date = new Date();
|
||||
const time = date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||
['#hero-clock', '#header-clock'].forEach(id => { $(id).textContent = time; $(id).dateTime = date.toISOString(); });
|
||||
$('#date').textContent = date.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
}
|
||||
clock(); setInterval(clock, 1000);
|
||||
|
||||
function makeGauge(id, max, step, unit, label) {
|
||||
const point = (angle, radius) => {
|
||||
const r = angle * Math.PI / 180;
|
||||
return [150 + radius * Math.sin(r), 150 - radius * Math.cos(r)];
|
||||
};
|
||||
let ticks = '';
|
||||
for (let v = 0; v <= max; v += step / 4) {
|
||||
const angle = -130 + v / max * 260;
|
||||
const major = v % step === 0;
|
||||
const a = point(angle, major ? 112 : 119), b = point(angle, 126), n = point(angle, 97);
|
||||
ticks += `<line class="tick ${major ? 'major' : ''}" x1="${a[0]}" y1="${a[1]}" x2="${b[0]}" y2="${b[1]}"/>`;
|
||||
if (major) ticks += `<text x="${n[0]}" y="${n[1] + 4}">${max === 7000 ? v / 1000 : v}</text>`;
|
||||
}
|
||||
$(id).innerHTML = `<svg viewBox="0 0 300 285" role="img" aria-label="${label} : en attente"><circle cx="150" cy="150" r="133" fill="none" stroke="var(--border)"/>${ticks}<text x="150" y="110" class="dial-label">${unit}</text><line class="needle" x1="150" y1="150" x2="150" y2="43" style="transform-origin:150px 150px;transform:rotate(-130deg)"/><circle cx="150" cy="150" r="7" fill="var(--accent)"/><circle cx="150" cy="150" r="3" fill="var(--surface)"/><text x="150" y="212" class="dial-value">—</text><text x="150" y="239" class="dial-label">${label}</text></svg>`;
|
||||
return value => {
|
||||
const valid = typeof value === 'number' && Number.isFinite(value);
|
||||
$(`${id} .dial-value`).textContent = valid ? Math.round(value).toLocaleString('fr-FR') : '—';
|
||||
$(`${id} .needle`).style.opacity = valid ? '1' : '0';
|
||||
if (valid) $(`${id} .needle`).style.transform = `rotate(${-130 + Math.max(0, Math.min(max, value)) / max * 260}deg)`;
|
||||
$(`${id} svg`).setAttribute('aria-label', `${label} : ${valid ? Math.round(value) : 'indisponible'} ${max === 7000 ? 'tr/min' : 'km/h'}`);
|
||||
};
|
||||
}
|
||||
const speedGauge = makeGauge('#speed-gauge', 220, 20, 'KM/H', 'VITESSE');
|
||||
const rpmGauge = makeGauge('#rpm-gauge', 7000, 1000, '× 1 000 TR/MIN', 'RÉGIME MOTEUR');
|
||||
const savedProfile = storage.get('profile', 'route');
|
||||
$('#profile').value = ['route', 'ville', 'arret'].includes(savedProfile) ? savedProfile : 'route';
|
||||
let generation = 0, timer;
|
||||
function offline() {
|
||||
$('#connection').textContent = 'Données indisponibles';
|
||||
$('#connection').classList.add('error');
|
||||
$('#footer-status').textContent = 'Connexion perdue · tentative de reconnexion';
|
||||
document.body.classList.add('stale');
|
||||
all('[data-value]').forEach(el => el.textContent = '—');
|
||||
all('[data-fuel]').forEach(el => el.style.width = '0%');
|
||||
speedGauge(null); rpmGauge(null);
|
||||
}
|
||||
async function poll() {
|
||||
const current = ++generation;
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
const response = await fetch(`/api/telemetry?profile=${encodeURIComponent($('#profile').value)}`, { signal: AbortSignal.timeout(3000), cache: 'no-store' });
|
||||
if (!response.ok) throw new Error('API');
|
||||
const data = await response.json();
|
||||
if (current !== generation) return;
|
||||
const numeric = ['speedKph', 'rpm', 'fuelPercent', 'coolantCelsius', 'batteryVolts', 'outsideCelsius', 'rangeKm', 'tripKm', 'consumptionL100'];
|
||||
if (data.schemaVersion !== 1 || data.source !== 'demo' || !Number.isFinite(data.timestamp) || Math.abs(Date.now() - data.timestamp) > 10000 || numeric.some(key => !Number.isFinite(data[key]))) throw new Error('Données invalides');
|
||||
$('#connection').textContent = '● Démonstration';
|
||||
$('#connection').classList.remove('error');
|
||||
document.body.classList.remove('stale');
|
||||
$('#footer-status').textContent = 'Mode démonstration · CAN non connecté';
|
||||
all('[data-value]').forEach(el => {
|
||||
const value = data[el.dataset.value];
|
||||
el.textContent = typeof value === 'number' ? value.toLocaleString('fr-FR', { maximumFractionDigits: 1 }) : value;
|
||||
});
|
||||
all('[data-fuel]').forEach(el => el.style.width = `${Math.max(0, Math.min(100, data.fuelPercent))}%`);
|
||||
speedGauge(data.speedKph); rpmGauge(data.rpm);
|
||||
} catch { if (current === generation) offline(); }
|
||||
finally { if (current === generation) timer = setTimeout(poll, 500); }
|
||||
}
|
||||
$('#profile').addEventListener('change', () => { storage.set('profile', $('#profile').value); poll(); });
|
||||
window.addEventListener('storage', event => {
|
||||
if (event.key === 'caria.profile' && ['route', 'ville', 'arret'].includes(event.newValue)) { $('#profile').value = event.newValue; poll(); }
|
||||
if (event.key === 'caria.stream') configureStream();
|
||||
if (event.key === 'caria.theme') document.body.classList.toggle('light', event.newValue === 'light');
|
||||
});
|
||||
poll();
|
||||
|
||||
function validUrl(value) {
|
||||
try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password ? url.href : ''; } catch { return ''; }
|
||||
}
|
||||
const defaultStream = 'https://media.cunatbrule.fr/web/';
|
||||
function configureStream() {
|
||||
const url = validUrl(storage.get('stream', defaultStream));
|
||||
$('#stream-link').hidden = !url;
|
||||
if (url) $('#stream-link').href = url; else $('#stream-link').removeAttribute('href');
|
||||
$('#stream-description').textContent = url ? new URL(url).hostname : 'Ajoutez l’adresse de votre site dans les réglages.';
|
||||
$('#stream-configure').textContent = url ? 'Modifier le site' : 'Configurer mon site';
|
||||
}
|
||||
configureStream();
|
||||
document.body.classList.toggle('light', storage.get('theme') === 'light');
|
||||
function openSettings() {
|
||||
$('#stream-url').value = storage.get('stream', defaultStream);
|
||||
$('#theme').value = document.body.classList.contains('light') ? 'light' : 'dark';
|
||||
$('#settings-message').textContent = '';
|
||||
$('#settings').showModal();
|
||||
}
|
||||
$('#settings-open').onclick = openSettings;
|
||||
$('#stream-configure').onclick = openSettings;
|
||||
$('#settings-close').onclick = () => $('#settings').close();
|
||||
$('#settings-form').addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
const raw = $('#stream-url').value.trim(), url = validUrl(raw);
|
||||
if (raw && !url) { $('#settings-message').textContent = 'Saisissez une adresse HTTP ou HTTPS, sans identifiant ni mot de passe.'; return; }
|
||||
const streamSaved = storage.set('stream', url), themeSaved = storage.set('theme', $('#theme').value);
|
||||
if (!streamSaved || !themeSaved) { $('#settings-message').textContent = 'Le navigateur bloque l’enregistrement local des réglages.'; return; }
|
||||
document.body.classList.toggle('light', $('#theme').value === 'light');
|
||||
configureStream(); $('#settings').close(); toast('Réglages enregistrés');
|
||||
});
|
||||
$('#fullscreen').onclick = async () => {
|
||||
try { if (document.fullscreenElement) await document.exitFullscreen(); else await document.documentElement.requestFullscreen(); }
|
||||
catch { toast('Le plein écran n’est pas disponible dans ce navigateur.'); }
|
||||
};
|
||||
|
||||
const audio = $('#audio');
|
||||
let tracks = [], selected = -1;
|
||||
function renderPlaylist() {
|
||||
$('#playlist').replaceChildren();
|
||||
tracks.forEach((track, index) => {
|
||||
const li = document.createElement('li'), button = document.createElement('button');
|
||||
const number = document.createElement('span'), name = document.createElement('b');
|
||||
number.textContent = String(index + 1).padStart(2, '0'); name.textContent = track.name;
|
||||
button.append(number, name); button.classList.toggle('selected', index === selected);
|
||||
button.setAttribute('aria-pressed', String(index === selected)); button.onclick = () => selectTrack(index, true);
|
||||
li.append(button); $('#playlist').append(li);
|
||||
});
|
||||
$('#track-count').textContent = `${tracks.length} TITRE${tracks.length > 1 ? 'S' : ''}`;
|
||||
$('#empty-library').hidden = tracks.length > 0;
|
||||
['#play', '#previous', '#next'].forEach(id => $(id).disabled = tracks.length === 0);
|
||||
}
|
||||
async function playAudio() {
|
||||
try { await audio.play(); $('#audio-message').textContent = 'Lecture locale · aucun fichier envoyé au serveur.'; }
|
||||
catch { $('#audio-message').textContent = 'Lecture impossible : réessayez ou choisissez un format audio pris en charge.'; }
|
||||
}
|
||||
function selectTrack(index, play = false) {
|
||||
if (!tracks.length) return;
|
||||
selected = (index + tracks.length) % tracks.length;
|
||||
audio.src = tracks[selected].url;
|
||||
$('#track-title').textContent = tracks[selected].name;
|
||||
$('#home-track').textContent = tracks[selected].name;
|
||||
$('#track-subtitle').textContent = `Morceau ${selected + 1} sur ${tracks.length} · Bibliothèque locale`;
|
||||
$('#home-track-sub').textContent = 'Bibliothèque locale';
|
||||
renderPlaylist(); if (play) playAudio();
|
||||
}
|
||||
$('#music-files').addEventListener('change', event => {
|
||||
let added = 0;
|
||||
for (const file of event.target.files) {
|
||||
if (!file.type.startsWith('audio/') && !/\.(mp3|flac|ogg|wav|m4a|aac|opus)$/i.test(file.name)) continue;
|
||||
tracks.push({ name: file.name.replace(/\.[^.]+$/, ''), url: URL.createObjectURL(file) }); added++;
|
||||
}
|
||||
renderPlaylist();
|
||||
if (selected === -1 && tracks.length) selectTrack(0);
|
||||
toast(added ? `${added} morceau${added > 1 ? 'x' : ''} ajouté${added > 1 ? 's' : ''}` : 'Aucun fichier audio reconnu');
|
||||
event.target.value = '';
|
||||
});
|
||||
$('#play').onclick = () => { if (selected === -1) return; if (audio.paused) playAudio(); else audio.pause(); };
|
||||
$('#previous').onclick = () => selectTrack(selected - 1, true);
|
||||
$('#next').onclick = () => selectTrack(selected + 1, true);
|
||||
audio.addEventListener('play', () => $('#play').textContent = 'Pause');
|
||||
audio.addEventListener('pause', () => $('#play').textContent = 'Lecture');
|
||||
audio.addEventListener('ended', () => { if (selected + 1 < tracks.length) selectTrack(selected + 1, true); else $('#play').textContent = 'Lecture'; });
|
||||
audio.addEventListener('error', () => $('#audio-message').textContent = 'Ce fichier ne peut pas être lu. Essayez un autre format audio.');
|
||||
window.addEventListener('pagehide', event => { if (!event.persisted) tracks.forEach(track => URL.revokeObjectURL(track.url)); });
|
||||
renderPlaylist();
|
||||
@@ -0,0 +1,51 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="theme-color" content="#101313"><title>CARIA · Cockpit</title>
|
||||
<link rel="stylesheet" href="/styles.css"><script type="module" src="/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<aside class="rail">
|
||||
<a href="#home" class="brand" aria-label="CARIA accueil">C<span> /</span></a>
|
||||
<nav aria-label="Applications">
|
||||
<a href="#home" data-view="home"><span class="icon">⌂</span><span>Accueil</span></a>
|
||||
<a href="#drive" data-view="drive"><span class="icon">◉</span><span>Conduite</span></a>
|
||||
<a href="#music" data-view="music"><span class="icon">♫</span><span>Musique</span></a>
|
||||
<a href="#stream" data-view="stream"><span class="icon">▷</span><span>Streaming</span></a>
|
||||
</nav>
|
||||
<button id="settings-open" class="settings-button"><span class="icon">⚙</span><span>Réglages</span></button>
|
||||
</aside>
|
||||
<div class="workspace">
|
||||
<header><div class="wordmark">CARIA<span>AUTOMOTIVE</span></div><div class="header-right"><span id="connection" class="badge">Connexion…</span><span class="header-temp">21° <small>EXT. · DÉMO</small></span><time id="header-clock">--:--</time><button id="fullscreen" aria-label="Plein écran" title="Plein écran">⛶</button></div></header>
|
||||
<main>
|
||||
<section id="home" class="view">
|
||||
<div class="page-heading"><div><p class="eyebrow">VOTRE ESPACE À BORD</p><h1>Le plaisir du trajet.</h1></div><span class="subtle">Cockpit personnel <span class="accent">/ 01</span></span></div>
|
||||
<div class="home-grid">
|
||||
<article class="clock-card card"><div class="card-top"><span class="eyebrow">BONJOUR, CONDUCTEUR</span><span class="tiny-dot"></span></div><time id="hero-clock">--:--</time><p id="date">—</p><div class="horizon" aria-hidden="true"><div class="sun"></div><div class="mountain one"></div><div class="mountain two"></div><div class="road"></div></div><div class="clock-bottom"><span>Un nouvel horizon vous attend.</span><span>CARIA / ON BOARD</span></div></article>
|
||||
<article class="weather-card card"><div class="card-top"><span class="eyebrow">À L’EXTÉRIEUR</span><span class="label">DÉMO</span></div><div class="weather-main"><span class="weather-sun">☀</span><strong>21<span>°</span></strong></div><h2>Quelques éclaircies</h2><p>Une belle journée pour prendre la route.</p><div class="weather-bottom"><span>Min. <b>16°</b></span><span>Max. <b>24°</b></span><span>Vent <b>8 km/h</b></span></div></article>
|
||||
<article class="vehicle-card card"><div class="card-top"><span class="eyebrow">VOTRE VÉHICULE</span><span class="label">DÉMO</span></div><h2>Mégane III</h2><div class="vehicle-stats"><div><strong data-value="rangeKm">—</strong><span>km d’autonomie</span></div><div><strong><span data-value="fuelPercent">—</span><small> %</small></strong><span>carburant</span></div></div><div class="fuel-track"><span data-fuel></span></div><a class="text-link" href="#drive">Ouvrir le tableau de bord <span>↗</span></a></article>
|
||||
<article class="music-card card"><div class="album-art" aria-hidden="true"><div class="record"></div><span>C / SOUND</span></div><div><p class="eyebrow">LA BANDE-SON DU VOYAGE</p><h2 id="home-track">Votre musique, partout.</h2><p id="home-track-sub">Emportez votre bibliothèque locale.</p><a class="pill" href="#music">♫ Ouvrir ma musique <span>↗</span></a></div></article>
|
||||
</div>
|
||||
</section>
|
||||
<section id="drive" class="view" hidden>
|
||||
<div class="page-heading"><div><p class="eyebrow">LES ESSENTIELS EN UN REGARD</p><h1>Tableau de bord</h1></div><label class="profile-label">Simulation <select id="profile"><option value="route">Route</option><option value="ville">Ville</option><option value="arret">À l’arrêt</option></select></label></div>
|
||||
<div class="cluster card"><div class="cluster-top"><span>MÉGANE III <span class="subtle">/ INSTRUMENTATION</span></span><span class="label">DONNÉES DE DÉMONSTRATION</span></div><div class="gauges"><div class="gauge" id="speed-gauge"></div><div class="gear"><p class="eyebrow">RAPPORT</p><strong data-value="gear">—</strong><span>DEMO</span></div><div class="gauge" id="rpm-gauge"></div></div><div class="cluster-bottom"><span>Trajet <b><span data-value="tripKm">—</span> km</b></span><span>Consommation <b><span data-value="consumptionL100">—</span> L/100</b></span></div></div>
|
||||
<div class="instrument-grid"><article class="card"><p class="eyebrow">CARBURANT</p><h2><span data-value="fuelPercent">—</span><small> %</small></h2><div class="fuel-track"><span data-fuel></span></div><p><span data-value="rangeKm">—</span> km d’autonomie</p></article><article class="card"><p class="eyebrow">TEMPÉRATURE MOTEUR</p><h2><span data-value="coolantCelsius">—</span><small> °C</small></h2><p>Liquide de refroidissement</p></article><article class="card"><p class="eyebrow">BATTERIE</p><h2><span data-value="batteryVolts">—</span><small> V</small></h2><p>Tension simulée</p></article><article class="card"><p class="eyebrow">TEMPÉRATURE EXTÉRIEURE</p><h2><span data-value="outsideCelsius">—</span><small> °C</small></h2><p>Mesure de démonstration</p></article></div>
|
||||
</section>
|
||||
<section id="music" class="view" hidden>
|
||||
<div class="page-heading"><div><p class="eyebrow">VOTRE BIBLIOTHÈQUE EMBARQUÉE</p><h1>Musique locale</h1></div><label class="pill file-picker">+ Ajouter des morceaux<input id="music-files" type="file" accept="audio/*,.mp3,.flac,.ogg,.wav,.m4a" multiple></label></div>
|
||||
<div class="player-grid"><article class="card player"><div class="album-art large" aria-hidden="true"><div class="record"></div><span>C / SOUND</span></div><p class="eyebrow">EN ÉCOUTE</p><h2 id="track-title">À vous de donner le tempo.</h2><p id="track-subtitle">Choisissez des fichiers sur cet appareil.</p><audio id="audio" controls preload="metadata"></audio><div class="player-actions"><button id="previous" aria-label="Morceau précédent">❮</button><button id="play" class="pill">Lecture</button><button id="next" aria-label="Morceau suivant">❯</button></div><p id="audio-message" role="status">MP3, WAV, OGG… selon les formats pris en charge par votre navigateur.</p></article><article class="card library"><div class="card-top"><h2>Vos morceaux</h2><span id="track-count" class="label">0 TITRE</span></div><ol id="playlist"></ol><div id="empty-library"><span>♫</span><h3>La route a sa bande-son.</h3><p>Ajoutez vos morceaux avec le bouton ci-dessus.<br>Ils restent sur cet appareil, sans envoi au serveur.</p></div></article></div>
|
||||
</section>
|
||||
<section id="stream" class="view" hidden>
|
||||
<div class="page-heading"><div><p class="eyebrow">VOTRE APPLICATION WEB</p><h1>Streaming</h1></div></div><article class="card stream-card"><span class="stream-symbol">▷</span><p class="eyebrow">VOTRE CINÉMA À BORD</p><h2>Un accès direct à votre univers.</h2><p id="stream-description">Ajoutez l’adresse de votre site dans les réglages.</p><a id="stream-link" class="pill" target="_blank" rel="noopener noreferrer" hidden>Ouvrir mon streaming ↗</a><button id="stream-configure" class="pill">Configurer mon site</button><p class="subtle">S’ouvre dans un nouvel onglet. Votre cockpit reste accessible.</p></article>
|
||||
</section>
|
||||
</main>
|
||||
<footer><span><i class="tiny-dot"></i> COCKPIT <span class="subtle">/ VERSION 0.1</span></span><span id="footer-status">Mode démonstration · CAN non connecté</span><span>FAIT POUR LE VOYAGE <span class="accent">↗</span></span></footer>
|
||||
</div>
|
||||
</div>
|
||||
<dialog id="settings"><form id="settings-form"><div class="card-top"><h2>Votre cockpit</h2><button type="button" id="settings-close" aria-label="Fermer les réglages">✕</button></div><label>Adresse de votre streaming<input id="stream-url" type="url" placeholder="https://votre-site.fr"></label><p class="subtle">Enregistrée dans ce navigateur. Laissez vide pour retirer le lien.</p><label>Ambiance<select id="theme"><option value="dark">Nuit</option><option value="light">Jour</option></select></label><p>Plusieurs écrans : ouvrez la conduite sur l’un et l’accueil sur l’autre.</p><div class="screen-links"><a href="/#drive" target="_blank" rel="noopener">Écran conduite ↗</a><a href="/#home" target="_blank" rel="noopener">Écran multimédia ↗</a></div><p class="subtle">Météo et véhicule : démonstration. Navigation et radar seront ajoutés dans une prochaine étape.</p><button class="pill" type="submit">Enregistrer</button><p id="settings-message" role="status"></p></form></dialog>
|
||||
<div id="toast" role="status" hidden></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
location /api/ {
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $backend http://telemetry:8080;
|
||||
proxy_pass $backend;
|
||||
proxy_connect_timeout 2s;
|
||||
proxy_read_timeout 5s;
|
||||
}
|
||||
location / { try_files $uri $uri/ =404; }
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user