weather_app/src/App.svelte
hermes-explorigin f5488eaa98
All checks were successful
Test, Build & Deploy / test-and-build (push) Successful in 41s
Test, Build & Deploy / deploy (push) Successful in 25s
Live swipe feedback: content tracks finger with destination peek
Replace the instant-jump swipe with a live drag on touch: the current tab
content translates with the finger in real time (translateX driven by
touchmove), a peek chip at the edge shows the destination tab, and on
release the sheet animates to the next tab or snaps back. Horizontal vs
vertical travel is decided after 12px so normal page scrolling is
untouched; touch-action:pan-y keeps horizontal gestures JS-owned. Radar
still pans its map instead of switching tabs.
2026-08-30 19:22:35 +00:00

764 lines
20 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 (live drag + visual feedback) ---
// Horizontal swipe on the content area moves between tabs (touch only). The
// content translates with the finger in real time, showing a "peek" chip of
// the destination, then animates to the next tab (or snaps back) on release.
// 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 = 70 // px of horizontal travel before it counts as a swipe
const AXIS_THRESHOLD = 12 // travel before we decide horizontal vs vertical
const SWIPE_TIMEOUT = 160 // ms for the snap-back / snap-forward animation
const TAB_LABELS = { current: 'Now', hourly: 'Hourly', daily: 'Daily', detail: 'More', radar: 'Radar' }
let contentEl = $state(null) // bound to .content-area, gives travel distance
let swiping = $state(false) // a touch drag is in progress
let swipeAnchor = $state(null) // { x, y } of the starting touch
let swipeDX = $state(0) // live horizontal travel in px (drives the transform)
let swipeLock = $state(null) // 'x' | 'y' | null once the axis is decided
let settling = $state(false) // true while animating on release, enables CSS transition
const clampIdx = (i) => Math.min(Math.max(i, 0), VALID_VIEWS.length - 1)
// The destination view to hint at as the finger drags (only after we've
// committed to a horizontal swipe, so vertical scrolling shows nothing).
const peekView = $derived(
swiping && swipeLock === 'x' && VALID_VIEWS.includes(viewMode)
? VALID_VIEWS[clampIdx(VALID_VIEWS.indexOf(viewMode) + (swipeDX < 0 ? 1 : -1))]
: null
)
function onSwipeStart(e) {
if (viewMode === 'radar' || !e.touches || !e.touches[0]) return
const t = e.touches[0]
swipeAnchor = { x: t.clientX, y: t.clientY }
swipeDX = 0
swipeLock = null
settling = false
swiping = true
}
function onSwipeMove(e) {
if (!swiping || !swipeAnchor) return
const t = e.touches[0]
const dx = t.clientX - swipeAnchor.x
const dy = t.clientY - swipeAnchor.y
// Decide the axis the first time we travel far enough; once locked to Y we
// leave default scrolling alone and never pop the peek nipple.
if (!swipeLock) {
if (Math.abs(dx) > AXIS_THRESHOLD && Math.abs(dx) > Math.abs(dy)) swipeLock = 'x'
else if (Math.abs(dy) > AXIS_THRESHOLD) swipeLock = 'y'
}
if (swipeLock === 'x') {
e.preventDefault() // stop the page from scrolling while we drag sideways
swipeDX = dx
} else if (swipeLock === 'y') {
swipeDX = 0 // let vertical scroll pass through
}
}
function finishSwipe(e) {
if (!swiping) return
const t = e.changedTouches && e.changedTouches[0]
const dx = swipeLock === 'x' ? (t ? t.clientX - swipeAnchor.x : swipeDX) : swipeDX
const dy = t ? t.clientY - swipeAnchor.y : 0
const horizontal = swipeLock === 'x' && Math.abs(dx) >= SWIPE_THRESHOLD && Math.abs(dx) >= Math.abs(dy)
if (horizontal && viewMode !== 'radar') {
const idx = VALID_VIEWS.indexOf(viewMode)
const step = dx < 0 ? 1 : -1 // left swipe = next, right = previous
const next = VALID_VIEWS[clampIdx(idx + step)]
if (next !== viewMode) {
// Animate the current sheet the rest of the way off-screen, then swap.
settling = true
swipeDX = (contentEl ? contentEl.clientWidth : 420) * step * -1
setTimeout(() => {
setView(next)
swiping = false
swipeDX = 0
swipeLock = null
swipeAnchor = null
settling = false
}, SWIPE_TIMEOUT)
return
}
}
// Not a switch: ease the sheet back to rest.
settling = true
swipeDX = 0
setTimeout(() => {
swiping = false
swipeLock = null
swipeAnchor = null
settling = false
}, SWIPE_TIMEOUT)
}
function cancelSwipe(e) {
if (!swiping) return
settling = true
swipeDX = 0
setTimeout(() => {
swiping = false
swipeLock = null
swipeAnchor = null
settling = false
}, SWIPE_TIMEOUT)
}
const slideStyle = $derived(swipeDX ? `transform: translateX(${swipeDX}px)` : '')
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"
class:swiping={swiping}
bind:this={contentEl}
ontouchstart={onSwipeStart}
ontouchmove={onSwipeMove}
ontouchend={finishSwipe}
ontouchcancel={cancelSwipe}
>
<div
class="swipe-layer"
class:settle={settling}
style={slideStyle}
>
{#if peekView && TAB_LABELS[peekView]}
<span
class="swipe-peek"
class:from-left={swipeDX >= 0}
class:from-right={swipeDX < 0}
aria-hidden="true"
>{TAB_LABELS[peekView]}</span>
{/if}
{#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>
</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;
}
.swipe-layer {
position: relative;
min-height: 100%;
will-change: transform;
touch-action: pan-y;
}
.swipe-layer.settle {
transition: transform 160ms ease-out;
}
.content-area.swiping {
cursor: grabbing;
}
.swipe-peek {
position: absolute;
top: 8px;
z-index: 5;
padding: 5px 10px;
border-radius: var(--radius-md);
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-primary);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
opacity: 0;
animation: peek-in 160ms ease-out forwards;
pointer-events: none;
}
.swipe-peek.from-left {
left: 8px;
}
.swipe-peek.from-right {
right: 8px;
}
@keyframes peek-in {
from { opacity: 0; transform: translateX(10px); }
to { opacity: 1; transform: translateX(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>