add weather display

This commit is contained in:
lumijiez
2025-05-22 22:11:20 +03:00
parent aa0e88d635
commit 03d5f54b61
7 changed files with 394 additions and 0 deletions

14
next.config.js Normal file
View File

@@ -0,0 +1,14 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.weatherapi.com',
pathname: '/weather/**',
},
],
},
}
module.exports = nextConfig

82
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"@react-pdf/renderer": "^4.3.0", "@react-pdf/renderer": "^4.3.0",
"framer-motion": "^12.12.1", "framer-motion": "^12.12.1",
"mongodb": "^6.3.0", "mongodb": "^6.3.0",
"mongoose": "^8.15.0",
"next": "14.1.0", "next": "14.1.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdfkit": "^0.17.1", "pdfkit": "^0.17.1",
@@ -1171,6 +1172,23 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/decimal.js-light": { "node_modules/decimal.js-light": {
"version": "2.5.1", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
@@ -1663,6 +1681,15 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/kareem": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz",
"integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/lilconfig": { "node_modules/lilconfig": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -1845,6 +1872,28 @@
"whatwg-url": "^14.1.0 || ^13.0.0" "whatwg-url": "^14.1.0 || ^13.0.0"
} }
}, },
"node_modules/mongoose": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.15.0.tgz",
"integrity": "sha512-WFKsY1q12ScGabnZWUB9c/QzZmz/ESorrV27OembB7Gz6rrh9m3GA4Srsv1uvW1s9AHO5DeZ6DdUTyF9zyNERQ==",
"license": "MIT",
"dependencies": {
"bson": "^6.10.3",
"kareem": "2.6.3",
"mongodb": "~6.16.0",
"mpath": "0.9.0",
"mquery": "5.0.0",
"ms": "2.1.3",
"sift": "17.1.3"
},
"engines": {
"node": ">=16.20.1"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mongoose"
}
},
"node_modules/motion-dom": { "node_modules/motion-dom": {
"version": "12.12.1", "version": "12.12.1",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.12.1.tgz", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.12.1.tgz",
@@ -1860,6 +1909,33 @@
"integrity": "sha512-f9qiqUHm7hWSLlNW8gS9pisnsN7CRFRD58vNjptKdsqFLpkVnX00TNeD6Q0d27V9KzT7ySFyK1TZ/DShfVOv6w==", "integrity": "sha512-f9qiqUHm7hWSLlNW8gS9pisnsN7CRFRD58vNjptKdsqFLpkVnX00TNeD6Q0d27V9KzT7ySFyK1TZ/DShfVOv6w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/mpath": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz",
"integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/mquery": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz",
"integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==",
"license": "MIT",
"dependencies": {
"debug": "4.x"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/mz": { "node_modules/mz": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -2614,6 +2690,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/sift": {
"version": "17.1.3",
"resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz",
"integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==",
"license": "MIT"
},
"node_modules/signal-exit": { "node_modules/signal-exit": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",

View File

@@ -12,6 +12,7 @@
"@react-pdf/renderer": "^4.3.0", "@react-pdf/renderer": "^4.3.0",
"framer-motion": "^12.12.1", "framer-motion": "^12.12.1",
"mongodb": "^6.3.0", "mongodb": "^6.3.0",
"mongoose": "^8.15.0",
"next": "14.1.0", "next": "14.1.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdfkit": "^0.17.1", "pdfkit": "^0.17.1",

View File

@@ -0,0 +1,74 @@
import { NextResponse } from 'next/server'
import { connectToDatabase } from '@/lib/mongodb'
const CACHE_DURATION = 2 * 60 * 60 * 1000 // 2 hours in milliseconds
const COORDS = {
lat: 47.06149235737582,
lon: 28.86683069635403
}
async function fetchWeatherFromAPI() {
const response = await fetch(
`http://api.weatherapi.com/v1/forecast.json?key=${process.env.WEATHER_API_KEY}&q=${COORDS.lat},${COORDS.lon}&days=7&aqi=no`
)
if (!response.ok) {
throw new Error('Failed to fetch weather data')
}
return response.json()
}
export async function GET() {
try {
// Ensure database connection
const { db } = await connectToDatabase()
// Check if we have cached data
const cachedWeather = await db.collection('weather').findOne({
'location.lat': COORDS.lat,
'location.lon': COORDS.lon
})
const now = new Date()
const isCacheValid = cachedWeather &&
(now - new Date(cachedWeather.lastUpdated)) < CACHE_DURATION
// If cache is valid, return cached data
if (isCacheValid) {
return NextResponse.json(cachedWeather)
}
// If no cache or cache is invalid, fetch fresh data
const weatherData = await fetchWeatherFromAPI()
// Add metadata to the weather data
const weatherWithMetadata = {
...weatherData,
location: COORDS,
lastUpdated: now
}
// Update or create weather document
await db.collection('weather').updateOne(
{
'location.lat': COORDS.lat,
'location.lon': COORDS.lon
},
{ $set: weatherWithMetadata },
{ upsert: true }
)
return NextResponse.json(weatherWithMetadata)
} catch (error) {
console.error('Weather API Error:', error)
// If we have cached data but failed to fetch new data, return cached data
if (cachedWeather) {
return NextResponse.json(cachedWeather)
}
return NextResponse.json(
{ error: 'Failed to fetch weather data' },
{ status: 500 }
)
}
}

View File

@@ -10,6 +10,7 @@ import { LastRefresh } from '@/components/LastRefresh'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { Navbar } from '@/components/Navbar' import { Navbar } from '@/components/Navbar'
import WeatherForecast from '@/components/WeatherForecast'
export default function Dashboard() { export default function Dashboard() {
const [data, setData] = useState([]) const [data, setData] = useState([])
@@ -153,6 +154,15 @@ export default function Dashboard() {
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 text-white"> <div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 text-white">
<Navbar lastUpdateTime={lastUpdateTime} /> <Navbar lastUpdateTime={lastUpdateTime} />
<div className="max-w-[1400px] mx-auto p-4 sm:p-6 space-y-6"> <div className="max-w-[1400px] mx-auto p-4 sm:p-6 space-y-6">
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="w-full"
>
<WeatherForecast />
</motion.div>
<motion.div <motion.div
variants={containerVariants} variants={containerVariants}
initial="hidden" initial="hidden"

View File

@@ -0,0 +1,125 @@
'use client';
import { useEffect, useState } from 'react';
import Image from 'next/image';
import { Card } from '@/components/ui/card'
import { motion } from 'framer-motion'
const WEATHER_ICONS = {
'01d': '☀️', // clear sky
'01n': '🌙', // clear sky night
'02d': '⛅', // few clouds
'02n': '☁️', // few clouds night
'03d': '☁️', // scattered clouds
'03n': '☁️', // scattered clouds night
'04d': '☁️', // broken clouds
'04n': '☁️', // broken clouds night
'09d': '🌧️', // shower rain
'09n': '🌧️', // shower rain night
'10d': '🌦️', // rain
'10n': '🌧️', // rain night
'11d': '⛈️', // thunderstorm
'11n': '⛈️', // thunderstorm night
'13d': '🌨️', // snow
'13n': '🌨️', // snow night
'50d': '🌫️', // mist
'50n': '🌫️', // mist night
}
export default function WeatherForecast() {
const [weatherData, setWeatherData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchWeather = async () => {
try {
const response = await fetch('/api/weather');
if (!response.ok) throw new Error('Failed to fetch weather data');
const data = await response.json();
setWeatherData(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchWeather();
}, []);
if (loading) {
return (
<div className="w-full h-24 bg-gray-100 rounded-lg animate-pulse flex items-center justify-center">
<div className="text-gray-400">Loading weather data...</div>
</div>
);
}
if (error) {
return (
<div className="w-full h-24 bg-red-50 rounded-lg flex items-center justify-center">
<div className="text-red-500">Error loading weather data</div>
</div>
);
}
if (!weatherData?.forecast?.forecastday) {
return null;
}
return (
<Card className="w-full p-3 bg-gray-800/50 backdrop-blur-sm border-gray-700 hover:bg-gray-800/70 transition-all duration-300">
<div className="flex justify-between items-center mb-2">
<div>
<h2 className="text-lg font-semibold text-white">7-Day Forecast</h2>
<div className="text-xs text-gray-400">
Chisinau, UTM
</div>
</div>
<div className="text-xs text-gray-400">
Last updated: {new Date(weatherData.lastUpdated).toLocaleTimeString()}
</div>
</div>
<div className="grid grid-cols-7 gap-2">
{weatherData.forecast.forecastday.map((day, index) => (
<motion.div
key={day.date}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="flex flex-col items-center p-2 rounded-lg bg-gray-700/30 hover:bg-gray-700/50 transition-all duration-300"
>
<div className="text-xs text-gray-400">
{index === 0 ? 'Today' : new Date(day.date).toLocaleDateString('en-US', { weekday: 'short' })}
</div>
<div className="flex items-center gap-2">
<div className="relative w-10 h-10">
<Image
src={`https:${day.day.condition.icon}`}
alt={day.day.condition.text}
fill
className="object-contain"
/>
</div>
<div className="flex flex-col">
<span className="text-sm font-semibold text-white">
{Math.round(day.day.maxtemp_c)}°
</span>
<span className="text-xs text-gray-400">
{Math.round(day.day.mintemp_c)}°
</span>
</div>
</div>
<div className="text-xs text-gray-500 text-center mt-0.5 line-clamp-1">
{day.day.condition.text}
</div>
<div className="text-xs text-gray-400">
{day.day.daily_chance_of_rain}%
</div>
</motion.div>
))}
</div>
</Card>
)
}

88
src/lib/weather.js Normal file
View File

@@ -0,0 +1,88 @@
import { connectToDatabase } from './mongodb'
const WEATHER_API_KEY = process.env.OPENWEATHER_API_KEY
const LAT = 47.06149235737582
const LON = 28.86683069635403
const WEATHER_ICONS = {
'01d': '☀️', // clear sky
'01n': '🌙', // clear sky night
'02d': '⛅', // few clouds
'02n': '☁️', // few clouds night
'03d': '☁️', // scattered clouds
'03n': '☁️', // scattered clouds night
'04d': '☁️', // broken clouds
'04n': '☁️', // broken clouds night
'09d': '🌧️', // shower rain
'09n': '🌧️', // shower rain night
'10d': '🌦️', // rain
'10n': '🌧️', // rain night
'11d': '⛈️', // thunderstorm
'11n': '⛈️', // thunderstorm night
'13d': '🌨️', // snow
'13n': '🌨️', // snow night
'50d': '🌫️', // mist
'50n': '🌫️', // mist night
}
export async function fetchWeatherData() {
try {
const response = await fetch(
`https://api.openweathermap.org/data/2.5/forecast?lat=${LAT}&lon=${LON}&appid=${WEATHER_API_KEY}&units=metric`
)
const data = await response.json()
// Process the data to get daily forecasts
const dailyForecasts = data.list.reduce((acc, item) => {
const date = new Date(item.dt * 1000)
const day = date.toISOString().split('T')[0]
if (!acc[day]) {
acc[day] = {
date: day,
temp_min: item.main.temp_min,
temp_max: item.main.temp_max,
humidity: item.main.humidity,
wind_speed: item.wind.speed,
icon: item.weather[0].icon,
description: item.weather[0].description,
timestamp: date
}
} else {
acc[day].temp_min = Math.min(acc[day].temp_min, item.main.temp_min)
acc[day].temp_max = Math.max(acc[day].temp_max, item.main.temp_max)
}
return acc
}, {})
return Object.values(dailyForecasts)
} catch (error) {
console.error('Error fetching weather data:', error)
throw error
}
}
export async function getWeatherData() {
const { db } = await connectToDatabase()
const collection = db.collection('weather_data')
// Get the latest weather data
const latestData = await collection.findOne({}, { sort: { timestamp: -1 } })
// If no data exists or data is older than 2 hours, fetch new data
if (!latestData || (Date.now() - latestData.timestamp.getTime() > 2 * 60 * 60 * 1000)) {
const newData = await fetchWeatherData()
await collection.insertOne({
forecasts: newData,
timestamp: new Date()
})
return newData
}
return latestData.forecasts
}
export function getWeatherIcon(iconCode) {
return WEATHER_ICONS[iconCode] || '❓'
}