├── .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 | 
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 |

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}]({`https://openweathermap.org/img/wn/${weather.weather[0].icon}.png`})
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 |

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 |
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 |
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 |
--------------------------------------------------------------------------------