368 lines
15 KiB
HTML
368 lines
15 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=1024, height=600, initial-scale=1">
|
||
<title>Watchboard</title>
|
||
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { background: #0a0a0a; color: #e0ddd5; font-family: 'Sora', sans-serif; overflow: hidden; height: 100vh; display: flex; }
|
||
|
||
/* Layout */
|
||
#left { width: 58%; display: flex; flex-direction: column; padding: 16px 0 16px 20px; gap: 12px; }
|
||
#right { width: 42%; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 16px; gap: 12px; }
|
||
|
||
/* Camera */
|
||
#cam-wrap { width: 70%; border-radius: 8px; overflow: hidden; border: 1px solid #222; flex-shrink: 0; }
|
||
#pulse-cam { width: 100%; display: block; background: #111; }
|
||
|
||
/* Clock */
|
||
#clock-wrap { position: relative; }
|
||
canvas#clock { width: 180px; height: 180px; }
|
||
#digital-time { text-align: center; color: #c8b273; font-size: 22px; font-weight: 300; letter-spacing: 3px; margin-top: 6px; font-variant-numeric: tabular-nums; }
|
||
#digital-date { text-align: center; color: #666; font-size: 13px; font-weight: 300; letter-spacing: 1px; margin-top: 2px; }
|
||
|
||
/* Calendar */
|
||
#calendar { width: 100%; max-width: 280px; }
|
||
#cal-nav { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||
#cal-nav button { background: none; border: 1px solid #333; color: #c8b273; font-family: 'Sora'; font-size: 16px; width: 28px; height: 28px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||
#cal-nav button:hover { background: #1a1a1a; border-color: #c8b273; }
|
||
#cal-title { color: #c8b273; font-size: 15px; font-weight: 600; letter-spacing: 1px; }
|
||
#cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; text-align: center; }
|
||
#cal-grid .day-name { color: #555; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; padding: 4px 0; }
|
||
#cal-grid .day { padding: 5px 0; font-size: 13px; font-weight: 300; color: #444; border-radius: 50%; aspect-ratio: 1; display: flex; align-items: center; justify-content: center; }
|
||
#cal-grid .day.current-month { color: #999; }
|
||
#cal-grid .day.today { background: #c8b273; color: #0a0a0a; font-weight: 700; }
|
||
#cal-grid .day.sunday { color: #664; }
|
||
#cal-grid .day.current-month.sunday { color: #c8b273; opacity: 0.7; }
|
||
#cal-grid .day.today.sunday { color: #0a0a0a; opacity: 1; }
|
||
|
||
/* Alerts */
|
||
#alerts-section { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
|
||
#alerts-header { color: #c8b273; font-size: 12px; font-weight: 600; letter-spacing: 3px; text-transform: uppercase; margin-bottom: 10px; display: flex; align-items: center; gap: 10px; }
|
||
#alerts-header .dot { width: 6px; height: 6px; border-radius: 50%; background: #c8b273; animation: pulse 3s ease-in-out infinite; }
|
||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .2; } }
|
||
#alert-list { flex: 1; overflow-y: auto; scrollbar-width: none; }
|
||
#alert-list::-webkit-scrollbar { display: none; }
|
||
|
||
.alert-item { padding: 12px 40px 12px 16px; border-left: 2px solid #333; margin-bottom: 6px; background: #111; border-radius: 0 6px 6px 0; animation: slideIn .4s ease; position: relative; transition: all .3s; }
|
||
.alert-item.info { border-left-color: #c8b273; }
|
||
.alert-item.warning { border-left-color: #d4a050; }
|
||
.alert-item.critical { border-left-color: #c45; }
|
||
.alert-msg { font-size: 16px; font-weight: 400; color: #ddd; line-height: 1.4; }
|
||
.alert-time { font-size: 11px; color: #888; margin-top: 4px; font-weight: 400; }
|
||
@keyframes slideIn { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } }
|
||
.alert-item.critical .alert-msg { color: #e88; }
|
||
.alert-item.warning .alert-msg { color: #d4a050; }
|
||
|
||
.alert-remove { position: absolute; top: 10px; right: 12px; background: none; border: none; color: #333; font-size: 18px; cursor: pointer; padding: 4px 8px; line-height: 1; font-family: 'Sora'; }
|
||
.alert-remove:hover { color: #c45; }
|
||
.alert-item.done { opacity: 0.35; border-left-color: #2a2a2a !important; }
|
||
.alert-item.done .alert-msg { text-decoration: line-through; }
|
||
|
||
/* Divider line */
|
||
#right::before { content: ''; position: absolute; left: 0; top: 10%; height: 80%; width: 1px; background: linear-gradient(transparent, #333, transparent); }
|
||
#right { position: relative; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="left">
|
||
<div id="alerts-section">
|
||
<div id="alerts-header"><span class="dot"></span> STATUS</div>
|
||
<div id="alert-list"></div>
|
||
</div>
|
||
<div id="cam-wrap"><img id="pulse-cam" alt="Pulse-Ox"></div>
|
||
</div>
|
||
<div id="right">
|
||
<div id="clock-wrap"><canvas id="clock" width="440" height="440"></canvas></div>
|
||
<div id="digital-time"></div>
|
||
<div id="digital-date"></div>
|
||
<div id="calendar">
|
||
<div id="cal-nav">
|
||
<button id="cal-prev">‹</button>
|
||
<div id="cal-title"></div>
|
||
<button id="cal-next">›</button>
|
||
</div>
|
||
<div id="cal-grid"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// === CLOCK (Braun-inspired) ===
|
||
const canvas = document.getElementById('clock');
|
||
const ctx = canvas.getContext('2d');
|
||
const W = canvas.width, H = canvas.height, CX = W/2, CY = H/2, R = W/2 - 30;
|
||
|
||
const GOLD = '#c8b273';
|
||
const DIM = '#333';
|
||
const FACE = '#0a0a0a';
|
||
|
||
function drawClock() {
|
||
const now = new Date();
|
||
ctx.clearRect(0, 0, W, H);
|
||
|
||
// Outer ring
|
||
ctx.beginPath(); ctx.arc(CX, CY, R + 12, 0, Math.PI*2);
|
||
ctx.strokeStyle = '#222'; ctx.lineWidth = 1; ctx.stroke();
|
||
|
||
// Face circle
|
||
ctx.beginPath(); ctx.arc(CX, CY, R, 0, Math.PI*2);
|
||
ctx.strokeStyle = GOLD; ctx.lineWidth = 2; ctx.stroke();
|
||
|
||
// Hour numbers
|
||
ctx.font = '600 28px Sora';
|
||
ctx.fillStyle = '#bbb';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
for (let i = 1; i <= 12; i++) {
|
||
const a = (i * Math.PI / 6) - Math.PI/2;
|
||
const nr = R - 32;
|
||
ctx.fillText(i.toString(), CX + Math.cos(a)*nr, CY + Math.sin(a)*nr);
|
||
}
|
||
|
||
// Minute ticks
|
||
for (let i = 0; i < 60; i++) {
|
||
const a = (i * Math.PI / 30) - Math.PI/2;
|
||
const outer = R - 6;
|
||
const inner = i % 5 === 0 ? R - 18 : R - 12;
|
||
ctx.beginPath();
|
||
ctx.moveTo(CX + Math.cos(a)*inner, CY + Math.sin(a)*inner);
|
||
ctx.lineTo(CX + Math.cos(a)*outer, CY + Math.sin(a)*outer);
|
||
ctx.strokeStyle = i % 5 === 0 ? GOLD : '#444';
|
||
ctx.lineWidth = i % 5 === 0 ? 2 : 1;
|
||
ctx.stroke();
|
||
}
|
||
|
||
const h = now.getHours(), m = now.getMinutes(), s = now.getSeconds();
|
||
|
||
// Hour hand — thick, short
|
||
const ha = ((h % 12) + m/60) * Math.PI/6 - Math.PI/2;
|
||
ctx.beginPath(); ctx.moveTo(CX, CY);
|
||
ctx.lineTo(CX + Math.cos(ha)*R*0.48, CY + Math.sin(ha)*R*0.48);
|
||
ctx.strokeStyle = '#ddd'; ctx.lineWidth = 6; ctx.lineCap = 'round'; ctx.stroke();
|
||
|
||
// Minute hand — thinner, longer
|
||
const ma = (m + s/60) * Math.PI/30 - Math.PI/2;
|
||
ctx.beginPath(); ctx.moveTo(CX, CY);
|
||
ctx.lineTo(CX + Math.cos(ma)*R*0.72, CY + Math.sin(ma)*R*0.72);
|
||
ctx.strokeStyle = '#ddd'; ctx.lineWidth = 3.5; ctx.lineCap = 'round'; ctx.stroke();
|
||
|
||
// Second hand — Braun yellow/gold, thin, with counterweight
|
||
const sa = s * Math.PI/30 - Math.PI/2;
|
||
// Counterweight
|
||
ctx.beginPath(); ctx.moveTo(CX, CY);
|
||
ctx.lineTo(CX - Math.cos(sa)*R*0.15, CY - Math.sin(sa)*R*0.15);
|
||
ctx.strokeStyle = GOLD; ctx.lineWidth = 2; ctx.lineCap = 'round'; ctx.stroke();
|
||
// Main
|
||
ctx.beginPath(); ctx.moveTo(CX, CY);
|
||
ctx.lineTo(CX + Math.cos(sa)*R*0.78, CY + Math.sin(sa)*R*0.78);
|
||
ctx.strokeStyle = GOLD; ctx.lineWidth = 1.5; ctx.stroke();
|
||
|
||
// Center dot
|
||
ctx.beginPath(); ctx.arc(CX, CY, 5, 0, Math.PI*2);
|
||
ctx.fillStyle = GOLD; ctx.fill();
|
||
|
||
// Digital time
|
||
document.getElementById('digital-time').textContent =
|
||
now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||
document.getElementById('digital-date').textContent =
|
||
now.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
|
||
|
||
requestAnimationFrame(drawClock);
|
||
}
|
||
drawClock();
|
||
|
||
// === CALENDAR ===
|
||
let calYear, calMonth;
|
||
(function initCal() {
|
||
const now = new Date();
|
||
calYear = now.getFullYear();
|
||
calMonth = now.getMonth();
|
||
renderCalendar();
|
||
})();
|
||
|
||
document.getElementById('cal-prev').addEventListener('click', () => { calMonth--; if (calMonth < 0) { calMonth = 11; calYear--; } renderCalendar(); });
|
||
document.getElementById('cal-next').addEventListener('click', () => { calMonth++; if (calMonth > 11) { calMonth = 0; calYear++; } renderCalendar(); });
|
||
|
||
function renderCalendar() {
|
||
const now = new Date();
|
||
const isCurrentMonth = calYear === now.getFullYear() && calMonth === now.getMonth();
|
||
|
||
document.getElementById('cal-title').textContent =
|
||
new Date(calYear, calMonth).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||
|
||
const grid = document.getElementById('cal-grid');
|
||
grid.innerHTML = '';
|
||
['S','M','T','W','T','F','S'].forEach(d => {
|
||
const el = document.createElement('div');
|
||
el.className = 'day-name'; el.textContent = d; grid.appendChild(el);
|
||
});
|
||
|
||
const first = new Date(calYear, calMonth, 1).getDay();
|
||
const days = new Date(calYear, calMonth + 1, 0).getDate();
|
||
const today = now.getDate();
|
||
|
||
const prevDays = new Date(calYear, calMonth, 0).getDate();
|
||
for (let i = first - 1; i >= 0; i--) {
|
||
const el = document.createElement('div');
|
||
el.className = 'day'; el.textContent = prevDays - i; grid.appendChild(el);
|
||
}
|
||
for (let d = 1; d <= days; d++) {
|
||
const el = document.createElement('div');
|
||
const dow = new Date(calYear, calMonth, d).getDay();
|
||
let cls = 'day current-month';
|
||
if (isCurrentMonth && d === today) cls += ' today';
|
||
if (dow === 0) cls += ' sunday';
|
||
el.className = cls;
|
||
el.textContent = d; grid.appendChild(el);
|
||
}
|
||
const total = first + days;
|
||
for (let i = 1; i <= (7 - total % 7) % 7; i++) {
|
||
const el = document.createElement('div');
|
||
el.className = 'day'; el.textContent = i; grid.appendChild(el);
|
||
}
|
||
}
|
||
|
||
// Reset to current month at midnight
|
||
setInterval(() => {
|
||
const now = new Date();
|
||
if (now.getHours() === 0 && now.getMinutes() === 0) {
|
||
calYear = now.getFullYear(); calMonth = now.getMonth(); renderCalendar();
|
||
}
|
||
}, 60000);
|
||
|
||
// === NOTIFICATION SOUND ===
|
||
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||
function pling() {
|
||
// Warm two-tone chime
|
||
[880, 1320].forEach((freq, i) => {
|
||
const osc = audioCtx.createOscillator();
|
||
const gain = audioCtx.createGain();
|
||
osc.connect(gain); gain.connect(audioCtx.destination);
|
||
osc.type = 'sine';
|
||
osc.frequency.setValueAtTime(freq, audioCtx.currentTime + i * 0.08);
|
||
gain.gain.setValueAtTime(0.1, audioCtx.currentTime + i * 0.08);
|
||
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + i * 0.08 + 0.5);
|
||
osc.start(audioCtx.currentTime + i * 0.08);
|
||
osc.stop(audioCtx.currentTime + i * 0.08 + 0.5);
|
||
});
|
||
}
|
||
|
||
// === ALERTS ===
|
||
const alertList = document.getElementById('alert-list');
|
||
|
||
function formatTime(ts) {
|
||
const d = new Date(ts);
|
||
const now = new Date();
|
||
const diffMs = now - d;
|
||
if (diffMs < 60000) return 'just now';
|
||
if (diffMs < 3600000) return Math.floor(diffMs/60000) + 'm ago';
|
||
if (diffMs < 86400000) return Math.floor(diffMs/3600000) + 'h ago';
|
||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' ' +
|
||
d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
||
}
|
||
|
||
function renderAlert(alert, prepend = false) {
|
||
const div = document.createElement('div');
|
||
div.className = 'alert-item ' + (alert.priority || 'info') + (alert.done ? ' done' : '');
|
||
div.dataset.id = alert.id;
|
||
div.innerHTML = `<button class="alert-remove" onclick="removeAlert('${alert.id}', this.parentElement)">×</button><div class="alert-msg">${escHtml(alert.message)}</div><div class="alert-time">${formatTime(alert.timestamp)}</div>`;
|
||
|
||
// Long-press (300ms) to toggle done
|
||
let holdTimer = null;
|
||
const startHold = (e) => {
|
||
holdTimer = setTimeout(() => {
|
||
holdTimer = null;
|
||
const isDone = div.classList.toggle('done');
|
||
// Persist done state to server
|
||
fetch('/api/alerts/' + alert.id + '/done', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ done: isDone })
|
||
}).catch(() => {});
|
||
pling();
|
||
}, 300);
|
||
};
|
||
const cancelHold = () => { if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; } };
|
||
div.addEventListener('touchstart', startHold, { passive: true });
|
||
div.addEventListener('touchend', cancelHold);
|
||
div.addEventListener('touchmove', cancelHold);
|
||
div.addEventListener('mousedown', startHold);
|
||
div.addEventListener('mouseup', cancelHold);
|
||
div.addEventListener('mouseleave', cancelHold);
|
||
|
||
if (prepend) alertList.prepend(div);
|
||
else alertList.appendChild(div);
|
||
while (alertList.children.length > 15) alertList.lastChild.remove();
|
||
}
|
||
|
||
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||
|
||
function removeAlert(id, el) {
|
||
el.style.opacity = '0'; el.style.transform = 'translateX(30px)'; el.style.transition = 'all .3s';
|
||
fetch('/api/alerts/' + id, { method: 'DELETE' });
|
||
setTimeout(() => el.remove(), 300);
|
||
}
|
||
|
||
// Update relative times every 30s
|
||
setInterval(() => {
|
||
document.querySelectorAll('.alert-item').forEach(el => {
|
||
const id = el.dataset.id;
|
||
// Would need stored timestamps; refresh page every hour instead
|
||
});
|
||
}, 30000);
|
||
|
||
// Auto-refresh page every 2 hours to keep times fresh
|
||
setTimeout(() => location.reload(), 7200000);
|
||
|
||
// Load existing
|
||
fetch('/api/alerts').then(r => r.json()).then(alerts => {
|
||
alerts.slice(0, 15).forEach(a => renderAlert(a));
|
||
});
|
||
|
||
// SSE
|
||
const evtSource = new EventSource('/api/alerts/stream');
|
||
evtSource.onmessage = (e) => {
|
||
const data = JSON.parse(e.data);
|
||
if (data.type === 'connected') return;
|
||
if (data.type === 'remove') {
|
||
const el = document.querySelector(`.alert-item[data-id="${data.id}"]`);
|
||
if (el) { el.style.opacity = '0'; setTimeout(() => el.remove(), 300); }
|
||
return;
|
||
}
|
||
renderAlert(data, true);
|
||
pling();
|
||
};
|
||
evtSource.onerror = () => { setTimeout(() => location.reload(), 5000); };
|
||
|
||
// Resume audio on first interaction
|
||
document.addEventListener('click', () => { if (audioCtx.state === 'suspended') audioCtx.resume(); }, { once: true });
|
||
|
||
// === PULSE-OX CAMERA (7pm - 8am only) ===
|
||
const camImg = document.getElementById('pulse-cam');
|
||
const camWrap = document.getElementById('cam-wrap');
|
||
|
||
function isCamTime() {
|
||
const h = new Date().getHours();
|
||
return h >= 19 || h < 8;
|
||
}
|
||
|
||
function updateCam() {
|
||
if (isCamTime()) {
|
||
camWrap.style.display = '';
|
||
if (!camImg.src.includes('/stream')) {
|
||
camImg.src = '/api/cam/pulse-ox/stream';
|
||
}
|
||
} else {
|
||
camWrap.style.display = 'none';
|
||
camImg.src = '';
|
||
}
|
||
}
|
||
|
||
updateCam();
|
||
setInterval(updateCam, 60000);
|
||
</script>
|
||
</body>
|
||
</html>
|