Klimate App

This commit is contained in:
Piyush Agarwal 2024-11-07 11:47:30 +05:30
commit d1ad533352
53 changed files with 8550 additions and 0 deletions

25
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

53
package.json Normal file
View 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
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
public/logo2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

1
public/vite.svg Normal file
View 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
View File

40
src/App.tsx Normal file
View 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
View 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
View 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
View 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();

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View 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
View 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>
);
}

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

View 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>
);
}

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

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

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

View 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,
}

View 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,
}

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

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

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

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

View 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>
);
}

View 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>
);
}

View 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
View 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),
};
}

View 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
};
}

View 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;
}

View 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
View 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
View 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
View 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
View 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
View 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>
);
}

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

@ -0,0 +1 @@
/// <reference types="vite/client" />

57
tailwind.config.js Normal file
View 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
View 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
View 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
View 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
View 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"),
},
},
});