├── .dockerignore ├── .gitignore ├── Dockerfile ├── README.md ├── components.json ├── docker-compose.yml ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── logo.png ├── logo2.png └── vite.svg ├── src ├── App.css ├── App.tsx ├── api │ ├── config.ts │ ├── types.ts │ └── weather.ts ├── components │ ├── city-search.tsx │ ├── current-weather.tsx │ ├── favorite-button.tsx │ ├── favorite-cities.tsx │ ├── header.tsx │ ├── hourly-temprature.tsx │ ├── layout.tsx │ ├── loading-skeleton.tsx │ ├── theme-toggle.tsx │ ├── ui │ │ ├── alert.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── command.tsx │ │ ├── dialog.tsx │ │ ├── scroll-area.tsx │ │ ├── skeleton.tsx │ │ ├── sonner.tsx │ │ └── tooltip.tsx │ ├── weather-details.tsx │ └── weather-forecast.tsx ├── context │ └── theme-provider.tsx ├── hooks │ ├── use-favorite.ts │ ├── use-geolocation.ts │ ├── use-local-storage.ts │ ├── use-search-history.ts │ └── use-weather.ts ├── index.css ├── lib │ └── utils.ts ├── main.tsx ├── pages │ ├── city-page.tsx │ └── weather-dashboard.tsx └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | .env -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY package*.json . 6 | 7 | RUN npm install 8 | 9 | COPY . . 10 | 11 | EXPOSE 5173 12 | 13 | CMD [ "npm","run","dev" ] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Weather App with Next JS, React, Tanstack Query, Shadcn UI, Recharts, Tailwind, Typescript Tutorial 🔥🔥 2 | ## https://youtu.be/BCp_5PoKrvI 3 | 4 | ![klimate](https://github.com/user-attachments/assets/03aed8a9-f2e1-4fcf-8628-5d1abd0c678c) 5 | 6 | ### Make sure to create a `.env` file with following variables - 7 | 8 | ``` 9 | VITE_OPENWEATHER_API_KEY= 10 | ``` 11 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | frontend: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | 9 | ports: 10 | - "5173:5173" 11 | 12 | volumes: 13 | - .:/app 14 | - /app/node_modules 15 | 16 | env_file: 17 | - .env 18 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "weather-app", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --host 0.0.0.0", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@radix-ui/react-dialog": "^1.1.2", 14 | "@radix-ui/react-dropdown-menu": "^2.1.2", 15 | "@radix-ui/react-icons": "^1.3.1", 16 | "@radix-ui/react-scroll-area": "^1.2.0", 17 | "@radix-ui/react-slot": "^1.1.0", 18 | "@radix-ui/react-switch": "^1.1.1", 19 | "@radix-ui/react-tooltip": "^1.1.3", 20 | "@tanstack/react-query": "^5.59.16", 21 | "@tanstack/react-query-devtools": "^5.59.16", 22 | "class-variance-authority": "^0.7.0", 23 | "clsx": "^2.1.1", 24 | "cmdk": "^1.0.0", 25 | "date-fns": "^4.1.0", 26 | "lucide-react": "^0.454.0", 27 | "next-themes": "^0.3.0", 28 | "react": "^18.3.1", 29 | "react-dom": "^18.3.1", 30 | "react-router-dom": "^6.27.0", 31 | "recharts": "^2.13.3", 32 | "sonner": "^1.5.0", 33 | "tailwind-merge": "^2.5.4", 34 | "tailwindcss-animate": "^1.0.7" 35 | }, 36 | "devDependencies": { 37 | "@eslint/js": "^9.13.0", 38 | "@types/node": "^22.8.6", 39 | "@types/react": "^18.3.12", 40 | "@types/react-dom": "^18.3.1", 41 | "@vitejs/plugin-react": "^4.3.3", 42 | "autoprefixer": "^10.4.20", 43 | "eslint": "^9.13.0", 44 | "eslint-plugin-react-hooks": "^5.0.0", 45 | "eslint-plugin-react-refresh": "^0.4.14", 46 | "globals": "^15.11.0", 47 | "postcss": "^8.4.47", 48 | "tailwindcss": "^3.4.14", 49 | "typescript": "~5.6.2", 50 | "typescript-eslint": "^8.11.0", 51 | "vite": "^5.4.10" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piyush-eon/tanstack-query-weather-app/1c5da13c7809cd57e6d0e3d6bcd46944bf603308/public/logo.png -------------------------------------------------------------------------------- /public/logo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piyush-eon/tanstack-query-weather-app/1c5da13c7809cd57e6d0e3d6bcd46944bf603308/public/logo2.png -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piyush-eon/tanstack-query-weather-app/1c5da13c7809cd57e6d0e3d6bcd46944bf603308/src/App.css -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 2 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; 3 | import { Toaster } from "./components/ui/sonner"; 4 | import { WeatherDashboard } from "./pages/weather-dashboard"; 5 | import { Layout } from "./components/layout"; 6 | import { ThemeProvider } from "./context/theme-provider"; 7 | import { BrowserRouter, Route, Routes } from "react-router-dom"; 8 | import { CityPage } from "./pages/city-page"; 9 | 10 | const queryClient = new QueryClient({ 11 | defaultOptions: { 12 | queries: { 13 | staleTime: 5 * 60 * 1000, // 5 minutes 14 | gcTime: 10 * 60 * 1000, // 10 minutes 15 | retry: false, 16 | refetchOnWindowFocus: false, 17 | }, 18 | }, 19 | }); 20 | 21 | function App() { 22 | return ( 23 | 24 | 25 | 26 | 27 | 28 | } /> 29 | } /> 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | } 39 | 40 | export default App; 41 | -------------------------------------------------------------------------------- /src/api/config.ts: -------------------------------------------------------------------------------- 1 | export const API_CONFIG = { 2 | BASE_URL: "https://api.openweathermap.org/data/2.5", 3 | GEO: "https://api.openweathermap.org/geo/1.0", 4 | API_KEY: import.meta.env.VITE_OPENWEATHER_API_KEY, 5 | DEFAULT_PARAMS: { 6 | units: "metric", 7 | appid: import.meta.env.VITE_OPENWEATHER_API_KEY, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /src/api/types.ts: -------------------------------------------------------------------------------- 1 | export interface Coordinates { 2 | lat: number; 3 | lon: number; 4 | } 5 | 6 | export interface GeocodingResponse { 7 | name: string; 8 | local_names?: Record; 9 | lat: number; 10 | lon: number; 11 | country: string; 12 | state?: string; 13 | } 14 | 15 | export interface WeatherCondition { 16 | id: number; 17 | main: string; 18 | description: string; 19 | icon: string; 20 | } 21 | 22 | export interface WeatherData { 23 | coord: Coordinates; 24 | weather: WeatherCondition[]; 25 | main: { 26 | temp: number; 27 | feels_like: number; 28 | temp_min: number; 29 | temp_max: number; 30 | pressure: number; 31 | humidity: number; 32 | }; 33 | wind: { 34 | speed: number; 35 | deg: number; 36 | }; 37 | sys: { 38 | sunrise: number; 39 | sunset: number; 40 | country: string; 41 | }; 42 | name: string; 43 | dt: number; 44 | } 45 | 46 | export interface ForecastData { 47 | list: Array<{ 48 | dt: number; 49 | main: WeatherData["main"]; 50 | weather: WeatherData["weather"]; 51 | wind: WeatherData["wind"]; 52 | dt_txt: string; 53 | }>; 54 | city: { 55 | name: string; 56 | country: string; 57 | sunrise: number; 58 | sunset: number; 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /src/api/weather.ts: -------------------------------------------------------------------------------- 1 | import { API_CONFIG } from "./config"; 2 | import type { 3 | WeatherData, 4 | ForecastData, 5 | GeocodingResponse, 6 | Coordinates, 7 | } from "./types"; 8 | 9 | class WeatherAPI { 10 | private createUrl(endpoint: string, params: Record) { 11 | const searchParams = new URLSearchParams({ 12 | appid: API_CONFIG.API_KEY, 13 | ...params, 14 | }); 15 | return `${endpoint}?${searchParams.toString()}`; 16 | } 17 | 18 | private async fetchData(url: string): Promise { 19 | const response = await fetch(url); 20 | 21 | if (!response.ok) { 22 | throw new Error(`Weather API Error: ${response.statusText}`); 23 | } 24 | 25 | return response.json(); 26 | } 27 | 28 | async getCurrentWeather({ lat, lon }: Coordinates): Promise { 29 | const url = this.createUrl(`${API_CONFIG.BASE_URL}/weather`, { 30 | lat: lat.toString(), 31 | lon: lon.toString(), 32 | units: "metric", 33 | }); 34 | return this.fetchData(url); 35 | } 36 | 37 | async getForecast({ lat, lon }: Coordinates): Promise { 38 | const url = this.createUrl(`${API_CONFIG.BASE_URL}/forecast`, { 39 | lat: lat.toString(), 40 | lon: lon.toString(), 41 | units: "metric", 42 | }); 43 | return this.fetchData(url); 44 | } 45 | 46 | async reverseGeocode({ 47 | lat, 48 | lon, 49 | }: Coordinates): Promise { 50 | const url = this.createUrl(`${API_CONFIG.GEO}/reverse`, { 51 | lat: lat.toString(), 52 | lon: lon.toString(), 53 | limit: "1", 54 | }); 55 | return this.fetchData(url); 56 | } 57 | 58 | async searchLocations(query: string): Promise { 59 | const url = this.createUrl(`${API_CONFIG.GEO}/direct`, { 60 | q: query, 61 | limit: "5", 62 | }); 63 | return this.fetchData(url); 64 | } 65 | } 66 | 67 | export const weatherAPI = new WeatherAPI(); 68 | -------------------------------------------------------------------------------- /src/components/city-search.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | import { format } from "date-fns"; 4 | import { Search, Loader2, Clock, Star, XCircle } from "lucide-react"; 5 | import { useLocationSearch } from "@/hooks/use-weather"; 6 | import { useSearchHistory } from "@/hooks/use-search-history"; 7 | import { 8 | Command, 9 | CommandDialog, 10 | CommandEmpty, 11 | CommandGroup, 12 | CommandInput, 13 | CommandItem, 14 | CommandList, 15 | CommandSeparator, 16 | } from "@/components/ui/command"; 17 | import { Button } from "@/components/ui/button"; 18 | import { useFavorites } from "@/hooks/use-favorite"; 19 | 20 | export function CitySearch() { 21 | const [open, setOpen] = useState(false); 22 | const [query, setQuery] = useState(""); 23 | const navigate = useNavigate(); 24 | 25 | const { data: locations, isLoading } = useLocationSearch(query); 26 | const { favorites } = useFavorites(); 27 | const { history, clearHistory, addToHistory } = useSearchHistory(); 28 | 29 | const handleSelect = (cityData: string) => { 30 | const [lat, lon, name, country] = cityData.split("|"); 31 | 32 | // Add to search history 33 | addToHistory.mutate({ 34 | query, 35 | name, 36 | lat: parseFloat(lat), 37 | lon: parseFloat(lon), 38 | country, 39 | }); 40 | 41 | setOpen(false); 42 | navigate(`/city/${name}?lat=${lat}&lon=${lon}`); 43 | }; 44 | 45 | return ( 46 | <> 47 | 55 | 56 | 57 | 62 | 63 | {query.length > 2 && !isLoading && ( 64 | No cities found. 65 | )} 66 | 67 | {/* Favorites Section */} 68 | {favorites.length > 0 && ( 69 | 70 | {favorites.map((city) => ( 71 | 76 | 77 | {city.name} 78 | {city.state && ( 79 | 80 | , {city.state} 81 | 82 | )} 83 | 84 | , {city.country} 85 | 86 | 87 | ))} 88 | 89 | )} 90 | 91 | {/* Search History Section */} 92 | {history.length > 0 && ( 93 | <> 94 | 95 | 96 |
97 |

