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