add weather display
This commit is contained in:
14
next.config.js
Normal file
14
next.config.js
Normal 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
82
package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"@react-pdf/renderer": "^4.3.0",
|
||||
"framer-motion": "^12.12.1",
|
||||
"mongodb": "^6.3.0",
|
||||
"mongoose": "^8.15.0",
|
||||
"next": "14.1.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfkit": "^0.17.1",
|
||||
@@ -1171,6 +1172,23 @@
|
||||
"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": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
@@ -1663,6 +1681,15 @@
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"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": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -1845,6 +1872,28 @@
|
||||
"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": {
|
||||
"version": "12.12.1",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.12.1.tgz",
|
||||
@@ -1860,6 +1909,33 @@
|
||||
"integrity": "sha512-f9qiqUHm7hWSLlNW8gS9pisnsN7CRFRD58vNjptKdsqFLpkVnX00TNeD6Q0d27V9KzT7ySFyK1TZ/DShfVOv6w==",
|
||||
"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": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
@@ -2614,6 +2690,12 @@
|
||||
"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": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"@react-pdf/renderer": "^4.3.0",
|
||||
"framer-motion": "^12.12.1",
|
||||
"mongodb": "^6.3.0",
|
||||
"mongoose": "^8.15.0",
|
||||
"next": "14.1.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfkit": "^0.17.1",
|
||||
|
||||
74
src/app/api/weather/route.js
Normal file
74
src/app/api/weather/route.js
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { LastRefresh } from '@/components/LastRefresh'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Navbar } from '@/components/Navbar'
|
||||
import WeatherForecast from '@/components/WeatherForecast'
|
||||
|
||||
export default function Dashboard() {
|
||||
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">
|
||||
<Navbar lastUpdateTime={lastUpdateTime} />
|
||||
<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
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
|
||||
125
src/components/WeatherForecast.js
Normal file
125
src/components/WeatherForecast.js
Normal 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
88
src/lib/weather.js
Normal 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] || '❓'
|
||||
}
|
||||
Reference in New Issue
Block a user