Files
weather/app/public/dashboard-settings.html
T
Nate EmmertandClaude Sonnet 5.5 1830fb2e2b Merge /conditions into the app as a Station tab
- New Station tab (top tabs, bottom nav, swipe order) carrying the old /conditions look:
  radar-ring hero, condition pill, wind compass, atmosphere/precip/solar cards, indoor
- Scoped sl-* styles and station-live.js; units follow the app's metric/imperial setting
- Empty/error states when no station is connected
- /conditions now 301-redirects to /?tab=station; removed conditions.html,
  /api/conditions/public and the old My Station drawer
- /api/ambient-config also returns the station name
- Dashboard and settings links updated; SW cache v8; manifest shortcut for Station
- Drops an indoor-temp block in applyStationOverlay that referenced an out-of-scope helper

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:45:47 -04:00

426 lines
21 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Station Settings</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--ink: #1a1a1a; --ink-mid: #3d3730; --ink-light: #5f5649;
--paper: #f5f2ec; --paper-dark: #ece7db; --paper-darker: #ddd6c7;
--border: #b9ae9b; --accent: #c25a2a; --cool: #2a6b8a;
--mono: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--ink: #ece7dc; --ink-mid: #d2c8b1; --ink-light: #beb399;
--paper: #191510; --paper-dark: #201c16; --paper-darker: #2b2620;
--border: #3a342a; --accent: #e07848; --cool: #5ab0d8;
}
}
html, body { min-height: 100%; background: var(--paper); color: var(--ink); font-family: var(--mono); font-size: 15px; }
.header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.5rem; border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--paper-dark) 82%, transparent); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); position: sticky; top: 0; z-index: 10; transition: box-shadow .25s; }
.header.scrolled { box-shadow: 0 4px 24px rgba(0,0,0,0.18); }
.header h1 { font-size: 1.15rem; }
.header-right { display: flex; align-items: center; gap: 1rem; font-size: 0.85rem; }
.nav-link {
color: var(--ink-light); text-decoration: none; font-size: 0.78rem; font-weight: 600;
padding: 0.32rem 0.75rem; border: 1px solid var(--border); border-radius: 8px;
background: color-mix(in srgb, white 5%, transparent); backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
transition: background .18s cubic-bezier(.2,0,0,1), border-color .18s cubic-bezier(.2,0,0,1),
color .18s cubic-bezier(.2,0,0,1), box-shadow .18s cubic-bezier(.2,0,0,1),
transform .1s cubic-bezier(.2,0,0,1);
}
.nav-link:hover { background: color-mix(in srgb, var(--accent) 18%, transparent); border-color: var(--accent); color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); }
.nav-link:active { transform: scale(0.95); box-shadow: none; }
.page { max-width: 860px; margin: 0 auto; padding: 2rem 1rem 4rem; }
.section { background: var(--paper-dark); border: 1px solid var(--border); border-radius: 14px; padding: 1.5rem; margin-bottom: 1.25rem; }
.section-hdr { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.09em; color: var(--ink-light); margin-bottom: 1.1rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border); }
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 0.75rem 0; }
@media (max-width: 600px) { .settings-grid { grid-template-columns: 1fr; } }
label { display: block; font-size: 0.8rem; color: var(--ink-light); margin-bottom: 0.3rem; }
input[type="text"], input[type="password"], input[type="number"] {
width: 100%; padding: 0.5rem 0.75rem; background: var(--paper); border: 1px solid var(--border);
border-radius: 8px; color: var(--ink); font-size: 0.9rem; font-family: var(--mono);
transition: border-color .15s;
}
input:focus { outline: 2px solid var(--accent); border-color: transparent; }
.settings-actions { display: flex; align-items: center; gap: 1rem; margin-top: 0.85rem; }
button.primary {
padding: 0.45rem 1.2rem; background: var(--accent); color: #fff; border: none;
border-radius: 8px; font-family: var(--mono); font-size: 0.9rem; cursor: pointer; font-weight: 600;
transition: opacity .18s cubic-bezier(.2,0,0,1), box-shadow .18s cubic-bezier(.2,0,0,1), transform .1s cubic-bezier(.2,0,0,1);
}
button.primary:hover { opacity: 0.88; box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent); }
button.primary:active { transform: scale(0.95); box-shadow: none; }
button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
.save-msg { font-size: 0.85rem; color: var(--cool); }
.save-msg.err { color: var(--accent); }
.keys-status { font-size: 0.85rem; margin-bottom: 0.6rem; }
.keys-status.ok { color: var(--cool); }
.keys-status.missing { color: var(--accent); }
.hint { font-size: 0.78rem; color: var(--ink-light); margin-top: 0.65rem; line-height: 1.5; }
.divider { border: none; border-top: 1px solid var(--border); margin: 1.25rem 0; }
.threshold-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
@media (max-width: 480px) { .threshold-row { grid-template-columns: 1fr; } }
/* Export + backfill */
.row-between { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.row-between .hint { margin-top: 0.2rem; }
.controls { display: flex; gap: 0.5rem; align-items: center; flex-shrink: 0; }
.controls select { padding: 0.45rem 0.6rem; background: var(--paper); border: 1px solid var(--border); border-radius: 8px; color: var(--ink); font-family: var(--mono); font-size: 0.85rem; }
.export-link { display: inline-block; padding: 0.45rem 1.2rem; background: var(--cool); color: #fff; border-radius: 8px; font-size: 0.9rem; font-weight: 600; text-decoration: none; }
.export-link:hover { opacity: 0.88; }
.sub-label { font-size: 0.82rem; font-weight: 600; color: var(--ink); margin-bottom: 0.2rem; }
.progress-bar-wrap { height: 6px; background: var(--paper-darker); border-radius: 3px; margin-top: 1rem; overflow: hidden; }
.progress-bar { height: 100%; background: var(--cool); border-radius: 3px; transition: width 0.5s; width: 0%; }
.progress-text { font-size: 0.82rem; color: var(--ink-light); margin-top: 0.4rem; }
</style>
</head>
<body>
<div class="header" id="pageHeader">
<div><h1>Settings</h1></div>
<div class="header-right">
<a href="/dashboard" class="nav-link">← Dashboard</a>
<a href="/dashboard/account" class="nav-link">Account</a>
<a href="/dashboard/logout" class="nav-link">Sign out</a>
</div>
</div>
<div class="page">
<!-- Station API Keys -->
<div class="section">
<div class="section-hdr">Station API Keys</div>
<p id="keysStatus" class="keys-status"></p>
<div class="settings-grid">
<div>
<label>Ambient API Key</label>
<input id="inputApiKey" type="password" placeholder="Enter API key" autocomplete="off">
</div>
<div>
<label>Application Key</label>
<input id="inputAppKey" type="password" placeholder="Enter application key" autocomplete="off">
</div>
</div>
<div class="settings-actions">
<button class="primary" id="saveKeysBtn">Save Keys</button>
<span id="saveMsg" class="save-msg"></span>
</div>
<p class="hint">Find these at <strong>ambientweather.net → My Account → API Keys</strong>. Keys are stored on the server only — never exposed to the browser.</p>
</div>
<!-- Alert Thresholds -->
<div class="section">
<div class="section-hdr">Alert Thresholds</div>
<p class="hint" style="margin:0 0 0.85rem">Leave blank to disable. Applied to all push/ntfy subscriptions.</p>
<div class="threshold-row">
<div><label>High Temp (°F)</label><input id="thrTempHigh" type="number" placeholder="e.g. 95" autocomplete="off"></div>
<div><label>Low Temp (°F)</label><input id="thrTempLow" type="number" placeholder="e.g. 35" autocomplete="off"></div>
<div><label>Wind Gust (mph)</label><input id="thrWind" type="number" placeholder="e.g. 40" autocomplete="off"></div>
<div><label>Rain Rate (in/hr)</label><input id="thrRainRate" type="number" step="0.01" placeholder="e.g. 0.5" autocomplete="off"></div>
</div>
<div class="settings-actions">
<button class="primary" id="saveThreshBtn">Save Thresholds</button>
<span id="threshMsg" class="save-msg"></span>
</div>
</div>
<!-- Station Display -->
<div class="section">
<div class="section-hdr">Station Display</div>
<div>
<label>Station Name (shown on the Station tab of the weather app)</label>
<input id="inputStationName" type="text" placeholder="e.g. Genesis 8:22" maxlength="60" autocomplete="off">
</div>
<div class="settings-actions">
<button class="primary" id="saveNameBtn">Save Name</button>
<span id="nameMsg" class="save-msg"></span>
</div>
</div>
<!-- Station Location -->
<div class="section">
<div class="section-hdr">Station Location (for NWS Alerts)</div>
<p class="hint" style="margin:0 0 0.85rem">Used to fetch active NWS weather alerts. If you have push notifications enabled, your saved subscription location is used automatically.</p>
<div class="threshold-row">
<div><label>Latitude</label><input id="inputLat" type="number" step="0.0001" placeholder="e.g. 38.8977" autocomplete="off"></div>
<div><label>Longitude</label><input id="inputLon" type="number" step="0.0001" placeholder="e.g. -77.0365" autocomplete="off"></div>
</div>
<div class="settings-actions">
<button class="primary" id="saveLocBtn">Save Location</button>
<span id="locMsg" class="save-msg"></span>
</div>
</div>
<!-- Export / Import -->
<div class="section">
<div class="section-hdr">Data</div>
<div class="row-between">
<div>
<div class="sub-label">Export</div>
<p class="hint" style="margin:0">Download your station history as a CSV spreadsheet.</p>
</div>
<div class="controls">
<select id="exportRange">
<option value="7d">7 days</option>
<option value="30d" selected>30 days</option>
<option value="90d">90 days</option>
<option value="1y">1 year</option>
<option value="2y">2 years</option>
<option value="3y">3 years</option>
</select>
<a id="exportBtn" class="export-link" href="#" target="_blank">Download CSV</a>
</div>
</div>
<hr class="divider">
<div class="row-between">
<div>
<div class="sub-label">Import Historical Data</div>
<p class="hint" style="margin:0">Pull existing data from Ambient's servers into your local records. Ambient keeps ~1 year of history.</p>
</div>
<div class="controls">
<select id="backfillDays">
<option value="7">7 days</option>
<option value="30" selected>30 days</option>
<option value="90">90 days</option>
<option value="180">180 days</option>
<option value="365">1 year</option>
</select>
<button class="primary" id="backfillBtn">Import</button>
</div>
</div>
<div id="backfillProgress" style="display:none">
<div class="progress-bar-wrap"><div class="progress-bar" id="progressBar"></div></div>
<div class="progress-text" id="progressText"></div>
</div>
</div>
<!-- Account -->
<div class="section">
<div class="section-hdr">Account &amp; Users</div>
<p class="hint" style="margin:0 0 0.85rem">Change your password and manage who can sign in (admin and viewer accounts).</p>
<a class="export-link" href="/dashboard/account">Open account &amp; users →</a>
</div>
</div>
</div>
<script>
// ── API Keys ──────────────────────────────────────────────────────────────────
async function checkKeyStatus() {
try {
const r = await fetch('/api/ambient-config');
if (!r.ok) return;
const { configured } = await r.json();
const el = document.getElementById('keysStatus');
if (configured) {
el.textContent = '✓ API keys are configured';
el.className = 'keys-status ok';
} else {
el.textContent = '⚠ No API keys saved — enter them below to enable station data';
el.className = 'keys-status missing';
}
} catch {}
}
document.getElementById('saveKeysBtn').addEventListener('click', async () => {
const apiKey = document.getElementById('inputApiKey').value.trim();
const appKey = document.getElementById('inputAppKey').value.trim();
const msg = document.getElementById('saveMsg');
if (!apiKey || !appKey) { msg.textContent = 'Both fields are required.'; msg.className = 'save-msg err'; return; }
try {
const r = await fetch('/api/ambient-config', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey, appKey })
});
if (r.status === 401) { location.href = '/dashboard/login'; return; }
if (!r.ok) throw new Error();
msg.textContent = 'Saved! Data will start recording on the next poll.';
msg.className = 'save-msg';
document.getElementById('inputApiKey').value = '';
document.getElementById('inputAppKey').value = '';
setTimeout(() => msg.textContent = '', 4000);
checkKeyStatus();
} catch { msg.textContent = 'Error saving keys.'; msg.className = 'save-msg err'; }
});
// ── Alert Thresholds ──────────────────────────────────────────────────────────
async function loadThresholds() {
try {
const r = await fetch('/api/subscriptions');
if (!r.ok) return;
const subs = await r.json();
const sub = subs?.find(s => s.thresholds);
if (!sub) return;
const t = sub.thresholds;
const set = (id, v) => { const el = document.getElementById(id); if (el && v) el.value = v; };
set('thrTempHigh', t.tempHigh);
set('thrTempLow', t.tempLow);
set('thrWind', t.wind);
set('thrRainRate', t.rainRate);
} catch {}
}
document.getElementById('saveThreshBtn').addEventListener('click', async () => {
const msg = document.getElementById('threshMsg');
const val = id => { const v = document.getElementById(id)?.value; return v ? parseFloat(v) : undefined; };
const thresholds = {};
const th = val('thrTempHigh'); if (th) thresholds.tempHigh = th;
const tl = val('thrTempLow'); if (tl) thresholds.tempLow = tl;
const wi = val('thrWind'); if (wi) thresholds.wind = wi;
const rr = val('thrRainRate'); if (rr) thresholds.rainRate = rr;
try {
const r = await fetch('/api/subscriptions/thresholds', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ thresholds })
});
if (r.ok) { msg.textContent = 'Saved'; msg.className = 'save-msg'; }
else { msg.textContent = 'Error saving'; msg.className = 'save-msg err'; }
setTimeout(() => msg.textContent = '', 4000);
} catch { msg.textContent = 'Error'; msg.className = 'save-msg err'; }
});
// ── Station Name ──────────────────────────────────────────────────────────────
async function loadStationName() {
try {
const r = await fetch('/api/dashboard/station-name');
if (!r.ok) return;
const d = await r.json();
const el = document.getElementById('inputStationName');
if (el && d.name) el.value = d.name;
} catch {}
}
document.getElementById('saveNameBtn').addEventListener('click', async () => {
const name = document.getElementById('inputStationName')?.value?.trim();
const msg = document.getElementById('nameMsg');
if (!name) { msg.textContent = 'Enter a name'; msg.className = 'save-msg err'; return; }
try {
const r = await fetch('/api/dashboard/station-name', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
if (r.ok) { msg.textContent = 'Saved'; msg.className = 'save-msg'; }
else { msg.textContent = 'Error'; msg.className = 'save-msg err'; }
setTimeout(() => msg.textContent = '', 3000);
} catch { msg.textContent = 'Error'; msg.className = 'save-msg err'; }
});
// ── Station Location ──────────────────────────────────────────────────────────
async function loadLocation() {
try {
const r = await fetch('/api/dashboard/location');
if (!r.ok) return;
const loc = await r.json();
if (!loc) return;
const latEl = document.getElementById('inputLat');
const lonEl = document.getElementById('inputLon');
if (latEl) latEl.value = loc.lat;
if (lonEl) lonEl.value = loc.lon;
} catch {}
}
document.getElementById('saveLocBtn').addEventListener('click', async () => {
const msg = document.getElementById('locMsg');
const lat = parseFloat(document.getElementById('inputLat')?.value);
const lon = parseFloat(document.getElementById('inputLon')?.value);
if (!isFinite(lat) || !isFinite(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) {
msg.textContent = 'Enter valid lat/lon'; msg.className = 'save-msg err'; return;
}
try {
const r = await fetch('/api/dashboard/location', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lat, lon })
});
if (r.ok) { msg.textContent = 'Saved'; msg.className = 'save-msg'; }
else { msg.textContent = 'Error'; msg.className = 'save-msg err'; }
setTimeout(() => msg.textContent = '', 3000);
} catch { msg.textContent = 'Error'; msg.className = 'save-msg err'; }
});
// ── Export ────────────────────────────────────────────────────────────────────
function updateExportLink() {
const sel = document.getElementById('exportRange');
const btn = document.getElementById('exportBtn');
if (sel && btn) btn.href = `/api/station/export.csv?range=${sel.value}`;
}
document.getElementById('exportRange')?.addEventListener('change', updateExportLink);
updateExportLink();
// ── Backfill ──────────────────────────────────────────────────────────────────
let backfillPoller = null;
async function startBackfill() {
const days = document.getElementById('backfillDays').value;
const btn = document.getElementById('backfillBtn');
const progress = document.getElementById('backfillProgress');
btn.disabled = true; btn.textContent = 'Running…';
progress.style.display = 'block';
document.getElementById('progressBar').style.width = '0%';
document.getElementById('progressText').textContent = 'Starting…';
try {
const r = await fetch('/api/station/backfill', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ days: parseInt(days) })
});
if (r.status === 401) { location.href = '/dashboard/login'; return; }
const data = await r.json();
if (!data.ok) {
document.getElementById('progressText').textContent = data.error || 'Failed to start.';
btn.disabled = false; btn.textContent = 'Import'; return;
}
backfillPoller = setInterval(pollBackfill, 2000);
} catch (e) {
document.getElementById('progressText').textContent = 'Error: ' + e.message;
btn.disabled = false; btn.textContent = 'Import';
}
}
async function pollBackfill() {
try {
const r = await fetch('/api/station/backfill/status');
if (!r.ok) return;
const s = await r.json();
const pct = s.total > 0 ? Math.min(100, Math.round((s.done / s.total) * 100)) : 0;
document.getElementById('progressBar').style.width = pct + '%';
if (s.error) {
document.getElementById('progressText').textContent = 'Error: ' + s.error;
} else if (s.running) {
document.getElementById('progressText').textContent = `${s.done} of ${s.total} days processed · ${s.inserted.toLocaleString()} readings imported`;
} else {
document.getElementById('progressText').textContent = `Done — imported ${s.inserted.toLocaleString()} readings.`;
document.getElementById('progressBar').style.width = '100%';
clearInterval(backfillPoller); backfillPoller = null;
document.getElementById('backfillBtn').disabled = false;
document.getElementById('backfillBtn').textContent = 'Import';
}
} catch {}
}
document.getElementById('backfillBtn').addEventListener('click', startBackfill);
// ── Scroll shadow ─────────────────────────────────────────────────────────────
window.addEventListener('scroll', () => {
document.getElementById('pageHeader').classList.toggle('scrolled', window.scrollY > 5);
}, { passive: true });
// ── Init ──────────────────────────────────────────────────────────────────────
checkKeyStatus();
loadThresholds();
loadStationName();
loadLocation();
</script>
</body>
</html>