Publish Container / publish (push) Successful in 2m51s
- Install banner (phones), Settings guide and dialog with iPhone/iPad and Android steps, native Install button where supported; steps also on the dashboard Account page - apple-touch-icon now a PNG (iOS ignores SVG icons) - Shared pwa-safe.css: dark cap behind the status bar/Dynamic Island in the installed app, inset-aware offline banner, pull-to-refresh, bottom bars, full-screen map, dialogs - Landscape-aware content gutters; measured top-bar height; viewport-fit=cover on every page Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
429 lines
21 KiB
HTML
429 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, viewport-fit=cover">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
<link rel="stylesheet" href="/pwa-safe.css">
|
|
<title>Station Settings</title>
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
:root {
|
|
--ink: #0d1b2a; --ink-mid: #2b3f55; --ink-light: #5a7089;
|
|
--paper: #edf2f7; --paper-dark: #ffffff; --paper-darker: #e3eaf2;
|
|
--border: #ccd6e2; --accent: #2563eb; --cool: #0e7490;
|
|
--mono: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', sans-serif;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:root {
|
|
--ink: #dce6f0; --ink-mid: #b7c6d6; --ink-light: #8aa2b8;
|
|
--paper: #070c14; --paper-dark: #0d1720; --paper-darker: #101c29;
|
|
--border: #22405c; --accent: #60a5fa; --cool: #38bdf8;
|
|
}
|
|
}
|
|
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: calc(1rem + env(safe-area-inset-top, 0px)) calc(1.5rem + env(safe-area-inset-right, 0px)) 1rem calc(1.5rem + env(safe-area-inset-left, 0px)); 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 & 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 & 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>
|