weather_app/src/App.svelte
hermes-explorigin cc6b573748 Add swipe-to-switch between tabs on mobile
Touch swipe on the content area moves between tabs (left = next, right =
previous), gated to non-radar views so map pan gestures keep working.
Vertical scroll with |dx|<|dy| is untouched; 60px horizontal threshold.
2026-08-30 14:05:08 +00:00

614 lines
16 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'
import { parseHash, viewToHash, VALID_VIEWS } from './lib/router.js'
// Hash-routable tab: initialize from the URL hash so a shared link like
// #/radar opens straight to that tab.
let viewMode = $state(parseHash(typeof window !== 'undefined' ? window.location.hash : ''))
/** Switch the active tab and reflect it in the URL hash. */
function setView(v) {
viewMode = v
if (typeof window !== 'undefined') {
window.location.hash = viewToHash(v)
}
}
// Keep viewMode in sync with the hash — covers browser back/forward and a
// manually edited URL. Guarded so we never re-enter on the same value.
$effect(() => {
if (typeof window === 'undefined') return
const onHash = () => {
const v = parseHash(window.location.hash)
if (v !== viewMode) viewMode = v
}
window.addEventListener('hashchange', onHash)
return () => window.removeEventListener('hashchange', onHash)
})
// --- Mobile swipe-to-switch tabs ---
// Horizontal swipe on the content area moves between tabs (touch only).
// Gated to non-radar views: the radar map handles its own pan gestures via
// pointer events, so a swipe there should pan the map, not switch tabs.
const SWIPE_THRESHOLD = 60 // px of horizontal travel before it counts as a swipe
let touchAnchor = $state(null)
function onSwipeStart(e) {
if (viewMode === 'radar' || !e.touches || !e.touches[0]) return
touchAnchor = { x: e.touches[0].clientX, y: e.touches[0].clientY }
}
function onSwipeEnd(e) {
if (viewMode === 'radar' || !touchAnchor || !e.changedTouches || !e.changedTouches[0]) return
const t = e.changedTouches[0]
const dx = t.clientX - touchAnchor.x
const dy = t.clientY - touchAnchor.y
touchAnchor = null
// Require a mostly-horizontal swipe past the threshold so vertical
// scrolling stays untouched.
if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dx) < Math.abs(dy)) return
const idx = VALID_VIEWS.indexOf(viewMode)
const step = dx < 0 ? 1 : -1 // left swipe = next tab, right = previous
const next = VALID_VIEWS[Math.min(Math.max(idx + step, 0), VALID_VIEWS.length - 1)]
if (next !== viewMode) setView(next)
}
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={() => setView('current')}
>Now</button>
<button
class="nav-btn"
class:active={viewMode === 'hourly'}
onclick={() => setView('hourly')}
>Hourly</button>
<button
class="nav-btn"
class:active={viewMode === 'daily'}
onclick={() => setView('daily')}
>Daily</button>
<button
class="nav-btn"
class:active={viewMode === 'detail'}
onclick={() => setView('detail')}
>More</button>
<button
class="nav-btn"
class:active={viewMode === 'radar'}
onclick={() => setView('radar')}
>Radar</button>
</div>
<!-- Desktop tab bar -->
<div class="desktop-tabs">
<button class="tab-btn" class:active={viewMode === 'current'} onclick={() => setView('current')}>Current</button>
<button class="tab-btn" class:active={viewMode === 'hourly'} onclick={() => setView('hourly')}>Hourly</button>
<button class="tab-btn" class:active={viewMode === 'daily'} onclick={() => setView('daily')}>7-Day Forecast</button>
<button class="tab-btn" class:active={viewMode === 'detail'} onclick={() => setView('detail')}>Details</button>
<button class="tab-btn" class:active={viewMode === 'radar'} onclick={() => setView('radar')}>Radar</button>
</div>
<!-- Content area -->
<div class="content-area" ontouchstart={onSwipeStart} ontouchend={onSwipeEnd}>
{#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>