mirror of
https://github.com/piyush-eon/tanstack-query-weather-app.git
synced 2025-11-24 05:11:19 +00:00
Klimate App
This commit is contained in:
commit
d1ad533352
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.env
|
||||
50
README.md
Normal file
50
README.md
Normal file
@ -0,0 +1,50 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
|
||||
- Optionally add `...tseslint.configs.stylisticTypeChecked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import react from 'eslint-plugin-react'
|
||||
|
||||
export default tseslint.config({
|
||||
// Set the react version
|
||||
settings: { react: { version: '18.3' } },
|
||||
plugins: {
|
||||
// Add the react plugin
|
||||
react,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended rules
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
},
|
||||
})
|
||||
```
|
||||
20
components.json
Normal file
20
components.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
28
eslint.config.js
Normal file
28
eslint.config.js
Normal file
@ -0,0 +1,28 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6026
package-lock.json
generated
Normal file
6026
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
53
package.json
Normal file
53
package.json
Normal file
@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "weather-app",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-icons": "^1.3.1",
|
||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"@tanstack/react-query": "^5.59.16",
|
||||
"@tanstack/react-query-devtools": "^5.59.16",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.454.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"recharts": "^2.13.3",
|
||||
"sonner": "^1.5.0",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.13.0",
|
||||
"@types/node": "^22.8.6",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.13.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.14",
|
||||
"globals": "^15.11.0",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.11.0",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
public/logo.png
Normal file
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
BIN
public/logo2.png
Normal file
BIN
public/logo2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
0
src/App.css
Normal file
0
src/App.css
Normal file
40
src/App.tsx
Normal file
40
src/App.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { Toaster } from "./components/ui/sonner";
|
||||
import { WeatherDashboard } from "./pages/weather-dashboard";
|
||||
import { Layout } from "./components/layout";
|
||||
import { ThemeProvider } from "./context/theme-provider";
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
import { CityPage } from "./pages/city-page";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<ThemeProvider defaultTheme="dark">
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<WeatherDashboard />} />
|
||||
<Route path="/city/:cityName" element={<CityPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
<Toaster richColors />
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
9
src/api/config.ts
Normal file
9
src/api/config.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export const API_CONFIG = {
|
||||
BASE_URL: "https://api.openweathermap.org/data/2.5",
|
||||
GEO: "https://api.openweathermap.org/geo/1.0",
|
||||
API_KEY: import.meta.env.VITE_OPENWEATHER_API_KEY,
|
||||
DEFAULT_PARAMS: {
|
||||
units: "metric",
|
||||
appid: import.meta.env.VITE_OPENWEATHER_API_KEY,
|
||||
},
|
||||
};
|
||||
60
src/api/types.ts
Normal file
60
src/api/types.ts
Normal file
@ -0,0 +1,60 @@
|
||||
export interface Coordinates {
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
export interface GeocodingResponse {
|
||||
name: string;
|
||||
local_names?: Record<string, string>;
|
||||
lat: number;
|
||||
lon: number;
|
||||
country: string;
|
||||
state?: string;
|
||||
}
|
||||
|
||||
export interface WeatherCondition {
|
||||
id: number;
|
||||
main: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface WeatherData {
|
||||
coord: Coordinates;
|
||||
weather: WeatherCondition[];
|
||||
main: {
|
||||
temp: number;
|
||||
feels_like: number;
|
||||
temp_min: number;
|
||||
temp_max: number;
|
||||
pressure: number;
|
||||
humidity: number;
|
||||
};
|
||||
wind: {
|
||||
speed: number;
|
||||
deg: number;
|
||||
};
|
||||
sys: {
|
||||
sunrise: number;
|
||||
sunset: number;
|
||||
country: string;
|
||||
};
|
||||
name: string;
|
||||
dt: number;
|
||||
}
|
||||
|
||||
export interface ForecastData {
|
||||
list: Array<{
|
||||
dt: number;
|
||||
main: WeatherData["main"];
|
||||
weather: WeatherData["weather"];
|
||||
wind: WeatherData["wind"];
|
||||
dt_txt: string;
|
||||
}>;
|
||||
city: {
|
||||
name: string;
|
||||
country: string;
|
||||
sunrise: number;
|
||||
sunset: number;
|
||||
};
|
||||
}
|
||||
67
src/api/weather.ts
Normal file
67
src/api/weather.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { API_CONFIG } from "./config";
|
||||
import type {
|
||||
WeatherData,
|
||||
ForecastData,
|
||||
GeocodingResponse,
|
||||
Coordinates,
|
||||
} from "./types";
|
||||
|
||||
class WeatherAPI {
|
||||
private createUrl(endpoint: string, params: Record<string, string | number>) {
|
||||
const searchParams = new URLSearchParams({
|
||||
appid: API_CONFIG.API_KEY,
|
||||
...params,
|
||||
});
|
||||
return `${endpoint}?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
private async fetchData<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Weather API Error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getCurrentWeather({ lat, lon }: Coordinates): Promise<WeatherData> {
|
||||
const url = this.createUrl(`${API_CONFIG.BASE_URL}/weather`, {
|
||||
lat: lat.toString(),
|
||||
lon: lon.toString(),
|
||||
units: "metric",
|
||||
});
|
||||
return this.fetchData<WeatherData>(url);
|
||||
}
|
||||
|
||||
async getForecast({ lat, lon }: Coordinates): Promise<ForecastData> {
|
||||
const url = this.createUrl(`${API_CONFIG.BASE_URL}/forecast`, {
|
||||
lat: lat.toString(),
|
||||
lon: lon.toString(),
|
||||
units: "metric",
|
||||
});
|
||||
return this.fetchData<ForecastData>(url);
|
||||
}
|
||||
|
||||
async reverseGeocode({
|
||||
lat,
|
||||
lon,
|
||||
}: Coordinates): Promise<GeocodingResponse[]> {
|
||||
const url = this.createUrl(`${API_CONFIG.GEO}/reverse`, {
|
||||
lat: lat.toString(),
|
||||
lon: lon.toString(),
|
||||
limit: "1",
|
||||
});
|
||||
return this.fetchData<GeocodingResponse[]>(url);
|
||||
}
|
||||
|
||||
async searchLocations(query: string): Promise<GeocodingResponse[]> {
|
||||
const url = this.createUrl(`${API_CONFIG.GEO}/direct`, {
|
||||
q: query,
|
||||
limit: "5",
|
||||
});
|
||||
return this.fetchData<GeocodingResponse[]>(url);
|
||||
}
|
||||
}
|
||||
|
||||
export const weatherAPI = new WeatherAPI();
|
||||
168
src/components/city-search.tsx
Normal file
168
src/components/city-search.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { format } from "date-fns";
|
||||
import { Search, Loader2, Clock, Star, XCircle } from "lucide-react";
|
||||
import { useLocationSearch } from "@/hooks/use-weather";
|
||||
import { useSearchHistory } from "@/hooks/use-search-history";
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useFavorites } from "@/hooks/use-favorite";
|
||||
|
||||
export function CitySearch() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: locations, isLoading } = useLocationSearch(query);
|
||||
const { favorites } = useFavorites();
|
||||
const { history, clearHistory, addToHistory } = useSearchHistory();
|
||||
|
||||
const handleSelect = (cityData: string) => {
|
||||
const [lat, lon, name, country] = cityData.split("|");
|
||||
|
||||
// Add to search history
|
||||
addToHistory.mutate({
|
||||
query,
|
||||
name,
|
||||
lat: parseFloat(lat),
|
||||
lon: parseFloat(lon),
|
||||
country,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
navigate(`/city/${name}?lat=${lat}&lon=${lon}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="relative w-full justify-start text-sm text-muted-foreground sm:pr-12 md:w-40 lg:w-64"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
Search cities...
|
||||
</Button>
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search cities..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
{query.length > 2 && !isLoading && (
|
||||
<CommandEmpty>No cities found.</CommandEmpty>
|
||||
)}
|
||||
|
||||
{/* Favorites Section */}
|
||||
{favorites.length > 0 && (
|
||||
<CommandGroup heading="Favorites">
|
||||
{favorites.map((city) => (
|
||||
<CommandItem
|
||||
key={city.id}
|
||||
value={`${city.lat}|${city.lon}|${city.name}|${city.country}`}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
<Star className="mr-2 h-4 w-4 text-yellow-500" />
|
||||
<span>{city.name}</span>
|
||||
{city.state && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
, {city.state}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
, {city.country}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Search History Section */}
|
||||
{history.length > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<div className="flex items-center justify-between px-2 my-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recent Searches
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => clearHistory.mutate()}
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
{history.map((item) => (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
value={`${item.lat}|${item.lon}|${item.name}|${item.country}`}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
<Clock className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||
<span>{item.name}</span>
|
||||
{item.state && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
, {item.state}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
, {item.country}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{format(item.searchedAt, "MMM d, h:mm a")}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Search Results */}
|
||||
<CommandSeparator />
|
||||
{locations && locations.length > 0 && (
|
||||
<CommandGroup heading="Suggestions">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{locations?.map((location) => (
|
||||
<CommandItem
|
||||
key={`${location.lat}-${location.lon}`}
|
||||
value={`${location.lat}|${location.lon}|${location.name}|${location.country}`}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
<span>{location.name}</span>
|
||||
{location.state && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
, {location.state}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
, {location.country}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</CommandDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
98
src/components/current-weather.tsx
Normal file
98
src/components/current-weather.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
import { Card, CardContent } from "./ui/card";
|
||||
import { ArrowDown, ArrowUp, Droplets, Wind } from "lucide-react";
|
||||
import type { WeatherData, GeocodingResponse } from "@/api/types";
|
||||
|
||||
interface CurrentWeatherProps {
|
||||
data: WeatherData;
|
||||
locationName?: GeocodingResponse;
|
||||
}
|
||||
|
||||
export function CurrentWeather({ data, locationName }: CurrentWeatherProps) {
|
||||
const {
|
||||
weather: [currentWeather],
|
||||
main: { temp, feels_like, temp_min, temp_max, humidity },
|
||||
wind: { speed },
|
||||
} = data;
|
||||
|
||||
// Format temperature
|
||||
const formatTemp = (temp: number) => `${Math.round(temp)}°`;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="p-6">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center">
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
{locationName?.name}
|
||||
</h2>
|
||||
{locationName?.state && (
|
||||
<span className="text-muted-foreground">
|
||||
, {locationName.state}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{locationName?.country}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-7xl font-bold tracking-tighter">
|
||||
{formatTemp(temp)}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Feels like {formatTemp(feels_like)}
|
||||
</p>
|
||||
<div className="flex gap-2 text-sm font-medium">
|
||||
<span className="flex items-center gap-1 text-blue-500">
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
{formatTemp(temp_min)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-red-500">
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
{formatTemp(temp_max)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Droplets className="h-4 w-4 text-blue-500" />
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">Humidity</p>
|
||||
<p className="text-sm text-muted-foreground">{humidity}%</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Wind className="h-4 w-4 text-blue-500" />
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">Wind Speed</p>
|
||||
<p className="text-sm text-muted-foreground">{speed} m/s</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="relative flex aspect-square w-full max-w-[200px] items-center justify-center">
|
||||
<img
|
||||
src={`https://openweathermap.org/img/wn/${currentWeather.icon}@4x.png`}
|
||||
alt={currentWeather.description}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
<div className="absolute bottom-0 text-center">
|
||||
<p className="text-sm font-medium capitalize">
|
||||
{currentWeather.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
43
src/components/favorite-button.tsx
Normal file
43
src/components/favorite-button.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
// src/components/weather/favorite-button.tsx
|
||||
import { Star } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { WeatherData } from "@/api/types";
|
||||
import { useFavorites } from "@/hooks/use-favorite";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface FavoriteButtonProps {
|
||||
data: WeatherData;
|
||||
}
|
||||
|
||||
export function FavoriteButton({ data }: FavoriteButtonProps) {
|
||||
const { addFavorite, removeFavorite, isFavorite } = useFavorites();
|
||||
const isCurrentlyFavorite = isFavorite(data.coord.lat, data.coord.lon);
|
||||
|
||||
const handleToggleFavorite = () => {
|
||||
if (isCurrentlyFavorite) {
|
||||
removeFavorite.mutate(`${data.coord.lat}-${data.coord.lon}`);
|
||||
toast.error(`Removed ${data.name} from Favorites`);
|
||||
} else {
|
||||
addFavorite.mutate({
|
||||
name: data.name,
|
||||
lat: data.coord.lat,
|
||||
lon: data.coord.lon,
|
||||
country: data.sys.country,
|
||||
});
|
||||
toast.success(`Added ${data.name} to Favorites`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={isCurrentlyFavorite ? "default" : "outline"}
|
||||
size="icon"
|
||||
onClick={handleToggleFavorite}
|
||||
className={isCurrentlyFavorite ? "bg-yellow-500 hover:bg-yellow-600" : ""}
|
||||
>
|
||||
<Star
|
||||
className={`h-4 w-4 ${isCurrentlyFavorite ? "fill-current" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
109
src/components/favorite-cities.tsx
Normal file
109
src/components/favorite-cities.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
// src/components/weather/favorite-cities.tsx
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useWeatherQuery } from "@/hooks/use-weather";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { X, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useFavorites } from "@/hooks/use-favorite";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface FavoriteCityTabletProps {
|
||||
id: string;
|
||||
name: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
function FavoriteCityTablet({
|
||||
id,
|
||||
name,
|
||||
lat,
|
||||
lon,
|
||||
onRemove,
|
||||
}: FavoriteCityTabletProps) {
|
||||
const navigate = useNavigate();
|
||||
const { data: weather, isLoading } = useWeatherQuery({ lat, lon });
|
||||
|
||||
const handleClick = () => {
|
||||
navigate(`/city/${name}?lat=${lat}&lon=${lon}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className="relative flex min-w-[250px] cursor-pointer items-center gap-3 rounded-lg border bg-card p-4 pr-8 shadow-sm transition-all hover:shadow-md"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1 h-6 w-6 rounded-full p-0 hover:text-destructive-foreground group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(id);
|
||||
toast.error(`Removed ${name} from Favorites`);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex h-8 items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : weather ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={`https://openweathermap.org/img/wn/${weather.weather[0].icon}.png`}
|
||||
alt={weather.weather[0].description}
|
||||
className="h-8 w-8"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium">{name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{weather.sys.country}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto text-right">
|
||||
<p className="text-xl font-bold">
|
||||
{Math.round(weather.main.temp)}°
|
||||
</p>
|
||||
<p className="text-xs capitalize text-muted-foreground">
|
||||
{weather.weather[0].description}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FavoriteCities() {
|
||||
const { favorites, removeFavorite } = useFavorites();
|
||||
|
||||
if (!favorites.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-xl font-bold tracking-tight">Favorites</h1>
|
||||
<ScrollArea className="w-full pb-4">
|
||||
<div className="flex gap-4">
|
||||
{favorites.map((city) => (
|
||||
<FavoriteCityTablet
|
||||
key={city.id}
|
||||
{...city}
|
||||
onRemove={() => removeFavorite.mutate(city.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" className="mt-2" />
|
||||
</ScrollArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
src/components/header.tsx
Normal file
27
src/components/header.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { CitySearch } from "./city-search";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { useTheme } from "@/context/theme-provider";
|
||||
|
||||
export function Header() {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-2">
|
||||
<div className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||
<Link to={"/"}>
|
||||
<img
|
||||
src={theme === "dark" ? "/logo.png" : "/logo2.png"}
|
||||
alt="Klimate logo"
|
||||
className="h-14"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<CitySearch />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
107
src/components/hourly-temprature.tsx
Normal file
107
src/components/hourly-temprature.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { format } from "date-fns";
|
||||
import type { ForecastData } from "@/api/types";
|
||||
|
||||
interface HourlyTemperatureProps {
|
||||
data: ForecastData;
|
||||
}
|
||||
|
||||
interface ChartData {
|
||||
time: string;
|
||||
temp: number;
|
||||
feels_like: number;
|
||||
}
|
||||
|
||||
export function HourlyTemperature({ data }: HourlyTemperatureProps) {
|
||||
// Get today's forecast data and format for chart
|
||||
|
||||
const chartData: ChartData[] = data.list
|
||||
.slice(0, 8) // Get next 24 hours (3-hour intervals)
|
||||
.map((item) => ({
|
||||
time: format(new Date(item.dt * 1000), "ha"),
|
||||
temp: Math.round(item.main.temp),
|
||||
feels_like: Math.round(item.main.feels_like),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card className="flex-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Today's Temperature</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData}>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}°`}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
Temperature
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
{payload[0].value}°
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
Feels Like
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
{payload[1].value}°
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="temp"
|
||||
stroke="#2563eb"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="feels_like"
|
||||
stroke="#64748b"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
strokeDasharray="5 5"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
18
src/components/layout.tsx
Normal file
18
src/components/layout.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Header } from "./header";
|
||||
|
||||
export function Layout({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<div className=" bg-gradient-to-br from-background to-muted">
|
||||
<Header />
|
||||
<main className="min-h-screen container mx-auto px-4 py-8">
|
||||
{children}
|
||||
</main>
|
||||
<footer className="border-t backdrop-blur supports-[backdrop-filter]:bg-background/60 py-12">
|
||||
<div className="container mx-auto px-4 text-center text-gray-200">
|
||||
<p>Made with 💗 by RoadsideCoder</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
src/components/loading-skeleton.tsx
Normal file
18
src/components/loading-skeleton.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
|
||||
function WeatherSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-6">
|
||||
<Skeleton className="h-[300px] w-full rounded-lg" />
|
||||
<Skeleton className="h-[300px] w-full rounded-lg" />
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Skeleton className="h-[300px] w-full rounded-lg" />
|
||||
<Skeleton className="h-[300px] w-full rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WeatherSkeleton;
|
||||
23
src/components/theme-toggle.tsx
Normal file
23
src/components/theme-toggle.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "@/context/theme-provider";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
className={`flex items-center cursor-pointer transition-transform duration-500 ${
|
||||
isDark ? "rotate-180" : "rotate-0"
|
||||
}`}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="h-6 w-6 text-yellow-500 rotate-0 transition-all" />
|
||||
) : (
|
||||
<Moon className="h-6 w-6 text-blue-500 rotate-0 transition-all" />
|
||||
)}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
src/components/ui/alert.tsx
Normal file
59
src/components/ui/alert.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"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",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
57
src/components/ui/button.tsx
Normal file
57
src/components/ui/button.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"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",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
76
src/components/ui/card.tsx
Normal file
76
src/components/ui/card.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
153
src/components/ui/command.tsx
Normal file
153
src/components/ui/command.tsx
Normal file
@ -0,0 +1,153 @@
|
||||
import * as React from "react"
|
||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
||||
import { MagnifyingGlassIcon } from "@radix-ui/react-icons"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
interface CommandDialogProps extends DialogProps {}
|
||||
|
||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
122
src/components/ui/dialog.tsx
Normal file
122
src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { Cross2Icon } from "@radix-ui/react-icons"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
46
src/components/ui/scroll-area.tsx
Normal file
46
src/components/ui/scroll-area.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
15
src/components/ui/skeleton.tsx
Normal file
15
src/components/ui/skeleton.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
29
src/components/ui/sonner.tsx
Normal file
29
src/components/ui/sonner.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner } from "sonner"
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
30
src/components/ui/tooltip.tsx
Normal file
30
src/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
78
src/components/weather-details.tsx
Normal file
78
src/components/weather-details.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||
import { Sunrise, Sunset, Compass, Gauge } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import type { WeatherData } from "@/api/types";
|
||||
|
||||
interface WeatherDetailsProps {
|
||||
data: WeatherData;
|
||||
}
|
||||
|
||||
export function WeatherDetails({ data }: WeatherDetailsProps) {
|
||||
const { wind, main, sys } = data;
|
||||
|
||||
// Format time using date-fns
|
||||
const formatTime = (timestamp: number) => {
|
||||
return format(new Date(timestamp * 1000), "h:mm a");
|
||||
};
|
||||
|
||||
// Convert wind degree to direction
|
||||
const getWindDirection = (degree: number) => {
|
||||
const directions = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
||||
const index =
|
||||
Math.round(((degree %= 360) < 0 ? degree + 360 : degree) / 45) % 8;
|
||||
return directions[index];
|
||||
};
|
||||
|
||||
const details = [
|
||||
{
|
||||
title: "Sunrise",
|
||||
value: formatTime(sys.sunrise),
|
||||
icon: Sunrise,
|
||||
color: "text-orange-500",
|
||||
},
|
||||
{
|
||||
title: "Sunset",
|
||||
value: formatTime(sys.sunset),
|
||||
icon: Sunset,
|
||||
color: "text-blue-500",
|
||||
},
|
||||
{
|
||||
title: "Wind Direction",
|
||||
value: `${getWindDirection(wind.deg)} (${wind.deg}°)`,
|
||||
icon: Compass,
|
||||
color: "text-green-500",
|
||||
},
|
||||
{
|
||||
title: "Pressure",
|
||||
value: `${main.pressure} hPa`,
|
||||
icon: Gauge,
|
||||
color: "text-purple-500",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Weather Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{details.map((detail) => (
|
||||
<div
|
||||
key={detail.title}
|
||||
className="flex items-center gap-3 rounded-lg border p-4"
|
||||
>
|
||||
<detail.icon className={`h-5 w-5 ${detail.color}`} />
|
||||
<div>
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{detail.title}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{detail.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
100
src/components/weather-forecast.tsx
Normal file
100
src/components/weather-forecast.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||
import { ArrowDown, ArrowUp, Droplets, Wind } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import type { ForecastData } from "@/api/types";
|
||||
|
||||
interface WeatherForecastProps {
|
||||
data: ForecastData;
|
||||
}
|
||||
|
||||
interface DailyForecast {
|
||||
date: number;
|
||||
temp_min: number;
|
||||
temp_max: number;
|
||||
humidity: number;
|
||||
wind: number;
|
||||
weather: {
|
||||
id: number;
|
||||
main: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function WeatherForecast({ data }: WeatherForecastProps) {
|
||||
// Group forecast by day and get daily min/max
|
||||
const dailyForecasts = data.list.reduce((acc, forecast) => {
|
||||
const date = format(new Date(forecast.dt * 1000), "yyyy-MM-dd");
|
||||
|
||||
if (!acc[date]) {
|
||||
acc[date] = {
|
||||
temp_min: forecast.main.temp_min,
|
||||
temp_max: forecast.main.temp_max,
|
||||
humidity: forecast.main.humidity,
|
||||
wind: forecast.wind.speed,
|
||||
weather: forecast.weather[0],
|
||||
date: forecast.dt,
|
||||
};
|
||||
} else {
|
||||
acc[date].temp_min = Math.min(acc[date].temp_min, forecast.main.temp_min);
|
||||
acc[date].temp_max = Math.max(acc[date].temp_max, forecast.main.temp_max);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {} as Record<string, DailyForecast>);
|
||||
|
||||
// Get next 5 days
|
||||
const nextDays = Object.values(dailyForecasts).slice(1, 6);
|
||||
|
||||
// Format temperature
|
||||
const formatTemp = (temp: number) => `${Math.round(temp)}°`;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>5-Day Forecast</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4">
|
||||
{nextDays.map((day) => (
|
||||
<div
|
||||
key={day.date}
|
||||
className="grid grid-cols-3 items-center gap-4 rounded-lg border p-4"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{format(new Date(day.date * 1000), "EEE, MMM d")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground capitalize">
|
||||
{day.weather.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-4">
|
||||
<span className="flex items-center text-blue-500">
|
||||
<ArrowDown className="mr-1 h-4 w-4" />
|
||||
{formatTemp(day.temp_min)}
|
||||
</span>
|
||||
<span className="flex items-center text-red-500">
|
||||
<ArrowUp className="mr-1 h-4 w-4" />
|
||||
{formatTemp(day.temp_max)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<span className="flex items-center gap-1">
|
||||
<Droplets className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm">{day.humidity}%</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Wind className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm">{day.wind}m/s</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
73
src/context/theme-provider.tsx
Normal file
73
src/context/theme-provider.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = "dark" | "light" | "system";
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
};
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
};
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
};
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
|
||||
return context;
|
||||
};
|
||||
70
src/hooks/use-favorite.ts
Normal file
70
src/hooks/use-favorite.ts
Normal file
@ -0,0 +1,70 @@
|
||||
// src/hooks/use-favorites.ts
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useLocalStorage } from "./use-local-storage";
|
||||
|
||||
export interface FavoriteCity {
|
||||
id: string;
|
||||
name: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
country: string;
|
||||
state?: string;
|
||||
addedAt: number;
|
||||
}
|
||||
|
||||
export function useFavorites() {
|
||||
const [favorites, setFavorites] = useLocalStorage<FavoriteCity[]>(
|
||||
"favorites",
|
||||
[]
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const favoritesQuery = useQuery({
|
||||
queryKey: ["favorites"],
|
||||
queryFn: () => favorites,
|
||||
initialData: favorites,
|
||||
staleTime: Infinity, // Since we're managing the data in localStorage
|
||||
});
|
||||
|
||||
const addFavorite = useMutation({
|
||||
mutationFn: async (city: Omit<FavoriteCity, "id" | "addedAt">) => {
|
||||
const newFavorite: FavoriteCity = {
|
||||
...city,
|
||||
id: `${city.lat}-${city.lon}`,
|
||||
addedAt: Date.now(),
|
||||
};
|
||||
|
||||
// Prevent duplicates
|
||||
const exists = favorites.some((fav) => fav.id === newFavorite.id);
|
||||
if (exists) return favorites;
|
||||
|
||||
const newFavorites = [...favorites, newFavorite];
|
||||
setFavorites(newFavorites);
|
||||
return newFavorites;
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate and refetch
|
||||
queryClient.invalidateQueries({ queryKey: ["favorites"] });
|
||||
},
|
||||
});
|
||||
|
||||
const removeFavorite = useMutation({
|
||||
mutationFn: async (cityId: string) => {
|
||||
const newFavorites = favorites.filter((city) => city.id !== cityId);
|
||||
setFavorites(newFavorites);
|
||||
return newFavorites;
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate and refetch
|
||||
queryClient.invalidateQueries({ queryKey: ["favorites"] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
favorites: favoritesQuery.data,
|
||||
addFavorite,
|
||||
removeFavorite,
|
||||
isFavorite: (lat: number, lon: number) =>
|
||||
favorites.some((city) => city.lat === lat && city.lon === lon),
|
||||
};
|
||||
}
|
||||
81
src/hooks/use-geolocation.ts
Normal file
81
src/hooks/use-geolocation.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { Coordinates } from "@/api/types";
|
||||
|
||||
interface GeolocationState {
|
||||
coordinates: Coordinates | null;
|
||||
error: string | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function useGeolocation() {
|
||||
const [locationData, setLocationData] = useState<GeolocationState>({
|
||||
coordinates: null,
|
||||
error: null,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
const getLocation = () => {
|
||||
setLocationData((prev) => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
if (!navigator.geolocation) {
|
||||
setLocationData({
|
||||
coordinates: null,
|
||||
error: "Geolocation is not supported by your browser",
|
||||
isLoading: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
setLocationData({
|
||||
coordinates: {
|
||||
lat: position.coords.latitude,
|
||||
lon: position.coords.longitude,
|
||||
},
|
||||
error: null,
|
||||
isLoading: false,
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
let errorMessage: string;
|
||||
|
||||
switch (error.code) {
|
||||
case error.PERMISSION_DENIED:
|
||||
errorMessage =
|
||||
"Location permission denied. Please enable location access.";
|
||||
break;
|
||||
case error.POSITION_UNAVAILABLE:
|
||||
errorMessage = "Location information is unavailable.";
|
||||
break;
|
||||
case error.TIMEOUT:
|
||||
errorMessage = "Location request timed out.";
|
||||
break;
|
||||
default:
|
||||
errorMessage = "An unknown error occurred.";
|
||||
}
|
||||
|
||||
setLocationData({
|
||||
coordinates: null,
|
||||
error: errorMessage,
|
||||
isLoading: false,
|
||||
});
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 5000,
|
||||
maximumAge: 0,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Get location on component mount
|
||||
useEffect(() => {
|
||||
getLocation();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...locationData,
|
||||
getLocation, // Expose method to manually refresh location
|
||||
};
|
||||
}
|
||||
23
src/hooks/use-local-storage.ts
Normal file
23
src/hooks/use-local-storage.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useLocalStorage<T>(key: string, initialValue: T) {
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(storedValue));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [key, storedValue]);
|
||||
|
||||
return [storedValue, setStoredValue] as const;
|
||||
}
|
||||
67
src/hooks/use-search-history.ts
Normal file
67
src/hooks/use-search-history.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useLocalStorage } from "./use-local-storage";
|
||||
|
||||
interface SearchHistoryItem {
|
||||
id: string;
|
||||
query: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
name: string;
|
||||
country: string;
|
||||
state?: string;
|
||||
searchedAt: number;
|
||||
}
|
||||
|
||||
export function useSearchHistory() {
|
||||
const [history, setHistory] = useLocalStorage<SearchHistoryItem[]>(
|
||||
"search-history",
|
||||
[]
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const historyQuery = useQuery({
|
||||
queryKey: ["search-history"],
|
||||
queryFn: () => history,
|
||||
initialData: history,
|
||||
});
|
||||
|
||||
const addToHistory = useMutation({
|
||||
mutationFn: async (
|
||||
search: Omit<SearchHistoryItem, "id" | "searchedAt">
|
||||
) => {
|
||||
const newSearch: SearchHistoryItem = {
|
||||
...search,
|
||||
id: `${search.lat}-${search.lon}-${Date.now()}`,
|
||||
searchedAt: Date.now(),
|
||||
};
|
||||
|
||||
// Remove duplicates and keep only last 10 searches
|
||||
const filteredHistory = history.filter(
|
||||
(item) => !(item.lat === search.lat && item.lon === search.lon)
|
||||
);
|
||||
const newHistory = [newSearch, ...filteredHistory].slice(0, 10);
|
||||
|
||||
setHistory(newHistory);
|
||||
return newHistory;
|
||||
},
|
||||
onSuccess: (newHistory) => {
|
||||
queryClient.setQueryData(["search-history"], newHistory);
|
||||
},
|
||||
});
|
||||
|
||||
const clearHistory = useMutation({
|
||||
mutationFn: async () => {
|
||||
setHistory([]);
|
||||
return [];
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.setQueryData(["search-history"], []);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
history: historyQuery.data ?? [],
|
||||
addToHistory,
|
||||
clearHistory,
|
||||
};
|
||||
}
|
||||
44
src/hooks/use-weather.ts
Normal file
44
src/hooks/use-weather.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { weatherAPI } from "@/api/weather";
|
||||
import type { Coordinates } from "@/api/types";
|
||||
|
||||
export const WEATHER_KEYS = {
|
||||
weather: (coords: Coordinates) => ["weather", coords] as const,
|
||||
forecast: (coords: Coordinates) => ["forecast", coords] as const,
|
||||
location: (coords: Coordinates) => ["location", coords] as const,
|
||||
search: (query: string) => ["location-search", query] as const,
|
||||
} as const;
|
||||
|
||||
export function useWeatherQuery(coordinates: Coordinates | null) {
|
||||
return useQuery({
|
||||
queryKey: WEATHER_KEYS.weather(coordinates ?? { lat: 0, lon: 0 }),
|
||||
queryFn: () =>
|
||||
coordinates ? weatherAPI.getCurrentWeather(coordinates) : null,
|
||||
enabled: !!coordinates,
|
||||
});
|
||||
}
|
||||
|
||||
export function useForecastQuery(coordinates: Coordinates | null) {
|
||||
return useQuery({
|
||||
queryKey: WEATHER_KEYS.forecast(coordinates ?? { lat: 0, lon: 0 }),
|
||||
queryFn: () => (coordinates ? weatherAPI.getForecast(coordinates) : null),
|
||||
enabled: !!coordinates,
|
||||
});
|
||||
}
|
||||
|
||||
export function useReverseGeocodeQuery(coordinates: Coordinates | null) {
|
||||
return useQuery({
|
||||
queryKey: WEATHER_KEYS.location(coordinates ?? { lat: 0, lon: 0 }),
|
||||
queryFn: () =>
|
||||
coordinates ? weatherAPI.reverseGeocode(coordinates) : null,
|
||||
enabled: !!coordinates,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLocationSearch(query: string) {
|
||||
return useQuery({
|
||||
queryKey: WEATHER_KEYS.search(query),
|
||||
queryFn: () => weatherAPI.searchLocations(query),
|
||||
enabled: query.length >= 3,
|
||||
});
|
||||
}
|
||||
67
src/index.css
Normal file
67
src/index.css
Normal file
@ -0,0 +1,67 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
61
src/pages/city-page.tsx
Normal file
61
src/pages/city-page.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { useWeatherQuery, useForecastQuery } from "@/hooks/use-weather";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { CurrentWeather } from "../components/current-weather";
|
||||
import { HourlyTemperature } from "../components/hourly-temprature";
|
||||
import { WeatherDetails } from "../components/weather-details";
|
||||
import { WeatherForecast } from "../components/weather-forecast";
|
||||
import WeatherSkeleton from "../components/loading-skeleton";
|
||||
import { FavoriteButton } from "@/components/favorite-button";
|
||||
|
||||
export function CityPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const params = useParams();
|
||||
const lat = parseFloat(searchParams.get("lat") || "0");
|
||||
const lon = parseFloat(searchParams.get("lon") || "0");
|
||||
|
||||
const coordinates = { lat, lon };
|
||||
|
||||
const weatherQuery = useWeatherQuery(coordinates);
|
||||
const forecastQuery = useForecastQuery(coordinates);
|
||||
|
||||
if (weatherQuery.error || forecastQuery.error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Failed to load weather data. Please try again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!weatherQuery.data || !forecastQuery.data || !params.cityName) {
|
||||
return <WeatherSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{params.cityName}, {weatherQuery.data.sys.country}
|
||||
</h1>
|
||||
<div className="flex gap-2">
|
||||
<FavoriteButton
|
||||
data={{ ...weatherQuery.data, name: params.cityName }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
<CurrentWeather data={weatherQuery.data} />
|
||||
<HourlyTemperature data={forecastQuery.data} />
|
||||
<div className="grid gap-6 md:grid-cols-2 items-start">
|
||||
<WeatherDetails data={weatherQuery.data} />
|
||||
<WeatherForecast data={forecastQuery.data} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
src/pages/weather-dashboard.tsx
Normal file
132
src/pages/weather-dashboard.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import {
|
||||
useForecastQuery,
|
||||
useReverseGeocodeQuery,
|
||||
useWeatherQuery,
|
||||
} from "@/hooks/use-weather";
|
||||
import { CurrentWeather } from "../components/current-weather";
|
||||
import { Alert, AlertDescription, AlertTitle } from "../components/ui/alert";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { MapPin, AlertTriangle, RefreshCw } from "lucide-react";
|
||||
import { useGeolocation } from "@/hooks/use-geolocation";
|
||||
import { WeatherDetails } from "../components/weather-details";
|
||||
import { WeatherForecast } from "../components/weather-forecast";
|
||||
import { HourlyTemperature } from "../components/hourly-temprature";
|
||||
import WeatherSkeleton from "../components/loading-skeleton";
|
||||
import { FavoriteCities } from "@/components/favorite-cities";
|
||||
|
||||
export function WeatherDashboard() {
|
||||
const {
|
||||
coordinates,
|
||||
error: locationError,
|
||||
isLoading: locationLoading,
|
||||
getLocation,
|
||||
} = useGeolocation();
|
||||
|
||||
const weatherQuery = useWeatherQuery(coordinates);
|
||||
const forecastQuery = useForecastQuery(coordinates);
|
||||
const locationQuery = useReverseGeocodeQuery(coordinates);
|
||||
|
||||
// Function to refresh all data
|
||||
const handleRefresh = () => {
|
||||
getLocation();
|
||||
if (coordinates) {
|
||||
weatherQuery.refetch();
|
||||
forecastQuery.refetch();
|
||||
locationQuery.refetch();
|
||||
}
|
||||
};
|
||||
|
||||
if (locationLoading) {
|
||||
return <WeatherSkeleton />;
|
||||
}
|
||||
|
||||
if (locationError) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Location Error</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-4">
|
||||
<p>{locationError}</p>
|
||||
<Button variant="outline" onClick={getLocation} className="w-fit">
|
||||
<MapPin className="mr-2 h-4 w-4" />
|
||||
Enable Location
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!coordinates) {
|
||||
return (
|
||||
<Alert>
|
||||
<MapPin className="h-4 w-4" />
|
||||
<AlertTitle>Location Required</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-4">
|
||||
<p>Please enable location access to see your local weather.</p>
|
||||
<Button variant="outline" onClick={getLocation} className="w-fit">
|
||||
<MapPin className="mr-2 h-4 w-4" />
|
||||
Enable Location
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const locationName = locationQuery.data?.[0];
|
||||
|
||||
if (weatherQuery.error || forecastQuery.error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-4">
|
||||
<p>Failed to fetch weather data. Please try again.</p>
|
||||
<Button variant="outline" onClick={handleRefresh} className="w-fit">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Retry
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!weatherQuery.data || !forecastQuery.data) {
|
||||
return <WeatherSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<FavoriteCities />
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold tracking-tight">My Location</h1>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
disabled={weatherQuery.isFetching || forecastQuery.isFetching}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${
|
||||
weatherQuery.isFetching ? "animate-spin" : ""
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
<CurrentWeather
|
||||
data={weatherQuery.data}
|
||||
locationName={locationName}
|
||||
/>
|
||||
<HourlyTemperature data={forecastQuery.data} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 items-start">
|
||||
<WeatherDetails data={weatherQuery.data} />
|
||||
<WeatherForecast data={forecastQuery.data} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
57
tailwind.config.js
Normal file
57
tailwind.config.js
Normal file
@ -0,0 +1,57 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
},
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))'
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))'
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))'
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))'
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))'
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))'
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))'
|
||||
},
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
chart: {
|
||||
'1': 'hsl(var(--chart-1))',
|
||||
'2': 'hsl(var(--chart-2))',
|
||||
'3': 'hsl(var(--chart-3))',
|
||||
'4': 'hsl(var(--chart-4))',
|
||||
'5': 'hsl(var(--chart-5))'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
};
|
||||
31
tsconfig.app.json
Normal file
31
tsconfig.app.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
24
tsconfig.node.json
Normal file
24
tsconfig.node.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
12
vite.config.ts
Normal file
12
vite.config.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import path from "path";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user