Move all settings to dedicated /dashboard/settings page
Publish Container / publish (push) Successful in 2m22s

Extracts API keys, alert thresholds, station name/location, data
export/import, and password change into a standalone settings page.
Dashboard gains a Settings nav link; the accordion at the bottom
is removed along with its now-orphaned JS and CSS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nate EmmertandClaude Sonnet 4.6 committed 2026-09-02 08:08:16 -04:00
1 parent 97dfae86ab
commit 47f60e72f7
3 files changed
+458 -376

No files matched your search

+451
View File
@@ -0,0 +1,451 @@
<!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/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 public conditions page)</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>
<!-- Change Password -->
<div class="section">
<div class="section-hdr">Change Password</div>
<div class="settings-grid">
<div><label>Current Password</label><input id="pwCurrent" type="password" autocomplete="current-password"></div>
<div><label>New Password (min 6 chars)</label><input id="pwNew" type="password" autocomplete="new-password"></div>
</div>
<div class="settings-actions">
<button class="primary" id="changePwBtn">Change Password</button>
<span id="pwMsg" class="save-msg"></span>
</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);
// ── Change Password ───────────────────────────────────────────────────────────
document.getElementById('changePwBtn').addEventListener('click', async () => {
const cur = document.getElementById('pwCurrent')?.value;
const nw = document.getElementById('pwNew')?.value;
const msg = document.getElementById('pwMsg');
if (!cur || !nw || nw.length < 6) { msg.textContent = 'Fill both fields (min 6 chars)'; msg.className = 'save-msg err'; return; }
try {
const r = await fetch('/api/dashboard/change-password', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword: cur, newPassword: nw })
});
if (r.ok) {
msg.textContent = 'Password changed'; msg.className = 'save-msg';
document.getElementById('pwCurrent').value = '';
document.getElementById('pwNew').value = '';
} else {
const e = await r.json();
msg.textContent = e.error || 'Error'; msg.className = 'save-msg err';
}
setTimeout(() => msg.textContent = '', 4000);
} catch { msg.textContent = 'Error'; msg.className = 'save-msg err'; }
});
// ── Scroll shadow ─────────────────────────────────────────────────────────────
window.addEventListener('scroll', () => {
document.getElementById('pageHeader').classList.toggle('scrolled', window.scrollY > 5);
}, { passive: true });
// ── Init ──────────────────────────────────────────────────────────────────────
checkKeyStatus();
loadThresholds();
loadStationName();
loadLocation();
</script>
</body>
</html>
+2 -376
View File
@@ -141,45 +141,13 @@ a { color: var(--cool); text-decoration: none; }
/* ── No-data ── */
.no-data { display: flex; align-items: center; justify-content: center; height: 180px; font-size: 0.85rem; color: var(--ink-light); }
/* ── Settings panel ── */
/* ── Settings panel (collapsible sections) ── */
.settings-panel { margin-top: 2.5rem; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
.settings-panel summary { padding: 0.85rem 1.2rem; cursor: pointer; font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-light); background: var(--paper-dark); list-style: none; display: flex; align-items: center; gap: 0.5rem; user-select: none; }
.settings-panel summary::before { content: '›'; display: inline-block; transition: transform 0.2s; }
.settings-panel[open] summary::before { transform: rotate(90deg); }
.settings-panel summary::-webkit-details-marker { display: none; }
.settings-body { padding: 1.2rem; background: var(--paper-dark); border-top: 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; } }
.settings-body label { display: block; font-size: 0.8rem; color: var(--ink-light); margin-bottom: 0.3rem; }
.settings-body input { 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); }
.settings-body input:focus { outline: 2px solid var(--accent); }
.settings-actions { display: flex; align-items: center; gap: 1rem; margin-top: 0.75rem; }
.settings-body button {
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);
}
.settings-body button:hover {
opacity: 0.88;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent);
}
.settings-body button:active { transform: scale(0.95); box-shadow: none; }
.save-msg { font-size: 0.85rem; color: var(--cool); }
.save-msg.err { color: var(--accent); }
.keys-status { font-size: 0.85rem; margin-bottom: 0.25rem; }
.keys-status.ok { color: var(--cool); }
.keys-status.missing { color: var(--accent); }
.keys-hint { font-size: 0.78rem; color: var(--ink-light); margin-top: 1rem; line-height: 1.5; }
.settings-divider { border: none; border-top: 1px solid var(--border); margin: 1.25rem 0; }
/* Records */
.records-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.6rem; }
.record-item { background: var(--paper); border: 1px solid var(--border); border-radius: 8px; padding: 0.6rem 0.85rem; }
@@ -189,16 +157,6 @@ a { color: var(--cool); text-decoration: none; }
/* Anomaly tile */
.stat.anomaly-above { border-color: var(--accent); }
.stat.anomaly-below { border-color: var(--cool); }
/* Export link */
.export-link { display: inline-block; padding: 0.45rem 1.2rem; background: var(--cool); color: #fff !important; border-radius: 8px; font-size: 0.9rem; font-weight: 600; text-decoration: none !important; }
.export-link:hover { opacity: 0.88; }
.backfill-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.backfill-controls { display: flex; gap: 0.5rem; align-items: center; flex-shrink: 0; }
.backfill-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; }
.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; }
.backfill-progress { margin-top: 0.75rem; }
/* NWS alert cards */
.alert-card { border-radius: 10px; padding: 0.6rem 1rem; margin-bottom: 0.5rem; font-size: 0.85rem; border: 1px solid; }
.alert-card.sev-extreme { background: rgba(120,0,0,0.12); border-color: #a00; color: var(--ink); }
@@ -243,11 +201,6 @@ a { color: var(--cool); text-decoration: none; }
.ah-body { color: var(--ink-light); font-size: 0.78rem; margin-top: 0.1rem; line-height: 1.4; }
.ah-empty { padding: 1.5rem; text-align: center; color: var(--ink-light); font-size: 0.85rem; }
/* Settings sections */
.settings-section-hdr { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--ink-light); margin: 1.25rem 0 0.6rem; padding-bottom: 0.4rem; border-bottom: 1px solid var(--border); }
.threshold-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
@media (max-width: 480px) { .threshold-row { grid-template-columns: 1fr; } }
.hidden { display: none !important; }
/* ── Section cards ── */
@@ -371,6 +324,7 @@ canvas.sparkline { display: block; width: 100%; height: 22px; margin-top: 0.4rem
<span class="station-name" id="stationName"></span>
<a href="/conditions" class="logout">Conditions</a>
<a href="/" class="logout">← Weather</a>
<a href="/dashboard/settings" class="logout">Settings</a>
<a href="/dashboard/logout" class="logout">Sign out</a>
</div>
</div>
@@ -562,125 +516,6 @@ canvas.sparkline { display: block; width: 100%; height: 22px; margin-top: 0.4rem
</div>
</details>
<!-- Settings -->
<details class="settings-panel" id="settingsPanel">
<summary>Station API Keys</summary>
<div class="settings-body">
<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 id="saveKeysBtn">Save Keys</button>
<span id="saveMsg" class="save-msg"></span>
</div>
<p class="keys-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>
<hr class="settings-divider">
<div class="backfill-header" style="margin-bottom:0.75rem">
<div>
<div class="chart-title" style="margin-bottom:0.25rem">Export Data</div>
<p class="keys-hint" style="margin:0">Download your station history as a CSV spreadsheet.</p>
</div>
<div class="backfill-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="settings-divider">
<div class="backfill-section">
<div class="backfill-header">
<div>
<div class="chart-title" style="margin-bottom:0.25rem">Import Historical Data</div>
<p class="keys-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="backfill-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 id="backfillBtn">Import</button>
</div>
</div>
<div id="backfillProgress" class="backfill-progress" 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>
<hr class="settings-divider">
<div class="settings-section-hdr">Alert Thresholds (Station)</div>
<p class="keys-hint" style="margin:0 0 0.75rem">Leave blank to disable. These extend your existing push/ntfy alert 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 id="saveThreshBtn">Save Thresholds</button>
<span id="threshMsg" class="save-msg"></span>
</div>
<hr class="settings-divider">
<div class="settings-section-hdr">Station Display</div>
<div style="margin-bottom:0.75rem">
<label>Station Name (shown on public conditions page)</label>
<input id="inputStationName" type="text" placeholder="e.g. Genesis 8:22" maxlength="60" autocomplete="off">
</div>
<div class="settings-actions">
<button id="saveNameBtn">Save Name</button>
<span id="nameMsg" class="save-msg"></span>
</div>
<hr class="settings-divider">
<div class="settings-section-hdr">Station Location (for NWS Alerts)</div>
<p class="keys-hint" style="margin:0 0 0.75rem">Used to fetch active NWS weather alerts for your area. If you have push notifications enabled, your saved 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 id="saveLocBtn">Save Location</button>
<span id="locMsg" class="save-msg"></span>
</div>
<hr class="settings-divider">
<div class="settings-section-hdr">Change Password</div>
<div class="settings-grid">
<div><label>Current Password</label><input id="pwCurrent" type="password" autocomplete="current-password"></div>
<div><label>New Password (min 6 chars)</label><input id="pwNew" type="password" autocomplete="new-password"></div>
</div>
<div class="settings-actions">
<button id="changePwBtn">Change Password</button>
<span id="pwMsg" class="save-msg"></span>
</div>
</div>
</details>
</div>
<script>
@@ -1135,106 +970,6 @@ document.getElementById('refreshBtn').addEventListener('click', () => {
setInterval(updateLatest, 120000);
setInterval(() => loadHistory(currentRange), 120000);
// ── API key settings ─────────────────────────────────────────────────────────
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';
document.getElementById('settingsPanel').open = true;
}
} 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('Save failed');
msg.textContent = 'Saved! Data will start recording on the next poll.';
msg.className = 'save-msg';
document.getElementById('inputApiKey').value = '';
document.getElementById('inputAppKey').value = '';
checkKeyStatus();
} catch (e) {
msg.textContent = 'Error saving keys.';
msg.className = 'save-msg err';
}
});
// ── 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. Refresh charts to see the data.`;
document.getElementById('progressBar').style.width = '100%';
clearInterval(backfillPoller);
backfillPoller = null;
document.getElementById('backfillBtn').disabled = false;
document.getElementById('backfillBtn').textContent = 'Import';
loadHistory(currentRange);
}
} catch {}
}
document.getElementById('backfillBtn').addEventListener('click', startBackfill);
// ── Pressure trend ────────────────────────────────────────────────────────────
async function updatePressureTrend() {
try {
@@ -1359,15 +1094,6 @@ const _origLoadHistory = loadHistory;
// Override renderCharts to accept yoy
const _origRenderCharts = renderCharts;
// ── CSV 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();
// ── Trend arrows ─────────────────────────────────────────────────────────────
async function updateTrends() {
try {
@@ -1489,74 +1215,6 @@ if (heatmapPanel) {
}, { once: false });
}
// ── 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'; }
});
// ── Change password ───────────────────────────────────────────────────────────
document.getElementById('changePwBtn')?.addEventListener('click', async () => {
const cur = document.getElementById('pwCurrent')?.value;
const nw = document.getElementById('pwNew')?.value;
const msg = document.getElementById('pwMsg');
if (!cur || !nw || nw.length < 6) { msg.textContent = 'Fill both fields (min 6 chars)'; msg.className = 'save-msg err'; return; }
try {
const r = await fetch('/api/dashboard/change-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPassword: cur, newPassword: nw }) });
if (r.ok) { msg.textContent = 'Password changed'; msg.className = 'save-msg'; document.getElementById('pwCurrent').value = ''; document.getElementById('pwNew').value = ''; }
else { const e = await r.json(); msg.textContent = e.error || 'Error'; msg.className = 'save-msg err'; }
setTimeout(() => msg.textContent = '', 4000);
} catch { msg.textContent = 'Error'; 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 — applies to all your subscriptions'; 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'; }
});
// ── Tile color coding ─────────────────────────────────────────────────────────
function tempColorClass(t) {
if (t == null) return '';
@@ -1707,34 +1365,6 @@ async function loadStreak() {
} catch {}
}
// ── 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'; loadAlerts(); }
else { msg.textContent = 'Error'; msg.className = 'save-msg err'; }
setTimeout(() => msg.textContent = '', 3000);
} catch { msg.textContent = 'Error'; msg.className = 'save-msg err'; }
});
// ── Alert History ─────────────────────────────────────────────────────────────
const TAG_LABELS = {
'nws': 'NWS Alert',
@@ -1818,12 +1448,8 @@ updateExtStats();
updateTrends();
loadRecords();
loadHistory(currentRange);
checkKeyStatus();
loadStationName();
loadThresholds();
loadAlerts();
loadStreak();
loadLocation();
setInterval(updatePressureTrend, 120000);
setInterval(updateExtStats, 300000);
setInterval(updateTrends, 120000);
+5
View File
@@ -959,6 +959,11 @@ app.get('/dashboard', requireDashboardAuth, (_req, res) => {
res.sendFile(path.join(__dirname, 'public', 'dashboard.html'));
});
// Settings page
app.get('/dashboard/settings', requireDashboardAuth, (_req, res) => {
res.sendFile(path.join(__dirname, 'public', 'dashboard-settings.html'));
});
// Station history API
app.get('/api/station/history', requireDashboardAuth, (req, res) => {
const rangeMap = { '1h': 3600000, '6h': 21600000, '24h': 86400000, '7d': 604800000, '30d': 2592000000, '90d': 7776000000, '1y': 31536000000, '2y': 63072000000, '3y': 94608000000 };