98 | Recent Searches 99 |

100 | 108 |
109 | {history.map((item) => ( 110 | 115 | 116 | {item.name} 117 | {item.state && ( 118 | 119 | , {item.state} 120 | 121 | )} 122 | 123 | , {item.country} 124 | 125 | 126 | {format(item.searchedAt, "MMM d, h:mm a")} 127 | 128 | 129 | ))} 130 |
131 | 132 | )} 133 | 134 | {/* Search Results */} 135 | 136 | {locations && locations.length > 0 && ( 137 | 138 | {isLoading && ( 139 |
140 | 141 |
142 | )} 143 | {locations?.map((location) => ( 144 | 149 | 150 | {location.name} 151 | {location.state && ( 152 | 153 | , {location.state} 154 | 155 | )} 156 | 157 | , {location.country} 158 | 159 | 160 | ))} 161 |
162 | )} 163 |
164 |
165 |
166 | 167 | ); 168 | } 169 | -------------------------------------------------------------------------------- /src/components/current-weather.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent } from "./ui/card"; 2 | import { ArrowDown, ArrowUp, Droplets, Wind } from "lucide-react"; 3 | import type { WeatherData, GeocodingResponse } from "@/api/types"; 4 | 5 | interface CurrentWeatherProps { 6 | data: WeatherData; 7 | locationName?: GeocodingResponse; 8 | } 9 | 10 | export function CurrentWeather({ data, locationName }: CurrentWeatherProps) { 11 | const { 12 | weather: [currentWeather], 13 | main: { temp, feels_like, temp_min, temp_max, humidity }, 14 | wind: { speed }, 15 | } = data; 16 | 17 | // Format temperature 18 | const formatTemp = (temp: number) => `${Math.round(temp)}°`; 19 | 20 | return ( 21 | 22 | 23 |
24 |
25 |
26 |
27 |

28 | {locationName?.name} 29 |

30 | {locationName?.state && ( 31 | 32 | , {locationName.state} 33 | 34 | )} 35 |
36 |

37 | {locationName?.country} 38 |

39 |
40 | 41 |
42 |

43 | {formatTemp(temp)} 44 |

45 |
46 |

47 | Feels like {formatTemp(feels_like)} 48 |

49 |
50 | 51 | 52 | {formatTemp(temp_min)} 53 | 54 | 55 | 56 | {formatTemp(temp_max)} 57 | 58 |
59 |
60 |
61 | 62 |
63 |
64 | 65 |
66 |

Humidity

67 |

{humidity}%

68 |
69 |
70 |
71 | 72 |
73 |

Wind Speed

74 |

{speed} m/s

75 |
76 |
77 |
78 |
79 | 80 |
81 |
82 | {currentWeather.description} 87 |
88 |

89 | {currentWeather.description} 90 |

91 |
92 |
93 |
94 |
95 |
96 |
97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /src/components/favorite-button.tsx: -------------------------------------------------------------------------------- 1 | // src/components/weather/favorite-button.tsx 2 | import { Star } from "lucide-react"; 3 | import { Button } from "@/components/ui/button"; 4 | import type { WeatherData } from "@/api/types"; 5 | import { useFavorites } from "@/hooks/use-favorite"; 6 | import { toast } from "sonner"; 7 | 8 | interface FavoriteButtonProps { 9 | data: WeatherData; 10 | } 11 | 12 | export function FavoriteButton({ data }: FavoriteButtonProps) { 13 | const { addFavorite, removeFavorite, isFavorite } = useFavorites(); 14 | const isCurrentlyFavorite = isFavorite(data.coord.lat, data.coord.lon); 15 | 16 | const handleToggleFavorite = () => { 17 | if (isCurrentlyFavorite) { 18 | removeFavorite.mutate(`${data.coord.lat}-${data.coord.lon}`); 19 | toast.error(`Removed ${data.name} from Favorites`); 20 | } else { 21 | addFavorite.mutate({ 22 | name: data.name, 23 | lat: data.coord.lat, 24 | lon: data.coord.lon, 25 | country: data.sys.country, 26 | }); 27 | toast.success(`Added ${data.name} to Favorites`); 28 | } 29 | }; 30 | 31 | return ( 32 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/components/favorite-cities.tsx: -------------------------------------------------------------------------------- 1 | // src/components/weather/favorite-cities.tsx 2 | import { useNavigate } from "react-router-dom"; 3 | import { useWeatherQuery } from "@/hooks/use-weather"; 4 | import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; 5 | import { X, Loader2 } from "lucide-react"; 6 | import { Button } from "@/components/ui/button"; 7 | import { useFavorites } from "@/hooks/use-favorite"; 8 | import { toast } from "sonner"; 9 | 10 | interface FavoriteCityTabletProps { 11 | id: string; 12 | name: string; 13 | lat: number; 14 | lon: number; 15 | onRemove: (id: string) => void; 16 | } 17 | 18 | function FavoriteCityTablet({ 19 | id, 20 | name, 21 | lat, 22 | lon, 23 | onRemove, 24 | }: FavoriteCityTabletProps) { 25 | const navigate = useNavigate(); 26 | const { data: weather, isLoading } = useWeatherQuery({ lat, lon }); 27 | 28 | const handleClick = () => { 29 | navigate(`/city/${name}?lat=${lat}&lon=${lon}`); 30 | }; 31 | 32 | return ( 33 |
39 | 51 | 52 | {isLoading ? ( 53 |
54 | 55 |
56 | ) : weather ? ( 57 | <> 58 |
59 | {weather.weather[0].description} 64 |
65 |

{name}

66 |

67 | {weather.sys.country} 68 |

69 |
70 |
71 |
72 |

73 | {Math.round(weather.main.temp)}° 74 |

75 |

76 | {weather.weather[0].description} 77 |

78 |
79 | 80 | ) : null} 81 |
82 | ); 83 | } 84 | 85 | export function FavoriteCities() { 86 | const { favorites, removeFavorite } = useFavorites(); 87 | 88 | if (!favorites.length) { 89 | return null; 90 | } 91 | 92 | return ( 93 | <> 94 |

Favorites

95 | 96 |
97 | {favorites.map((city) => ( 98 | removeFavorite.mutate(city.id)} 102 | /> 103 | ))} 104 |
105 | 106 |
107 | 108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /src/components/header.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | import { CitySearch } from "./city-search"; 3 | import { ThemeToggle } from "./theme-toggle"; 4 | import { useTheme } from "@/context/theme-provider"; 5 | 6 | export function Header() { 7 | const { theme } = useTheme(); 8 | 9 | return ( 10 |
11 |
12 | 13 | Klimate logo 18 | 19 | 20 |
21 | 22 | 23 |
24 |
25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/components/hourly-temprature.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; 2 | import { 3 | LineChart, 4 | Line, 5 | XAxis, 6 | YAxis, 7 | Tooltip, 8 | ResponsiveContainer, 9 | } from "recharts"; 10 | import { format } from "date-fns"; 11 | import type { ForecastData } from "@/api/types"; 12 | 13 | interface HourlyTemperatureProps { 14 | data: ForecastData; 15 | } 16 | 17 | interface ChartData { 18 | time: string; 19 | temp: number; 20 | feels_like: number; 21 | } 22 | 23 | export function HourlyTemperature({ data }: HourlyTemperatureProps) { 24 | // Get today's forecast data and format for chart 25 | 26 | const chartData: ChartData[] = data.list 27 | .slice(0, 8) // Get next 24 hours (3-hour intervals) 28 | .map((item) => ({ 29 | time: format(new Date(item.dt * 1000), "ha"), 30 | temp: Math.round(item.main.temp), 31 | feels_like: Math.round(item.main.feels_like), 32 | })); 33 | 34 | return ( 35 | 36 | 37 | Today's Temperature 38 | 39 | 40 |
41 | 42 | 43 | 50 | `${value}°`} 56 | /> 57 | { 59 | if (active && payload && payload.length) { 60 | return ( 61 |
62 |
63 |
64 | 65 | Temperature 66 | 67 | 68 | {payload[0].value}° 69 | 70 |
71 |
72 | 73 | Feels Like 74 | 75 | 76 | {payload[1].value}° 77 | 78 |
79 |
80 |
81 | ); 82 | } 83 | return null; 84 | }} 85 | /> 86 | 93 | 101 |
102 |
103 |
104 |
105 |
106 | ); 107 | } 108 | -------------------------------------------------------------------------------- /src/components/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from "react"; 2 | import { Header } from "./header"; 3 | 4 | export function Layout({ children }: PropsWithChildren) { 5 | return ( 6 |
7 |
8 |
9 | {children} 10 |
11 |
12 |
13 |

Made with 💗 by RoadsideCoder

14 |
15 |
16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/loading-skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "./ui/skeleton"; 2 | 3 | function WeatherSkeleton() { 4 | return ( 5 |
6 |
7 | 8 | 9 |
10 | 11 | 12 |
13 |
14 |
15 | ); 16 | } 17 | 18 | export default WeatherSkeleton; 19 | -------------------------------------------------------------------------------- /src/components/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | import { Moon, Sun } from "lucide-react"; 2 | import { useTheme } from "@/context/theme-provider"; 3 | 4 | export function ThemeToggle() { 5 | const { theme, setTheme } = useTheme(); 6 | const isDark = theme === "dark"; 7 | 8 | return ( 9 |
setTheme(isDark ? "light" : "dark")} 11 | className={`flex items-center cursor-pointer transition-transform duration-500 ${ 12 | isDark ? "rotate-180" : "rotate-0" 13 | }`} 14 | > 15 | {isDark ? ( 16 | 17 | ) : ( 18 | 19 | )} 20 | Toggle theme 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |

53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |

61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /src/components/ui/command.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { type DialogProps } from "@radix-ui/react-dialog" 3 | import { MagnifyingGlassIcon } from "@radix-ui/react-icons" 4 | import { Command as CommandPrimitive } from "cmdk" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { Dialog, DialogContent } from "@/components/ui/dialog" 8 | 9 | const Command = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | )) 22 | Command.displayName = CommandPrimitive.displayName 23 | 24 | interface CommandDialogProps extends DialogProps {} 25 | 26 | const CommandDialog = ({ children, ...props }: CommandDialogProps) => { 27 | return ( 28 | 29 | 30 | 31 | {children} 32 | 33 | 34 | 35 | ) 36 | } 37 | 38 | const CommandInput = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 |
43 | 44 | 52 |
53 | )) 54 | 55 | CommandInput.displayName = CommandPrimitive.Input.displayName 56 | 57 | const CommandList = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, ...props }, ref) => ( 61 | 66 | )) 67 | 68 | CommandList.displayName = CommandPrimitive.List.displayName 69 | 70 | const CommandEmpty = React.forwardRef< 71 | React.ElementRef, 72 | React.ComponentPropsWithoutRef 73 | >((props, ref) => ( 74 | 79 | )) 80 | 81 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName 82 | 83 | const CommandGroup = React.forwardRef< 84 | React.ElementRef, 85 | React.ComponentPropsWithoutRef 86 | >(({ className, ...props }, ref) => ( 87 | 95 | )) 96 | 97 | CommandGroup.displayName = CommandPrimitive.Group.displayName 98 | 99 | const CommandSeparator = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName 110 | 111 | const CommandItem = React.forwardRef< 112 | React.ElementRef, 113 | React.ComponentPropsWithoutRef 114 | >(({ className, ...props }, ref) => ( 115 | 123 | )) 124 | 125 | CommandItem.displayName = CommandPrimitive.Item.displayName 126 | 127 | const CommandShortcut = ({ 128 | className, 129 | ...props 130 | }: React.HTMLAttributes) => { 131 | return ( 132 | 139 | ) 140 | } 141 | CommandShortcut.displayName = "CommandShortcut" 142 | 143 | export { 144 | Command, 145 | CommandDialog, 146 | CommandInput, 147 | CommandList, 148 | CommandEmpty, 149 | CommandGroup, 150 | CommandItem, 151 | CommandShortcut, 152 | CommandSeparator, 153 | } 154 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { Cross2Icon } from "@radix-ui/react-icons" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogTrigger, 116 | DialogClose, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /src/components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const ScrollArea = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, children, ...props }, ref) => ( 10 | 15 | 16 | {children} 17 | 18 | 19 | 20 | 21 | )) 22 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName 23 | 24 | const ScrollBar = React.forwardRef< 25 | React.ElementRef, 26 | React.ComponentPropsWithoutRef 27 | >(({ className, orientation = "vertical", ...props }, ref) => ( 28 | 41 | 42 | 43 | )) 44 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName 45 | 46 | export { ScrollArea, ScrollBar } 47 | -------------------------------------------------------------------------------- /src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "next-themes" 2 | import { Toaster as Sonner } from "sonner" 3 | 4 | type ToasterProps = React.ComponentProps 5 | 6 | const Toaster = ({ ...props }: ToasterProps) => { 7 | const { theme = "system" } = useTheme() 8 | 9 | return ( 10 | 26 | ) 27 | } 28 | 29 | export { Toaster } 30 | -------------------------------------------------------------------------------- /src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const TooltipProvider = TooltipPrimitive.Provider 7 | 8 | const Tooltip = TooltipPrimitive.Root 9 | 10 | const TooltipTrigger = TooltipPrimitive.Trigger 11 | 12 | const TooltipContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, sideOffset = 4, ...props }, ref) => ( 16 | 17 | 26 | 27 | )) 28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 29 | 30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 31 | -------------------------------------------------------------------------------- /src/components/weather-details.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; 2 | import { Sunrise, Sunset, Compass, Gauge } from "lucide-react"; 3 | import { format } from "date-fns"; 4 | import type { WeatherData } from "@/api/types"; 5 | 6 | interface WeatherDetailsProps { 7 | data: WeatherData; 8 | } 9 | 10 | export function WeatherDetails({ data }: WeatherDetailsProps) { 11 | const { wind, main, sys } = data; 12 | 13 | // Format time using date-fns 14 | const formatTime = (timestamp: number) => { 15 | return format(new Date(timestamp * 1000), "h:mm a"); 16 | }; 17 | 18 | // Convert wind degree to direction 19 | const getWindDirection = (degree: number) => { 20 | const directions = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]; 21 | const index = 22 | Math.round(((degree %= 360) < 0 ? degree + 360 : degree) / 45) % 8; 23 | return directions[index]; 24 | }; 25 | 26 | const details = [ 27 | { 28 | title: "Sunrise", 29 | value: formatTime(sys.sunrise), 30 | icon: Sunrise, 31 | color: "text-orange-500", 32 | }, 33 | { 34 | title: "Sunset", 35 | value: formatTime(sys.sunset), 36 | icon: Sunset, 37 | color: "text-blue-500", 38 | }, 39 | { 40 | title: "Wind Direction", 41 | value: `${getWindDirection(wind.deg)} (${wind.deg}°)`, 42 | icon: Compass, 43 | color: "text-green-500", 44 | }, 45 | { 46 | title: "Pressure", 47 | value: `${main.pressure} hPa`, 48 | icon: Gauge, 49 | color: "text-purple-500", 50 | }, 51 | ]; 52 | 53 | return ( 54 | 55 | 56 | Weather Details 57 | 58 | 59 |
60 | {details.map((detail) => ( 61 |
65 | 66 |
67 |

68 | {detail.title} 69 |

70 |

{detail.value}

71 |
72 |
73 | ))} 74 |
75 |
76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /src/components/weather-forecast.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; 2 | import { ArrowDown, ArrowUp, Droplets, Wind } from "lucide-react"; 3 | import { format } from "date-fns"; 4 | import type { ForecastData } from "@/api/types"; 5 | 6 | interface WeatherForecastProps { 7 | data: ForecastData; 8 | } 9 | 10 | interface DailyForecast { 11 | date: number; 12 | temp_min: number; 13 | temp_max: number; 14 | humidity: number; 15 | wind: number; 16 | weather: { 17 | id: number; 18 | main: string; 19 | description: string; 20 | icon: string; 21 | }; 22 | } 23 | 24 | export function WeatherForecast({ data }: WeatherForecastProps) { 25 | // Group forecast by day and get daily min/max 26 | const dailyForecasts = data.list.reduce((acc, forecast) => { 27 | const date = format(new Date(forecast.dt * 1000), "yyyy-MM-dd"); 28 | 29 | if (!acc[date]) { 30 | acc[date] = { 31 | temp_min: forecast.main.temp_min, 32 | temp_max: forecast.main.temp_max, 33 | humidity: forecast.main.humidity, 34 | wind: forecast.wind.speed, 35 | weather: forecast.weather[0], 36 | date: forecast.dt, 37 | }; 38 | } else { 39 | acc[date].temp_min = Math.min(acc[date].temp_min, forecast.main.temp_min); 40 | acc[date].temp_max = Math.max(acc[date].temp_max, forecast.main.temp_max); 41 | } 42 | 43 | return acc; 44 | }, {} as Record); 45 | 46 | // Get next 5 days 47 | const nextDays = Object.values(dailyForecasts).slice(1, 6); 48 | 49 | // Format temperature 50 | const formatTemp = (temp: number) => `${Math.round(temp)}°`; 51 | 52 | return ( 53 | 54 | 55 | 5-Day Forecast 56 | 57 | 58 |
59 | {nextDays.map((day) => ( 60 |
64 |
65 |

66 | {format(new Date(day.date * 1000), "EEE, MMM d")} 67 |

68 |

69 | {day.weather.description} 70 |

71 |
72 | 73 |
74 | 75 | 76 | {formatTemp(day.temp_min)} 77 | 78 | 79 | 80 | {formatTemp(day.temp_max)} 81 | 82 |
83 | 84 |
85 | 86 | 87 | {day.humidity}% 88 | 89 | 90 | 91 | {day.wind}m/s 92 | 93 |
94 |
95 | ))} 96 |
97 |
98 |
99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /src/context/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useEffect, useState } from "react"; 2 | 3 | type Theme = "dark" | "light" | "system"; 4 | 5 | type ThemeProviderProps = { 6 | children: React.ReactNode; 7 | defaultTheme?: Theme; 8 | storageKey?: string; 9 | }; 10 | 11 | type ThemeProviderState = { 12 | theme: Theme; 13 | setTheme: (theme: Theme) => void; 14 | }; 15 | 16 | const initialState: ThemeProviderState = { 17 | theme: "system", 18 | setTheme: () => null, 19 | }; 20 | 21 | const ThemeProviderContext = createContext(initialState); 22 | 23 | export function ThemeProvider({ 24 | children, 25 | defaultTheme = "system", 26 | storageKey = "vite-ui-theme", 27 | ...props 28 | }: ThemeProviderProps) { 29 | const [theme, setTheme] = useState( 30 | () => (localStorage.getItem(storageKey) as Theme) || defaultTheme 31 | ); 32 | 33 | useEffect(() => { 34 | const root = window.document.documentElement; 35 | 36 | root.classList.remove("light", "dark"); 37 | 38 | if (theme === "system") { 39 | const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") 40 | .matches 41 | ? "dark" 42 | : "light"; 43 | 44 | root.classList.add(systemTheme); 45 | return; 46 | } 47 | 48 | root.classList.add(theme); 49 | }, [theme]); 50 | 51 | const value = { 52 | theme, 53 | setTheme: (theme: Theme) => { 54 | localStorage.setItem(storageKey, theme); 55 | setTheme(theme); 56 | }, 57 | }; 58 | 59 | return ( 60 | 61 | {children} 62 | 63 | ); 64 | } 65 | 66 | export const useTheme = () => { 67 | const context = useContext(ThemeProviderContext); 68 | 69 | if (context === undefined) 70 | throw new Error("useTheme must be used within a ThemeProvider"); 71 | 72 | return context; 73 | }; 74 | -------------------------------------------------------------------------------- /src/hooks/use-favorite.ts: -------------------------------------------------------------------------------- 1 | // src/hooks/use-favorites.ts 2 | import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; 3 | import { useLocalStorage } from "./use-local-storage"; 4 | 5 | export interface FavoriteCity { 6 | id: string; 7 | name: string; 8 | lat: number; 9 | lon: number; 10 | country: string; 11 | state?: string; 12 | addedAt: number; 13 | } 14 | 15 | export function useFavorites() { 16 | const [favorites, setFavorites] = useLocalStorage( 17 | "favorites", 18 | [] 19 | ); 20 | const queryClient = useQueryClient(); 21 | 22 | const favoritesQuery = useQuery({ 23 | queryKey: ["favorites"], 24 | queryFn: () => favorites, 25 | initialData: favorites, 26 | staleTime: Infinity, // Since we're managing the data in localStorage 27 | }); 28 | 29 | const addFavorite = useMutation({ 30 | mutationFn: async (city: Omit) => { 31 | const newFavorite: FavoriteCity = { 32 | ...city, 33 | id: `${city.lat}-${city.lon}`, 34 | addedAt: Date.now(), 35 | }; 36 | 37 | // Prevent duplicates 38 | const exists = favorites.some((fav) => fav.id === newFavorite.id); 39 | if (exists) return favorites; 40 | 41 | const newFavorites = [...favorites, newFavorite]; 42 | setFavorites(newFavorites); 43 | return newFavorites; 44 | }, 45 | onSuccess: () => { 46 | // Invalidate and refetch 47 | queryClient.invalidateQueries({ queryKey: ["favorites"] }); 48 | }, 49 | }); 50 | 51 | const removeFavorite = useMutation({ 52 | mutationFn: async (cityId: string) => { 53 | const newFavorites = favorites.filter((city) => city.id !== cityId); 54 | setFavorites(newFavorites); 55 | return newFavorites; 56 | }, 57 | onSuccess: () => { 58 | // Invalidate and refetch 59 | queryClient.invalidateQueries({ queryKey: ["favorites"] }); 60 | }, 61 | }); 62 | 63 | return { 64 | favorites: favoritesQuery.data, 65 | addFavorite, 66 | removeFavorite, 67 | isFavorite: (lat: number, lon: number) => 68 | favorites.some((city) => city.lat === lat && city.lon === lon), 69 | }; 70 | } 71 | -------------------------------------------------------------------------------- /src/hooks/use-geolocation.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import type { Coordinates } from "@/api/types"; 3 | 4 | interface GeolocationState { 5 | coordinates: Coordinates | null; 6 | error: string | null; 7 | isLoading: boolean; 8 | } 9 | 10 | export function useGeolocation() { 11 | const [locationData, setLocationData] = useState({ 12 | coordinates: null, 13 | error: null, 14 | isLoading: true, 15 | }); 16 | 17 | const getLocation = () => { 18 | setLocationData((prev) => ({ ...prev, isLoading: true, error: null })); 19 | 20 | if (!navigator.geolocation) { 21 | setLocationData({ 22 | coordinates: null, 23 | error: "Geolocation is not supported by your browser", 24 | isLoading: false, 25 | }); 26 | return; 27 | } 28 | 29 | navigator.geolocation.getCurrentPosition( 30 | (position) => { 31 | setLocationData({ 32 | coordinates: { 33 | lat: position.coords.latitude, 34 | lon: position.coords.longitude, 35 | }, 36 | error: null, 37 | isLoading: false, 38 | }); 39 | }, 40 | (error) => { 41 | let errorMessage: string; 42 | 43 | switch (error.code) { 44 | case error.PERMISSION_DENIED: 45 | errorMessage = 46 | "Location permission denied. Please enable location access."; 47 | break; 48 | case error.POSITION_UNAVAILABLE: 49 | errorMessage = "Location information is unavailable."; 50 | break; 51 | case error.TIMEOUT: 52 | errorMessage = "Location request timed out."; 53 | break; 54 | default: 55 | errorMessage = "An unknown error occurred."; 56 | } 57 | 58 | setLocationData({ 59 | coordinates: null, 60 | error: errorMessage, 61 | isLoading: false, 62 | }); 63 | }, 64 | { 65 | enableHighAccuracy: true, 66 | timeout: 5000, 67 | maximumAge: 0, 68 | } 69 | ); 70 | }; 71 | 72 | // Get location on component mount 73 | useEffect(() => { 74 | getLocation(); 75 | }, []); 76 | 77 | return { 78 | ...locationData, 79 | getLocation, // Expose method to manually refresh location 80 | }; 81 | } 82 | -------------------------------------------------------------------------------- /src/hooks/use-local-storage.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export function useLocalStorage(key: string, initialValue: T) { 4 | const [storedValue, setStoredValue] = useState(() => { 5 | try { 6 | const item = window.localStorage.getItem(key); 7 | return item ? JSON.parse(item) : initialValue; 8 | } catch (error) { 9 | console.error(error); 10 | return initialValue; 11 | } 12 | }); 13 | 14 | useEffect(() => { 15 | try { 16 | window.localStorage.setItem(key, JSON.stringify(storedValue)); 17 | } catch (error) { 18 | console.error(error); 19 | } 20 | }, [key, storedValue]); 21 | 22 | return [storedValue, setStoredValue] as const; 23 | } 24 | -------------------------------------------------------------------------------- /src/hooks/use-search-history.ts: -------------------------------------------------------------------------------- 1 | import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; 2 | import { useLocalStorage } from "./use-local-storage"; 3 | 4 | interface SearchHistoryItem { 5 | id: string; 6 | query: string; 7 | lat: number; 8 | lon: number; 9 | name: string; 10 | country: string; 11 | state?: string; 12 | searchedAt: number; 13 | } 14 | 15 | export function useSearchHistory() { 16 | const [history, setHistory] = useLocalStorage( 17 | "search-history", 18 | [] 19 | ); 20 | const queryClient = useQueryClient(); 21 | 22 | const historyQuery = useQuery({ 23 | queryKey: ["search-history"], 24 | queryFn: () => history, 25 | initialData: history, 26 | }); 27 | 28 | const addToHistory = useMutation({ 29 | mutationFn: async ( 30 | search: Omit 31 | ) => { 32 | const newSearch: SearchHistoryItem = { 33 | ...search, 34 | id: `${search.lat}-${search.lon}-${Date.now()}`, 35 | searchedAt: Date.now(), 36 | }; 37 | 38 | // Remove duplicates and keep only last 10 searches 39 | const filteredHistory = history.filter( 40 | (item) => !(item.lat === search.lat && item.lon === search.lon) 41 | ); 42 | const newHistory = [newSearch, ...filteredHistory].slice(0, 10); 43 | 44 | setHistory(newHistory); 45 | return newHistory; 46 | }, 47 | onSuccess: (newHistory) => { 48 | queryClient.setQueryData(["search-history"], newHistory); 49 | }, 50 | }); 51 | 52 | const clearHistory = useMutation({ 53 | mutationFn: async () => { 54 | setHistory([]); 55 | return []; 56 | }, 57 | onSuccess: () => { 58 | queryClient.setQueryData(["search-history"], []); 59 | }, 60 | }); 61 | 62 | return { 63 | history: historyQuery.data ?? [], 64 | addToHistory, 65 | clearHistory, 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /src/hooks/use-weather.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { weatherAPI } from "@/api/weather"; 3 | import type { Coordinates } from "@/api/types"; 4 | 5 | export const WEATHER_KEYS = { 6 | weather: (coords: Coordinates) => ["weather", coords] as const, 7 | forecast: (coords: Coordinates) => ["forecast", coords] as const, 8 | location: (coords: Coordinates) => ["location", coords] as const, 9 | search: (query: string) => ["location-search", query] as const, 10 | } as const; 11 | 12 | export function useWeatherQuery(coordinates: Coordinates | null) { 13 | return useQuery({ 14 | queryKey: WEATHER_KEYS.weather(coordinates ?? { lat: 0, lon: 0 }), 15 | queryFn: () => 16 | coordinates ? weatherAPI.getCurrentWeather(coordinates) : null, 17 | enabled: !!coordinates, 18 | }); 19 | } 20 | 21 | export function useForecastQuery(coordinates: Coordinates | null) { 22 | return useQuery({ 23 | queryKey: WEATHER_KEYS.forecast(coordinates ?? { lat: 0, lon: 0 }), 24 | queryFn: () => (coordinates ? weatherAPI.getForecast(coordinates) : null), 25 | enabled: !!coordinates, 26 | }); 27 | } 28 | 29 | export function useReverseGeocodeQuery(coordinates: Coordinates | null) { 30 | return useQuery({ 31 | queryKey: WEATHER_KEYS.location(coordinates ?? { lat: 0, lon: 0 }), 32 | queryFn: () => 33 | coordinates ? weatherAPI.reverseGeocode(coordinates) : null, 34 | enabled: !!coordinates, 35 | }); 36 | } 37 | 38 | export function useLocationSearch(query: string) { 39 | return useQuery({ 40 | queryKey: WEATHER_KEYS.search(query), 41 | queryFn: () => weatherAPI.searchLocations(query), 42 | enabled: query.length >= 3, 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 0 0% 3.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 0 0% 3.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 0 0% 3.9%; 13 | --primary: 0 0% 9%; 14 | --primary-foreground: 0 0% 98%; 15 | --secondary: 0 0% 96.1%; 16 | --secondary-foreground: 0 0% 9%; 17 | --muted: 0 0% 96.1%; 18 | --muted-foreground: 0 0% 45.1%; 19 | --accent: 0 0% 96.1%; 20 | --accent-foreground: 0 0% 9%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 0 0% 98%; 23 | --border: 0 0% 89.8%; 24 | --input: 0 0% 89.8%; 25 | --ring: 0 0% 3.9%; 26 | --chart-1: 12 76% 61%; 27 | --chart-2: 173 58% 39%; 28 | --chart-3: 197 37% 24%; 29 | --chart-4: 43 74% 66%; 30 | --chart-5: 27 87% 67%; 31 | --radius: 0.5rem; 32 | } 33 | .dark { 34 | --background: 0 0% 3.9%; 35 | --foreground: 0 0% 98%; 36 | --card: 0 0% 3.9%; 37 | --card-foreground: 0 0% 98%; 38 | --popover: 0 0% 3.9%; 39 | --popover-foreground: 0 0% 98%; 40 | --primary: 0 0% 98%; 41 | --primary-foreground: 0 0% 9%; 42 | --secondary: 0 0% 14.9%; 43 | --secondary-foreground: 0 0% 98%; 44 | --muted: 0 0% 14.9%; 45 | --muted-foreground: 0 0% 63.9%; 46 | --accent: 0 0% 14.9%; 47 | --accent-foreground: 0 0% 98%; 48 | --destructive: 0 62.8% 30.6%; 49 | --destructive-foreground: 0 0% 98%; 50 | --border: 0 0% 14.9%; 51 | --input: 0 0% 14.9%; 52 | --ring: 0 0% 83.1%; 53 | --chart-1: 220 70% 50%; 54 | --chart-2: 160 60% 45%; 55 | --chart-3: 30 80% 55%; 56 | --chart-4: 280 65% 60%; 57 | --chart-5: 340 75% 55%; 58 | } 59 | } 60 | @layer base { 61 | * { 62 | @apply border-border; 63 | } 64 | body { 65 | @apply bg-background text-foreground; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.tsx' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /src/pages/city-page.tsx: -------------------------------------------------------------------------------- 1 | import { useParams, useSearchParams } from "react-router-dom"; 2 | import { useWeatherQuery, useForecastQuery } from "@/hooks/use-weather"; 3 | import { Alert, AlertDescription } from "@/components/ui/alert"; 4 | import { AlertTriangle } from "lucide-react"; 5 | import { CurrentWeather } from "../components/current-weather"; 6 | import { HourlyTemperature } from "../components/hourly-temprature"; 7 | import { WeatherDetails } from "../components/weather-details"; 8 | import { WeatherForecast } from "../components/weather-forecast"; 9 | import WeatherSkeleton from "../components/loading-skeleton"; 10 | import { FavoriteButton } from "@/components/favorite-button"; 11 | 12 | export function CityPage() { 13 | const [searchParams] = useSearchParams(); 14 | const params = useParams(); 15 | const lat = parseFloat(searchParams.get("lat") || "0"); 16 | const lon = parseFloat(searchParams.get("lon") || "0"); 17 | 18 | const coordinates = { lat, lon }; 19 | 20 | const weatherQuery = useWeatherQuery(coordinates); 21 | const forecastQuery = useForecastQuery(coordinates); 22 | 23 | if (weatherQuery.error || forecastQuery.error) { 24 | return ( 25 | 26 | 27 | 28 | Failed to load weather data. Please try again. 29 | 30 | 31 | ); 32 | } 33 | 34 | if (!weatherQuery.data || !forecastQuery.data || !params.cityName) { 35 | return ; 36 | } 37 | 38 | return ( 39 |
40 |
41 |

42 | {params.cityName}, {weatherQuery.data.sys.country} 43 |

44 |
45 | 48 |
49 |
50 | 51 |
52 | 53 | 54 |
55 | 56 | 57 |
58 |
59 |
60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /src/pages/weather-dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useForecastQuery, 3 | useReverseGeocodeQuery, 4 | useWeatherQuery, 5 | } from "@/hooks/use-weather"; 6 | import { CurrentWeather } from "../components/current-weather"; 7 | import { Alert, AlertDescription, AlertTitle } from "../components/ui/alert"; 8 | import { Button } from "../components/ui/button"; 9 | import { MapPin, AlertTriangle, RefreshCw } from "lucide-react"; 10 | import { useGeolocation } from "@/hooks/use-geolocation"; 11 | import { WeatherDetails } from "../components/weather-details"; 12 | import { WeatherForecast } from "../components/weather-forecast"; 13 | import { HourlyTemperature } from "../components/hourly-temprature"; 14 | import WeatherSkeleton from "../components/loading-skeleton"; 15 | import { FavoriteCities } from "@/components/favorite-cities"; 16 | 17 | export function WeatherDashboard() { 18 | const { 19 | coordinates, 20 | error: locationError, 21 | isLoading: locationLoading, 22 | getLocation, 23 | } = useGeolocation(); 24 | 25 | const weatherQuery = useWeatherQuery(coordinates); 26 | const forecastQuery = useForecastQuery(coordinates); 27 | const locationQuery = useReverseGeocodeQuery(coordinates); 28 | 29 | // Function to refresh all data 30 | const handleRefresh = () => { 31 | getLocation(); 32 | if (coordinates) { 33 | weatherQuery.refetch(); 34 | forecastQuery.refetch(); 35 | locationQuery.refetch(); 36 | } 37 | }; 38 | 39 | if (locationLoading) { 40 | return ; 41 | } 42 | 43 | if (locationError) { 44 | return ( 45 | 46 | 47 | Location Error 48 | 49 |

{locationError}

50 | 54 |
55 |
56 | ); 57 | } 58 | 59 | if (!coordinates) { 60 | return ( 61 | 62 | 63 | Location Required 64 | 65 |

Please enable location access to see your local weather.

66 | 70 |
71 |
72 | ); 73 | } 74 | 75 | const locationName = locationQuery.data?.[0]; 76 | 77 | if (weatherQuery.error || forecastQuery.error) { 78 | return ( 79 | 80 | 81 | Error 82 | 83 |

Failed to fetch weather data. Please try again.

84 | 88 |
89 |
90 | ); 91 | } 92 | 93 | if (!weatherQuery.data || !forecastQuery.data) { 94 | return ; 95 | } 96 | 97 | return ( 98 |
99 | 100 |
101 |

My Location

102 | 114 |
115 | 116 |
117 |
118 | 122 | 123 |
124 | 125 |
126 | 127 | 128 |
129 |
130 |
131 | ); 132 | } 133 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | darkMode: ["class"], 4 | content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"], 5 | theme: { 6 | extend: { 7 | borderRadius: { 8 | lg: 'var(--radius)', 9 | md: 'calc(var(--radius) - 2px)', 10 | sm: 'calc(var(--radius) - 4px)' 11 | }, 12 | colors: { 13 | background: 'hsl(var(--background))', 14 | foreground: 'hsl(var(--foreground))', 15 | card: { 16 | DEFAULT: 'hsl(var(--card))', 17 | foreground: 'hsl(var(--card-foreground))' 18 | }, 19 | popover: { 20 | DEFAULT: 'hsl(var(--popover))', 21 | foreground: 'hsl(var(--popover-foreground))' 22 | }, 23 | primary: { 24 | DEFAULT: 'hsl(var(--primary))', 25 | foreground: 'hsl(var(--primary-foreground))' 26 | }, 27 | secondary: { 28 | DEFAULT: 'hsl(var(--secondary))', 29 | foreground: 'hsl(var(--secondary-foreground))' 30 | }, 31 | muted: { 32 | DEFAULT: 'hsl(var(--muted))', 33 | foreground: 'hsl(var(--muted-foreground))' 34 | }, 35 | accent: { 36 | DEFAULT: 'hsl(var(--accent))', 37 | foreground: 'hsl(var(--accent-foreground))' 38 | }, 39 | destructive: { 40 | DEFAULT: 'hsl(var(--destructive))', 41 | foreground: 'hsl(var(--destructive-foreground))' 42 | }, 43 | border: 'hsl(var(--border))', 44 | input: 'hsl(var(--input))', 45 | ring: 'hsl(var(--ring))', 46 | chart: { 47 | '1': 'hsl(var(--chart-1))', 48 | '2': 'hsl(var(--chart-2))', 49 | '3': 'hsl(var(--chart-3))', 50 | '4': 'hsl(var(--chart-4))', 51 | '5': 'hsl(var(--chart-5))' 52 | } 53 | } 54 | } 55 | }, 56 | plugins: [require("tailwindcss-animate")], 57 | }; 58 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "Bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | "baseUrl": ".", 19 | "paths": { 20 | "@/*": ["./src/*"] 21 | }, 22 | 23 | /* Linting */ 24 | "strict": true, 25 | "noUnusedLocals": true, 26 | "noUnusedParameters": true, 27 | "noFallthroughCasesInSwitch": true, 28 | "noUncheckedSideEffectImports": true 29 | }, 30 | "include": ["src"] 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.app.json" 6 | }, 7 | { 8 | "path": "./tsconfig.node.json" 9 | } 10 | ], 11 | "compilerOptions": { 12 | "baseUrl": ".", 13 | "paths": { 14 | "@/*": ["./src/*"] 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "Bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import react from "@vitejs/plugin-react"; 3 | import { defineConfig } from "vite"; 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | resolve: { 8 | alias: { 9 | "@": path.resolve(__dirname, "./src"), 10 | }, 11 | }, 12 | }); 13 | --------------------------------------------------------------------------------