Move all settings to dedicated /dashboard/settings page
Publish Container / publish (push) Successful in 2m22s
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:
1 parent
97dfae86ab
commit
47f60e72f7
3 files changed
+458
-376
No files matched your search
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user