feat(frontend): add weather icons and redesign calendar header

Backend changes:
- Add LOCATION configuration option to Settings
- Add /api/system/config endpoint to expose public config
- Implement location priority: config > geolocation > default

Frontend changes:
- Install and integrate weather-icons npm package (Erik Flowers)
- Redesign calendar header with date/time on left, weather/location on right
- Display weather icon using CSS classes instead of SVG components
- Fetch location from backend API on component mount
- Use configured location name (from .env) instead of geocoded result

Layout:
- Left: month/year + current time
- Right: city name + weather description + weather icon
This commit is contained in:
2026-04-06 22:18:44 +08:00
parent ff042cd932
commit 712d9e1652
9 changed files with 133 additions and 22 deletions

View File

@@ -1,5 +1,4 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { Cloud, CloudDrizzle, CloudFog, CloudLightning, CloudRain, CloudSnow, Sun } from 'lucide-vue-next'
export function formatNetworkRate(bytesPerSecond: number | null, online: boolean) {
if (!online || bytesPerSecond === null) return 'OFFLINE'
@@ -10,10 +9,24 @@ export function formatNetworkRate(bytesPerSecond: number | null, online: boolean
export function useClientTime() {
const clientTime = ref(new Date())
const city = ref('Location')
const weatherSummary = ref('Weather unavailable')
const weatherCode = ref<number | null>(null)
let clientTimeTimer: ReturnType<typeof setInterval> | null = null
// Load location from backend config
async function loadLocation() {
try {
const response = await fetch('/api/system/config')
if (response.ok) {
const config = await response.json()
city.value = config.location || 'Location'
}
} catch {
city.value = import.meta.env.VITE_LOCATION || 'Location'
}
}
function updateClientTime() {
clientTime.value = new Date()
}
@@ -40,27 +53,48 @@ export function useClientTime() {
const weatherIcon = computed(() => {
const code = weatherCode.value
if (code === 0) return Sun
if (code === 1 || code === 2 || code === 3) return Cloud
if (code === 45 || code === 48) return CloudFog
if ([51, 53, 55, 56, 57].includes(code ?? -1)) return CloudDrizzle
if ([61, 63, 65, 66, 67, 80, 81, 82].includes(code ?? -1)) return CloudRain
if ([71, 73, 75, 77, 85, 86].includes(code ?? -1)) return CloudSnow
if ([95, 96, 99].includes(code ?? -1)) return CloudLightning
return Cloud
if (code === 0) return 'wi-day-sunny'
if (code === 1) return 'wi-day-cloudy'
if (code === 2) return 'wi-day-cloudy-gusts'
if (code === 3) return 'wi-cloudy'
if (code === 45) return 'wi-day-fog'
if (code === 48) return 'wi-fog'
if ([51, 53].includes(code ?? -1)) return 'wi-day-showers'
if ([55, 56, 57].includes(code ?? -1)) return 'wi-showers'
if ([61, 63, 65, 80].includes(code ?? -1)) return 'wi-day-rain'
if ([66, 67, 81, 82].includes(code ?? -1)) return 'wi-rain'
if ([71, 73, 75, 77, 85, 86].includes(code ?? -1)) return 'wi-snow'
if ([95, 96, 99].includes(code ?? -1)) return 'wi-thunderstorm'
return 'wi-day-sunny'
})
async function loadWeather(latitude: number, longitude: number) {
try {
const response = await fetch(
// Fetch weather data
const weatherResp = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&current=temperature_2m,weather_code&timezone=auto`,
)
if (!response.ok) throw new Error('weather request failed')
const data = await response.json()
const current = data.current ?? {}
if (!weatherResp.ok) throw new Error('weather request failed')
const weatherData = await weatherResp.json()
const current = weatherData.current ?? {}
weatherCode.value = typeof current.weather_code === 'number' ? current.weather_code : null
const temp = typeof current.temperature_2m === 'number' ? `${Math.round(current.temperature_2m)}°C` : '--'
weatherSummary.value = `${weatherCodeLabel(current.weather_code)} ${temp}`
// Only fetch city name if not already set by config
if (city.value === 'Location') {
try {
const geoResp = await fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=10`,
)
if (geoResp.ok) {
const geoData = await geoResp.json()
city.value = geoData.address?.city ?? geoData.address?.town ?? geoData.address?.county ?? geoData.display_name?.split(',')[0] ?? 'Location'
}
} catch {
city.value = 'Location'
}
}
} catch {
weatherCode.value = null
weatherSummary.value = 'Weather unavailable'
@@ -70,6 +104,7 @@ export function useClientTime() {
onMounted(() => {
updateClientTime()
clientTimeTimer = setInterval(updateClientTime, 1000)
void loadLocation()
if (!navigator.geolocation) {
weatherCode.value = null
weatherSummary.value = 'Weather unavailable'
@@ -87,7 +122,7 @@ export function useClientTime() {
})
return {
clientTime, weatherSummary, weatherCode, weatherIcon,
clientTime, city, weatherSummary, weatherCode, weatherIcon,
updateClientTime, formatClientDate, formatClientClock, weatherCodeLabel, loadWeather
}
}