weather_app/src/App.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>