563 lines
13 KiB
Svelte
563 lines
13 KiB
Svelte
<script>
|
|
import { app } from './lib/stores/app.svelte.js'
|
|
import { notifications } from './lib/stores/notifications.svelte.js'
|
|
import LocationSidebar from './components/LocationSidebar.svelte'
|
|
import CurrentWeather from './components/CurrentWeather.svelte'
|
|
import HourlyForecast from './components/HourlyForecast.svelte'
|
|
import DailyForecast from './components/DailyForecast.svelte'
|
|
import WeatherDetail from './components/WeatherDetail.svelte'
|
|
import PrecipitationRadar from './components/PrecipitationRadar.svelte'
|
|
import NotificationBell from './components/NotificationBell.svelte'
|
|
import SettingsDialog from './components/SettingsDialog.svelte'
|
|
import AddLocationDialog from './components/AddLocationDialog.svelte'
|
|
|
|
let viewMode = $state('current') // 'current' | 'hourly' | 'daily' | 'detail' | 'radar'
|
|
let initDone = $state(false)
|
|
let geoError = $state('')
|
|
|
|
// Initialize app on mount
|
|
$effect(() => {
|
|
initApp()
|
|
})
|
|
|
|
async function initApp() {
|
|
await app.init()
|
|
|
|
// If no locations exist, try to geolocate
|
|
if (app.locations.length === 0) {
|
|
await tryGeolocation()
|
|
}
|
|
|
|
// Trigger alert analysis after initial forecast
|
|
if (app.forecastData) {
|
|
notifications.analyze()
|
|
}
|
|
|
|
initDone = true
|
|
}
|
|
|
|
// Re-analyze alerts whenever forecast data changes
|
|
$effect(() => {
|
|
if (app.forecastData && app.settings.alertsEnabled) {
|
|
notifications.analyze()
|
|
}
|
|
})
|
|
|
|
// Restart the auto-refresh timer whenever the interval preference changes
|
|
$effect(() => {
|
|
app.settings.refreshInterval
|
|
app.startAutoRefresh()
|
|
})
|
|
|
|
async function tryGeolocation() {
|
|
if (!navigator.geolocation) {
|
|
geoError = 'Geolocation not supported. Add a location to get started.'
|
|
return
|
|
}
|
|
|
|
try {
|
|
const position = await new Promise((resolve, reject) => {
|
|
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
|
enableHighAccuracy: true,
|
|
timeout: 10000,
|
|
maximumAge: 600000, // 10 minutes
|
|
})
|
|
})
|
|
|
|
const { latitude, longitude } = position.coords
|
|
const { addLocation, setCurrentLocation } = await import('./lib/storage/db.js')
|
|
|
|
const id = await addLocation({
|
|
name: 'Current Location',
|
|
lat: Math.round(latitude * 10000) / 10000,
|
|
lon: Math.round(longitude * 10000) / 10000,
|
|
isCurrent: true,
|
|
order: 0,
|
|
})
|
|
|
|
await setCurrentLocation(id)
|
|
await app.refreshLocations()
|
|
app.selectedLocationId = id
|
|
await app.refreshForecast()
|
|
} catch (e) {
|
|
geoError = 'Location access denied. Add a location manually.'
|
|
}
|
|
}
|
|
|
|
async function handleAddLocation(location) {
|
|
const { addLocation } = await import('./lib/storage/db.js')
|
|
await addLocation({
|
|
name: location.name,
|
|
lat: location.latitude,
|
|
lon: location.longitude,
|
|
isCurrent: false,
|
|
})
|
|
await app.refreshLocations()
|
|
app.showAddLocation = false
|
|
app.geocodingResults = []
|
|
|
|
// Select the newly added location
|
|
const updated = await import('./lib/storage/db.js').then(m => m.getLocations())
|
|
const newest = updated[updated.length - 1]
|
|
if (newest) {
|
|
app.selectLocation(newest.id)
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="app-shell">
|
|
<!-- Mobile header -->
|
|
<div class="mobile-header">
|
|
<button
|
|
class="btn-icon"
|
|
onclick={() => app.sidebarOpen = !app.sidebarOpen}
|
|
aria-label="Toggle menu"
|
|
>
|
|
{app.sidebarOpen ? '✕' : '☰'}
|
|
</button>
|
|
<span class="mobile-title">
|
|
{app.selectedLocation?.name || 'WeatherLens'}
|
|
</span>
|
|
<button class="btn-icon" onclick={() => app.showSettings = true} aria-label="Settings" title="Settings">
|
|
⚙️
|
|
</button>
|
|
<NotificationBell />
|
|
</div>
|
|
|
|
<!-- Sidebar overlay for mobile -->
|
|
{#if app.sidebarOpen}
|
|
<button
|
|
class="sidebar-overlay"
|
|
onclick={() => app.sidebarOpen = false}
|
|
aria-label="Close sidebar"
|
|
></button>
|
|
{/if}
|
|
|
|
<!-- Sidebar -->
|
|
<aside class="sidebar" class:open={app.sidebarOpen}>
|
|
<LocationSidebar />
|
|
</aside>
|
|
|
|
<!-- Main content -->
|
|
<main class="main-content">
|
|
<!-- Desktop header -->
|
|
<div class="top-bar">
|
|
<div class="top-bar-title">
|
|
<h1>{app.selectedLocation?.name || 'WeatherLens'}</h1>
|
|
{#if app.selectedLocation?.isCurrent}
|
|
<span class="current-badge">📍 Current</span>
|
|
{/if}
|
|
</div>
|
|
<div class="top-bar-actions">
|
|
<NotificationBell />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Error banner -->
|
|
{#if app.error}
|
|
<div class="error-banner animate-slide-down">
|
|
<span>⚠️ {app.error}</span>
|
|
<button class="btn-icon" onclick={() => app.error = ''} aria-label="Dismiss">✕</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Geolocation error on first load -->
|
|
{#if geoError && !app.forecastData}
|
|
<div class="empty-state">
|
|
<div class="empty-icon">🌍</div>
|
|
<h2>Welcome to WeatherLens</h2>
|
|
<p class="text-muted">{geoError}</p>
|
|
<button class="btn btn-primary mt-3" onclick={() => app.showAddLocation = true}>+ Add a Location</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Loading state -->
|
|
{#if app.loading && !app.forecastData}
|
|
<div class="loading-state">
|
|
<div class="loading-spinner">⏳</div>
|
|
<p class="text-muted mt-2">Loading weather data...</p>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Weather content -->
|
|
{#if app.forecastData && app.selectedLocation}
|
|
<!-- Mobile bottom nav -->
|
|
<div class="mobile-nav">
|
|
<button
|
|
class="nav-btn"
|
|
class:active={viewMode === 'current'}
|
|
onclick={() => viewMode = 'current'}
|
|
>Now</button>
|
|
<button
|
|
class="nav-btn"
|
|
class:active={viewMode === 'hourly'}
|
|
onclick={() => viewMode = 'hourly'}
|
|
>Hourly</button>
|
|
<button
|
|
class="nav-btn"
|
|
class:active={viewMode === 'daily'}
|
|
onclick={() => viewMode = 'daily'}
|
|
>Daily</button>
|
|
<button
|
|
class="nav-btn"
|
|
class:active={viewMode === 'detail'}
|
|
onclick={() => viewMode = 'detail'}
|
|
>More</button>
|
|
<button
|
|
class="nav-btn"
|
|
class:active={viewMode === 'radar'}
|
|
onclick={() => viewMode = 'radar'}
|
|
>Radar</button>
|
|
</div>
|
|
|
|
<!-- Desktop tab bar -->
|
|
<div class="desktop-tabs">
|
|
<button class="tab-btn" class:active={viewMode === 'current'} onclick={() => viewMode = 'current'}>Current</button>
|
|
<button class="tab-btn" class:active={viewMode === 'hourly'} onclick={() => viewMode = 'hourly'}>Hourly</button>
|
|
<button class="tab-btn" class:active={viewMode === 'daily'} onclick={() => viewMode = 'daily'}>7-Day Forecast</button>
|
|
<button class="tab-btn" class:active={viewMode === 'detail'} onclick={() => viewMode = 'detail'}>Details</button>
|
|
<button class="tab-btn" class:active={viewMode === 'radar'} onclick={() => viewMode = 'radar'}>Radar</button>
|
|
</div>
|
|
|
|
<!-- Content area -->
|
|
<div class="content-area">
|
|
{#if viewMode === 'current'}
|
|
<div class="animate-fade-in">
|
|
<CurrentWeather />
|
|
</div>
|
|
{:else if viewMode === 'hourly'}
|
|
<div class="animate-fade-in">
|
|
<HourlyForecast />
|
|
</div>
|
|
{:else if viewMode === 'daily'}
|
|
<div class="animate-fade-in">
|
|
<DailyForecast />
|
|
</div>
|
|
{:else if viewMode === 'detail'}
|
|
<div class="animate-fade-in">
|
|
<WeatherDetail />
|
|
</div>
|
|
{:else if viewMode === 'radar'}
|
|
<div class="animate-fade-in">
|
|
<PrecipitationRadar />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</main>
|
|
|
|
<!-- Settings dialog -->
|
|
{#if app.showSettings}
|
|
<SettingsDialog onClose={() => app.showSettings = false} />
|
|
{/if}
|
|
|
|
<!-- Add location dialog -->
|
|
{#if app.showAddLocation}
|
|
<AddLocationDialog
|
|
onClose={() => {
|
|
app.showAddLocation = false
|
|
app.geocodingResults = []
|
|
}}
|
|
onAdd={handleAddLocation}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.app-shell {
|
|
display: flex;
|
|
min-height: 100vh;
|
|
min-height: 100dvh;
|
|
max-width: 100vw;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
/* Mobile header */
|
|
.mobile-header {
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: max(8px, var(--safe-top)) 8px 8px max(12px, var(--safe-top));
|
|
background: var(--color-surface);
|
|
border-bottom: 1px solid var(--color-border);
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 100;
|
|
}
|
|
|
|
.mobile-title {
|
|
font-weight: 700;
|
|
font-size: 0.95rem;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
flex: 1;
|
|
margin: 0 8px;
|
|
}
|
|
|
|
/* Sidebar */
|
|
.sidebar {
|
|
width: 280px;
|
|
min-width: 280px;
|
|
background: var(--color-surface);
|
|
border-right: 1px solid var(--color-border);
|
|
height: 100vh;
|
|
height: 100dvh;
|
|
position: sticky;
|
|
top: 0;
|
|
overflow-y: auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
z-index: 60;
|
|
}
|
|
|
|
.sidebar-overlay {
|
|
display: none;
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
z-index: 59;
|
|
border: none;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
margin: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
/* Main content */
|
|
.main-content {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 0;
|
|
min-height: 100vh;
|
|
min-height: 100dvh;
|
|
}
|
|
|
|
.top-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 12px 20px;
|
|
background: var(--color-surface);
|
|
border-bottom: 1px solid var(--color-border);
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 50;
|
|
}
|
|
|
|
.top-bar-title {
|
|
flex: 1;
|
|
min-width: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.top-bar-title h1 {
|
|
font-size: 1.1rem;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.current-badge {
|
|
font-size: 0.75rem;
|
|
background: rgba(56, 189, 248, 0.15);
|
|
color: var(--color-primary);
|
|
padding: 2px 8px;
|
|
border-radius: 12px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.top-bar-actions {
|
|
display: flex;
|
|
gap: 6px;
|
|
align-items: center;
|
|
}
|
|
|
|
/* Error banner */
|
|
.error-banner {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin: 8px 16px;
|
|
padding: 10px 14px;
|
|
background: rgba(239, 68, 68, 0.1);
|
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
|
border-radius: var(--radius-md);
|
|
color: #fca5a5;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
/* Empty state */
|
|
.empty-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex: 1;
|
|
padding: 40px 20px;
|
|
text-align: center;
|
|
}
|
|
|
|
.empty-icon {
|
|
font-size: 4rem;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.empty-state h2 {
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
/* Loading state */
|
|
.loading-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex: 1;
|
|
padding: 60px 20px;
|
|
}
|
|
|
|
.loading-spinner {
|
|
font-size: 3rem;
|
|
animation: spin 1.5s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
from { transform: rotate(0deg); }
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
/* Desktop tabs */
|
|
.desktop-tabs {
|
|
display: flex;
|
|
gap: 0;
|
|
padding: 0 20px;
|
|
border-bottom: 1px solid var(--color-border);
|
|
background: var(--color-bg);
|
|
}
|
|
|
|
.tab-btn {
|
|
padding: 10px 16px;
|
|
border: none;
|
|
border-bottom: 2px solid transparent;
|
|
background: transparent;
|
|
color: var(--color-text-muted);
|
|
font-size: 0.85rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: color var(--transition), border-color var(--transition);
|
|
}
|
|
|
|
.tab-btn:hover {
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.tab-btn.active {
|
|
color: var(--color-primary);
|
|
border-bottom-color: var(--color-primary);
|
|
}
|
|
|
|
/* Mobile bottom nav */
|
|
.mobile-nav {
|
|
display: none;
|
|
position: sticky;
|
|
top: 52px;
|
|
z-index: 90;
|
|
padding: 4px 12px;
|
|
gap: 2px;
|
|
background: var(--color-surface);
|
|
border-bottom: 1px solid var(--color-border);
|
|
}
|
|
|
|
.nav-btn {
|
|
flex: 1;
|
|
padding: 8px 4px;
|
|
border: none;
|
|
border-radius: var(--radius-md);
|
|
background: transparent;
|
|
color: var(--color-text-muted);
|
|
font-size: 0.78rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: background-color var(--transition), color var(--transition);
|
|
white-space: nowrap;
|
|
text-align: center;
|
|
}
|
|
|
|
.nav-btn.active {
|
|
background: rgba(56, 189, 248, 0.12);
|
|
color: var(--color-primary);
|
|
}
|
|
|
|
.content-area {
|
|
flex: 1;
|
|
padding: 16px;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
min-width: 0;
|
|
}
|
|
|
|
/* Responsive */
|
|
@media (max-width: 1023px) {
|
|
.app-shell {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.mobile-header {
|
|
display: flex;
|
|
}
|
|
|
|
.top-bar {
|
|
display: none;
|
|
}
|
|
|
|
.sidebar {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
bottom: 0;
|
|
z-index: 60;
|
|
display: none;
|
|
transition: none;
|
|
width: min(300px, 85vw);
|
|
min-width: 0;
|
|
height: 100dvh;
|
|
padding-top: var(--safe-top);
|
|
}
|
|
|
|
.sidebar.open {
|
|
display: flex;
|
|
}
|
|
|
|
.sidebar-overlay {
|
|
display: block;
|
|
}
|
|
|
|
.desktop-tabs {
|
|
display: none;
|
|
}
|
|
|
|
.mobile-nav {
|
|
display: flex;
|
|
}
|
|
|
|
.content-area {
|
|
padding: 8px 8px max(16px, var(--safe-bottom));
|
|
}
|
|
|
|
.error-banner {
|
|
margin: 4px 8px;
|
|
}
|
|
}
|
|
|
|
@media (min-width: 1024px) {
|
|
.mobile-nav {
|
|
display: none;
|
|
}
|
|
}
|
|
</style>
|