- manifest.webmanifest + apple/maskable PNG icons; relative paths so the app works both at dev root and under /weather - plain-JS service worker (sw.js) with notificationclick/push handlers and an SW-mediated showNotification() path for native Android notifications - src/lib/pwa.js bootstraps registration and routes alerts to the SW - Every alert kind (storm/precip/heat/cold/uv/wind) is now a per-type preference toggle in Settings; removed the old alertThresholds.thunderstorm boolean in favor of notificationTypes.storm - Store dedups native notifications per forecast day and fires best-effort native notifications for newly-appeared alerts - inline-assets keeps manifest/sw/icons in dist; deploy.yml uploads + verifies all PWA files via WebDAV
424 lines
12 KiB
Svelte
424 lines
12 KiB
Svelte
<script>
|
|
import { app } from '../lib/stores/app.svelte.js'
|
|
import { saveSettings } from '../lib/storage/db.js'
|
|
|
|
let { onClose } = $props()
|
|
|
|
// Human labels for each notifiable alert type (used for the per-type toggles).
|
|
const TYPE_LABELS = {
|
|
storm: 'Severe weather / storms',
|
|
precip: 'Rain chance',
|
|
heat: 'High heat',
|
|
cold: 'Cold snap',
|
|
uv: 'High UV index',
|
|
wind: 'Strong winds',
|
|
}
|
|
|
|
let localSettings = $state({
|
|
...app.settings,
|
|
alertThresholds: { ...app.settings.alertThresholds },
|
|
notificationTypes: { ...app.settings.notificationTypes },
|
|
})
|
|
let saving = $state(false)
|
|
|
|
async function handleSave() {
|
|
saving = true
|
|
try {
|
|
app.settings = { ...localSettings }
|
|
await saveSettings({
|
|
...localSettings,
|
|
alertThresholds: { ...localSettings.alertThresholds },
|
|
notificationTypes: { ...localSettings.notificationTypes },
|
|
})
|
|
// Refresh forecast with new unit if it changed
|
|
await app.refreshForecast()
|
|
onClose()
|
|
} catch (e) {
|
|
console.error('Failed to save settings:', e)
|
|
} finally {
|
|
saving = false
|
|
}
|
|
}
|
|
|
|
function handleReset() {
|
|
if (confirm('Reset all locations and stored data?')) {
|
|
import('../lib/storage/db.js').then(async (db) => {
|
|
await db.clearAllLocations()
|
|
app.locations = []
|
|
app.selectedLocationId = null
|
|
app.forecastData = null
|
|
onClose()
|
|
})
|
|
}
|
|
}
|
|
|
|
function handleBackdrop(e) {
|
|
if (e.target === e.currentTarget) onClose()
|
|
}
|
|
</script>
|
|
|
|
<div class="modal-overlay" onclick={handleBackdrop} role="presentation">
|
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
<div
|
|
class="modal"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Settings"
|
|
tabindex="-1"
|
|
onclick={(e) => e.stopPropagation()}
|
|
>
|
|
<div class="modal-header">
|
|
<h3>⚙️ Settings</h3>
|
|
<button class="btn-icon" onclick={onClose} aria-label="Close">✕</button>
|
|
</div>
|
|
|
|
<div class="settings-body">
|
|
<!-- Data source -->
|
|
<div class="settings-group">
|
|
<h4>Data source</h4>
|
|
<div class="setting-row">
|
|
<div class="setting-col">
|
|
<span>Weather provider</span>
|
|
<span class="setting-help">NWS is US-only; falls back to Open-Meteo if unavailable.</span>
|
|
</div>
|
|
<select
|
|
value={localSettings.source || 'open-meteo'}
|
|
onchange={(e) => localSettings.source = e.target.value}
|
|
>
|
|
<option value="open-meteo">Open-Meteo (global)</option>
|
|
<option value="nws">National Weather Service (US)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Auto-refresh -->
|
|
<div class="settings-group">
|
|
<h4>Auto-refresh</h4>
|
|
<div class="setting-row">
|
|
<div class="setting-col">
|
|
<span>Refresh interval</span>
|
|
<span class="setting-help">Automatically fetch updated weather on a schedule. Turn off to refresh only when you open the app.</span>
|
|
</div>
|
|
<select
|
|
value={String(localSettings.refreshInterval || 0)}
|
|
onchange={(e) => localSettings.refreshInterval = parseInt(e.target.value)}
|
|
>
|
|
<option value="0">Off</option>
|
|
<option value="5">Every 5 minutes</option>
|
|
<option value="10">Every 10 minutes</option>
|
|
<option value="15">Every 15 minutes</option>
|
|
<option value="30">Every 30 minutes</option>
|
|
<option value="60">Every hour</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Units -->
|
|
<div class="settings-group">
|
|
<h4>Units</h4>
|
|
<div class="setting-row">
|
|
<span>Measurement system</span>
|
|
<select
|
|
value={localSettings.units}
|
|
onchange={(e) => localSettings.units = e.target.value}
|
|
>
|
|
<option value="metric">Metric (°C, km/h)</option>
|
|
<option value="imperial">Imperial (°F, mph)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Alerts -->
|
|
<div class="settings-group">
|
|
<h4>Weather Alerts</h4>
|
|
<div class="setting-row">
|
|
<span>Enable Alerts</span>
|
|
<label class="toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={localSettings.alertsEnabled}
|
|
onchange={(e) => localSettings.alertsEnabled = e.target.checked}
|
|
/>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="setting-row">
|
|
<div class="setting-col">
|
|
<span>Notify on each alert type</span>
|
|
<span class="setting-help">Turn off a type to fully suppress that alert (banner and notification).</span>
|
|
</div>
|
|
</div>
|
|
|
|
{#if localSettings.alertsEnabled}
|
|
<div class="setting-row">
|
|
<span>Rain threshold</span>
|
|
<select
|
|
value={String(localSettings.alertThresholds.precip)}
|
|
onchange={(e) => localSettings.alertThresholds.precip = parseInt(e.target.value)}
|
|
>
|
|
<option value="50">50%</option>
|
|
<option value="60">60%</option>
|
|
<option value="70">70%</option>
|
|
<option value="80">80%</option>
|
|
<option value="90">90%</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="setting-row">
|
|
<span>Wind gust threshold</span>
|
|
<select
|
|
value={String(localSettings.alertThresholds.windGust)}
|
|
onchange={(e) => localSettings.alertThresholds.windGust = parseInt(e.target.value)}
|
|
>
|
|
<option value="30">30 {localSettings.windUnit}</option>
|
|
<option value="40">40 {localSettings.windUnit}</option>
|
|
<option value="50">50 {localSettings.windUnit}</option>
|
|
<option value="60">60 {localSettings.windUnit}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="setting-row">
|
|
<span>UV Index threshold</span>
|
|
<select
|
|
value={String(localSettings.alertThresholds.uvIndex)}
|
|
onchange={(e) => localSettings.alertThresholds.uvIndex = parseInt(e.target.value)}
|
|
>
|
|
<option value="4">4+ (Moderate)</option>
|
|
<option value="6">6+ (High)</option>
|
|
<option value="8">8+ (Very High)</option>
|
|
<option value="11">11+ (Extreme)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="setting-row">
|
|
<span>High temp alert</span>
|
|
<select
|
|
value={String(localSettings.alertThresholds.tempHigh)}
|
|
onchange={(e) => localSettings.alertThresholds.tempHigh = parseInt(e.target.value)}
|
|
>
|
|
<option value="30">30°C / 86°F</option>
|
|
<option value="35">35°C / 95°F</option>
|
|
<option value="38">38°C / 100°F</option>
|
|
<option value="42">42°C / 108°F</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="setting-row">
|
|
<span>Low temp alert</span>
|
|
<select
|
|
value={String(localSettings.alertThresholds.tempLow)}
|
|
onchange={(e) => localSettings.alertThresholds.tempLow = parseInt(e.target.value)}
|
|
>
|
|
<option value="5">5°C / 41°F</option>
|
|
<option value="0">0°C / 32°F</option>
|
|
<option value="-5">-5°C / 23°F</option>
|
|
<option value="-10">-10°C / 14°F</option>
|
|
</select>
|
|
</div>
|
|
|
|
{#each ['storm', 'precip', 'heat', 'cold', 'uv', 'wind'] as key}
|
|
<div class="setting-row">
|
|
<span>{TYPE_LABELS[key]}</span>
|
|
<label class="toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={localSettings.notificationTypes[key]}
|
|
onchange={(e) => localSettings.notificationTypes[key] = e.target.checked}
|
|
/>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Danger zone -->
|
|
<div class="settings-group danger-zone">
|
|
<h4>Data</h4>
|
|
<button class="btn btn-danger btn-sm" onclick={handleReset}>
|
|
🗑 Reset All Data
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-footer">
|
|
<button class="btn btn-primary" onclick={handleSave} disabled={saving}>
|
|
{saving ? 'Saving...' : 'Save Settings'}
|
|
</button>
|
|
<button class="btn btn-ghost" onclick={onClose}>Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.modal-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 200;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.modal {
|
|
background: var(--color-surface);
|
|
border: 1px solid var(--color-border-light);
|
|
border-radius: var(--radius-lg);
|
|
padding: 20px;
|
|
max-width: 460px;
|
|
width: 100%;
|
|
box-shadow: var(--shadow-lg);
|
|
max-height: 85vh;
|
|
overflow-y: auto;
|
|
animation: fadeInScale 0.2s ease-out;
|
|
}
|
|
|
|
@keyframes fadeInScale {
|
|
from { opacity: 0; transform: scale(0.95); }
|
|
to { opacity: 1; transform: scale(1); }
|
|
}
|
|
|
|
.modal-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.settings-body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.settings-group {
|
|
padding: 16px;
|
|
background: var(--color-surface-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
}
|
|
|
|
.settings-group h4 {
|
|
font-size: 0.9rem;
|
|
margin-bottom: 12px;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.danger-zone {
|
|
border-color: rgba(239, 68, 68, 0.3);
|
|
}
|
|
|
|
.danger-zone h4 {
|
|
color: var(--color-danger);
|
|
}
|
|
|
|
.setting-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 8px 0;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.setting-row + .setting-row {
|
|
border-top: 1px solid var(--color-border);
|
|
}
|
|
|
|
.setting-col {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
padding-right: 12px;
|
|
}
|
|
|
|
.setting-help {
|
|
font-size: 0.72rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.setting-row span {
|
|
color: var(--color-text);
|
|
}
|
|
|
|
.setting-row select {
|
|
width: auto;
|
|
max-width: 160px;
|
|
padding: 6px 10px;
|
|
font-size: 0.82rem;
|
|
}
|
|
|
|
/* Toggle switch */
|
|
.toggle {
|
|
position: relative;
|
|
display: inline-block;
|
|
width: 44px;
|
|
height: 24px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.toggle input {
|
|
opacity: 0;
|
|
width: 0;
|
|
height: 0;
|
|
}
|
|
|
|
.toggle-slider {
|
|
position: absolute;
|
|
inset: 0;
|
|
background: var(--color-border);
|
|
border-radius: 24px;
|
|
transition: background-color 0.2s;
|
|
}
|
|
|
|
.toggle-slider::before {
|
|
content: '';
|
|
position: absolute;
|
|
height: 18px;
|
|
width: 18px;
|
|
left: 3px;
|
|
bottom: 3px;
|
|
background: white;
|
|
border-radius: 50%;
|
|
transition: transform 0.2s;
|
|
}
|
|
|
|
.toggle input:checked + .toggle-slider {
|
|
background: var(--color-primary);
|
|
}
|
|
|
|
.toggle input:checked + .toggle-slider::before {
|
|
transform: translateX(20px);
|
|
}
|
|
|
|
.modal-footer {
|
|
display: flex;
|
|
gap: 8px;
|
|
padding-top: 4px;
|
|
}
|
|
|
|
@media (max-width: 1023px) {
|
|
.modal {
|
|
max-height: 90dvh;
|
|
max-height: calc(100dvh - 52px);
|
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
|
margin-bottom: 0;
|
|
position: fixed;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
max-width: none;
|
|
padding-bottom: max(20px, var(--safe-bottom));
|
|
animation: slideUp 0.25s ease-out;
|
|
}
|
|
|
|
@keyframes slideUp {
|
|
from { transform: translateY(100%); }
|
|
to { transform: translateY(0); }
|
|
}
|
|
}
|
|
</style>
|