diff --git a/.eslintrc.json b/.eslintrc.json index bffb357..27b1e6c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,3 +1,3 @@ { - "extends": "next/core-web-vitals" + "extends": ["wesbos/typescript"] } diff --git a/app/Store/hook.ts b/app/Store/hook.ts new file mode 100644 index 0000000..7988133 --- /dev/null +++ b/app/Store/hook.ts @@ -0,0 +1,5 @@ +import { useDispatch, useSelector } from 'react-redux'; +import type { AppDispatch, RootState } from './store'; + +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector = useSelector.withTypes(); diff --git a/app/Store/rootReducer.ts b/app/Store/rootReducer.ts new file mode 100644 index 0000000..a00700f --- /dev/null +++ b/app/Store/rootReducer.ts @@ -0,0 +1,7 @@ +import { combineReducers } from '@reduxjs/toolkit'; + +import forecastReducer from './slices/forecastSlice'; + +export const rootReducer = combineReducers({ + forecast: forecastReducer, +}); diff --git a/app/Store/slices/forecastSlice.ts b/app/Store/slices/forecastSlice.ts new file mode 100644 index 0000000..60b189f --- /dev/null +++ b/app/Store/slices/forecastSlice.ts @@ -0,0 +1,75 @@ +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; +import { RootState } from '../store'; +import { WeatherData } from '@/types/forecastTypes'; + +const APIKEY = process.env.NEXT_PUBLIC_OPEN_WEATHER_APIKEY; + +/** + * Fetches the weather forecast based on the provided search term. + * @param {string} searchTerm - The term to search for the forecast. + * @returns {Promise} The weather forecast data. + * @throws {Error} If the fetch fails or the response is not ok. + */ +export const fetchForecast = createAsyncThunk( + 'forecast/fetchForecast', + async (searchTerm: string): Promise => { + try { + const response = await fetch( + `https://api.openweathermap.org/data/2.5/forecast?q=${searchTerm}&appid=${APIKEY}&units=imperial`, + ); + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + const data = (await response.json()) as WeatherData; + return data; + } catch (error) { + if (error instanceof Error) { + console.error('Fetch error:', error.message); + } else { + console.error('Unknown error:', error); + } + throw error; + } + }, +); + +type ForecastState = { + forecast: WeatherData | null; + status: 'idle' | 'loading' | 'succeeded' | 'failed'; + error: string | null; +}; + +const initialState: ForecastState = { + forecast: null, + status: 'idle', + error: null, +}; + +const forecastSlice = createSlice({ + name: 'forecast', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder + .addCase(fetchForecast.pending, (state) => { + state.status = 'loading'; + }) + .addCase(fetchForecast.fulfilled, (state, action) => { + state.status = 'succeeded'; + state.forecast = action.payload; + }) + .addCase(fetchForecast.rejected, (state, action) => { + state.status = 'failed'; + state.error = action.error.message; + }); + }, +}); + +/** + * Selects the forecast from the state. + * @param {RootState} state - The root state. + * @returns {WeatherData | null} The forecast data. + */ +export const selectForecast = (state: RootState) => state.forecast; + +export default forecastSlice.reducer; diff --git a/app/Store/store.ts b/app/Store/store.ts new file mode 100644 index 0000000..f6d5d9a --- /dev/null +++ b/app/Store/store.ts @@ -0,0 +1,9 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { rootReducer } from './rootReducer'; + +export const store = configureStore({ + reducer: rootReducer, +}); + +export type AppDispatch = typeof store.dispatch; +export type RootState = ReturnType; diff --git a/app/components/Forecast.tsx b/app/components/Forecast.tsx new file mode 100644 index 0000000..1d929a8 --- /dev/null +++ b/app/components/Forecast.tsx @@ -0,0 +1,51 @@ +'use client'; + +import React from 'react'; +import { AlertTriangle } from 'lucide-react'; +import { selectForecast } from '../Store/slices/forecastSlice'; +import { useAppSelector } from '../Store/hook'; +import ShadChart from './ShadChart'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; + +/** + * Displays the weather forecast based on the selected city. + * Utilizes the Redux store to retrieve forecast data. + * @returns {JSX.Element} The rendered forecast component. + */ +function Forecast() { + const { forecast, status, error } = useAppSelector(selectForecast); + let city: string; + + if (status === 'succeeded') { + city = forecast.city.name; + } + + return ( +
+ {status === 'loading' &&

Loading...

} + {status === 'failed' && ( + + + {error} + + Sorry there was an error, please try searching again! + + + )} + {status === 'succeeded' && ( +
+

+ {city} +

+
+ + + +
+
+ )} +
+ ); +} + +export default Forecast; diff --git a/app/components/ModeToggle.tsx b/app/components/ModeToggle.tsx new file mode 100644 index 0000000..5808237 --- /dev/null +++ b/app/components/ModeToggle.tsx @@ -0,0 +1,45 @@ +'use client'; + +import * as React from 'react'; +import { Moon, Sun } from 'lucide-react'; +import { useTheme } from 'next-themes'; + +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; + +/** + * A button to toggle between light and dark themes. + * Utilizes the Next.js theme provider. + * @returns {JSX.Element} The rendered button component. + */ +export function ModeToggle() { + const { setTheme } = useTheme(); + + return ( + + + + + + setTheme('light')}> + Light + + setTheme('dark')}> + Dark + + setTheme('system')}> + System + + + + ); +} diff --git a/app/components/ProviderClient.tsx b/app/components/ProviderClient.tsx new file mode 100644 index 0000000..84fcadb --- /dev/null +++ b/app/components/ProviderClient.tsx @@ -0,0 +1,20 @@ +'use client'; + +import React, { ReactNode } from 'react'; +import { Provider } from 'react-redux'; +import { store } from '../Store/store'; + +type ProviderClientProps = { + children: ReactNode[] | ReactNode; +}; + +/** + * A provider component that wraps children in a Redux Provider. + * @param {ProviderClientProps} props - The props for the provider component. + * @returns {JSX.Element} The rendered provider component. + */ +function ProviderClient({ children }: ProviderClientProps) { + return {children} ; +} + +export default ProviderClient; diff --git a/app/components/ShadChart.tsx b/app/components/ShadChart.tsx new file mode 100644 index 0000000..fbf3376 --- /dev/null +++ b/app/components/ShadChart.tsx @@ -0,0 +1,157 @@ +'use client'; + +import React from 'react'; +import { CartesianGrid, Line, LineChart, ReferenceLine } from 'recharts'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from '@/components/ui/chart'; +import { ForecastDataPoint } from '@/types/forecastTypes'; + +const options: Intl.DateTimeFormatOptions = { + weekday: 'short', + day: 'numeric', + month: 'short', + hour: 'numeric', + hour12: true, +}; + +const formatter = new Intl.DateTimeFormat('en-US', options); + +const chartConfig = { + temp: { + label: 'Temperature(℉)', + color: '#2563eb', + }, + pressure: { + label: 'Pressure(hPa) ', + }, + humidity: { + label: 'Humidity(%)', + }, +} satisfies ChartConfig; + +type ShadChartProps = { + forecastData: ForecastDataPoint[]; + dataType: 'temp' | 'pressure' | 'humidity'; +}; + +/** + * Renders a chart displaying forecast data for temperature, pressure, or humidity. + * @param {ShadChartProps} props - The properties for the chart component. + * @returns {JSX.Element} The rendered chart component. + */ +function ShadChart({ forecastData, dataType }: ShadChartProps) { + const tempForecasts = forecastData.map( + (foreCastEntry: ForecastDataPoint) => foreCastEntry.main.temp, + ); + const pressureForecasts = forecastData.map( + (foreCastEntry: ForecastDataPoint) => foreCastEntry.main.pressure, + ); + const humidityForecasts = forecastData.map( + (foreCastEntry: ForecastDataPoint) => foreCastEntry.main.humidity, + ); + + const tempAverage = Math.round( + tempForecasts.reduce((prev, curr) => prev + curr) / tempForecasts.length, + ); + const pressureAverage = Math.round( + pressureForecasts.reduce((prev, curr) => prev + curr) / + pressureForecasts.length, + ); + const humidityAverage = Math.round( + humidityForecasts.reduce((prev, curr) => prev + curr) / + humidityForecasts.length, + ); + + let averageLineValue: number; + let displayAverage: string; + if (dataType === 'temp') { + averageLineValue = tempAverage; + displayAverage = `${tempAverage} ℉`; + } else if (dataType === 'humidity') { + averageLineValue = humidityAverage; + displayAverage = `${humidityAverage} % `; + } else { + averageLineValue = pressureAverage; + displayAverage = `${pressureAverage} hPA`; + } + + const chartData = forecastData.map((f) => { + const date = new Date(f.dt_txt); + const displayDate = formatter.format(date); + return { + temp: f.main.temp, + pressure: f.main.pressure, + humidity: f.main.humidity, + displayDate, + }; + }); + return ( + + + {dataType.toUpperCase()} + 5 Day Forecast + + + + + + + `${value} at ${payload.displayDate}` + } + /> + } + /> + + + + + + +
+ {dataType.toUpperCase()} Average: {displayAverage} +
+
+
+ ); +} + +export default ShadChart; diff --git a/app/components/ShadSearchForm.tsx b/app/components/ShadSearchForm.tsx new file mode 100644 index 0000000..3d9efb0 --- /dev/null +++ b/app/components/ShadSearchForm.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { Button } from '@/components/ui/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { useAppDispatch } from '../Store/hook'; +import { fetchForecast } from '../Store/slices/forecastSlice'; + +const formSchema = z.object({ + city: z.string().min(3, { + message: 'City must be at least 3 characters in order to search', + }), +}); + +/** + * A form for searching the weather forecast by city. + * @returns {JSX.Element} The rendered search form component. + */ +function ShadSearchForm() { + const dispatch = useAppDispatch(); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + city: '', + }, + }); + + function onSubmit(values: z.infer) { + dispatch(fetchForecast(values.city)); + } + + return ( +
+ + ( + + Search: + + + + + + )} + /> + + + + ); +} + +export default ShadSearchForm; diff --git a/app/components/ThemeProvider.tsx b/app/components/ThemeProvider.tsx new file mode 100644 index 0000000..bc80f35 --- /dev/null +++ b/app/components/ThemeProvider.tsx @@ -0,0 +1,16 @@ +'use client'; + +import * as React from 'react'; +import { ThemeProvider as NextThemesProvider } from 'next-themes'; + +/** + * A theme provider component that wraps children in a Next.js theme provider. + * @param {React.ComponentProps} props - The props for the theme provider component. + * @returns {JSX.Element} The rendered theme provider component. + */ +export function ThemeProvider({ + children, + ...props +}: React.ComponentProps) { + return {children}; +} diff --git a/app/favicon.ico b/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/app/favicon.ico and /dev/null differ diff --git a/app/globals.css b/app/globals.css index d4f491e..1afa8a3 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,107 +1,68 @@ -:root { - --max-width: 1100px; - --border-radius: 12px; - --font-mono: ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono', - 'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro', - 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace; +@tailwind base; +@tailwind components; +@tailwind utilities; - --foreground-rgb: 0, 0, 0; - --background-start-rgb: 214, 219, 220; - --background-end-rgb: 255, 255, 255; - - --primary-glow: conic-gradient( - from 180deg at 50% 50%, - #16abff33 0deg, - #0885ff33 55deg, - #54d6ff33 120deg, - #0071ff33 160deg, - transparent 360deg - ); - --secondary-glow: radial-gradient( - rgba(255, 255, 255, 1), - rgba(255, 255, 255, 0) - ); - - --tile-start-rgb: 239, 245, 249; - --tile-end-rgb: 228, 232, 233; - --tile-border: conic-gradient( - #00000080, - #00000040, - #00000030, - #00000020, - #00000010, - #00000010, - #00000080 - ); - - --callout-rgb: 238, 240, 241; - --callout-border-rgb: 172, 175, 176; - --card-rgb: 180, 185, 188; - --card-border-rgb: 131, 134, 135; -} - -@media (prefers-color-scheme: dark) { +@layer base { :root { - --foreground-rgb: 255, 255, 255; - --background-start-rgb: 0, 0, 0; - --background-end-rgb: 0, 0, 0; - - --primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0)); - --secondary-glow: linear-gradient( - to bottom right, - rgba(1, 65, 255, 0), - rgba(1, 65, 255, 0), - rgba(1, 65, 255, 0.3) - ); - - --tile-start-rgb: 2, 13, 46; - --tile-end-rgb: 2, 5, 19; - --tile-border: conic-gradient( - #ffffff80, - #ffffff40, - #ffffff30, - #ffffff20, - #ffffff10, - #ffffff10, - #ffffff80 - ); - - --callout-rgb: 20, 20, 20; - --callout-border-rgb: 108, 108, 108; - --card-rgb: 100, 100, 100; - --card-border-rgb: 200, 200, 200; + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 221.2 83.2% 53.3%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 221.2 83.2% 53.3%; + --radius: 0.75rem; + --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%; } -} -* { - box-sizing: border-box; - padding: 0; - margin: 0; -} - -html, -body { - max-width: 100vw; - overflow-x: hidden; -} - -body { - color: rgb(var(--foreground-rgb)); - background: linear-gradient( - to bottom, - transparent, - rgb(var(--background-end-rgb)) - ) - rgb(var(--background-start-rgb)); -} - -a { - color: inherit; - text-decoration: none; -} - -@media (prefers-color-scheme: dark) { - html { - color-scheme: dark; + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 224.3 76.3% 48%; + --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; + } +} \ No newline at end of file diff --git a/app/layout.js b/app/layout.js deleted file mode 100644 index c93f806..0000000 --- a/app/layout.js +++ /dev/null @@ -1,17 +0,0 @@ -import './globals.css' -import { Inter } from 'next/font/google' - -const inter = Inter({ subsets: ['latin'] }) - -export const metadata = { - title: 'Create Next App', - description: 'Generated by create next app', -} - -export default function RootLayout({ children }) { - return ( - - {children} - - ) -} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..9af3e38 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,34 @@ +import './globals.css'; +import { Inter } from 'next/font/google'; + +import { ReactNode } from 'react'; +import ProviderClient from './components/ProviderClient'; +import { ThemeProvider } from './components/ThemeProvider'; + +const inter = Inter({ subsets: ['latin'] }); + +export const metadata = { + title: 'RTK Weather', + description: 'Generated by create next app', +}; + +type RootLayoutProps = { + children: ReactNode[]; +}; + +export default function RootLayout({ children }: RootLayoutProps) { + return ( + + + + {children} + + + + ); +} diff --git a/app/page.js b/app/page.js deleted file mode 100644 index f049c39..0000000 --- a/app/page.js +++ /dev/null @@ -1,95 +0,0 @@ -import Image from 'next/image' -import styles from './page.module.css' - -export default function Home() { - return ( -
-
-

- Get started by editing  - app/page.js -

- -
- -
- Next.js Logo -
- - -
- ) -} diff --git a/app/page.module.css b/app/page.module.css deleted file mode 100644 index 6676d2c..0000000 --- a/app/page.module.css +++ /dev/null @@ -1,229 +0,0 @@ -.main { - display: flex; - flex-direction: column; - justify-content: space-between; - align-items: center; - padding: 6rem; - min-height: 100vh; -} - -.description { - display: inherit; - justify-content: inherit; - align-items: inherit; - font-size: 0.85rem; - max-width: var(--max-width); - width: 100%; - z-index: 2; - font-family: var(--font-mono); -} - -.description a { - display: flex; - justify-content: center; - align-items: center; - gap: 0.5rem; -} - -.description p { - position: relative; - margin: 0; - padding: 1rem; - background-color: rgba(var(--callout-rgb), 0.5); - border: 1px solid rgba(var(--callout-border-rgb), 0.3); - border-radius: var(--border-radius); -} - -.code { - font-weight: 700; - font-family: var(--font-mono); -} - -.grid { - display: grid; - grid-template-columns: repeat(4, minmax(25%, auto)); - max-width: 100%; - width: var(--max-width); -} - -.card { - padding: 1rem 1.2rem; - border-radius: var(--border-radius); - background: rgba(var(--card-rgb), 0); - border: 1px solid rgba(var(--card-border-rgb), 0); - transition: background 200ms, border 200ms; -} - -.card span { - display: inline-block; - transition: transform 200ms; -} - -.card h2 { - font-weight: 600; - margin-bottom: 0.7rem; -} - -.card p { - margin: 0; - opacity: 0.6; - font-size: 0.9rem; - line-height: 1.5; - max-width: 30ch; -} - -.center { - display: flex; - justify-content: center; - align-items: center; - position: relative; - padding: 4rem 0; -} - -.center::before { - background: var(--secondary-glow); - border-radius: 50%; - width: 480px; - height: 360px; - margin-left: -400px; -} - -.center::after { - background: var(--primary-glow); - width: 240px; - height: 180px; - z-index: -1; -} - -.center::before, -.center::after { - content: ''; - left: 50%; - position: absolute; - filter: blur(45px); - transform: translateZ(0); -} - -.logo { - position: relative; -} -/* Enable hover only on non-touch devices */ -@media (hover: hover) and (pointer: fine) { - .card:hover { - background: rgba(var(--card-rgb), 0.1); - border: 1px solid rgba(var(--card-border-rgb), 0.15); - } - - .card:hover span { - transform: translateX(4px); - } -} - -@media (prefers-reduced-motion) { - .card:hover span { - transform: none; - } -} - -/* Mobile */ -@media (max-width: 700px) { - .content { - padding: 4rem; - } - - .grid { - grid-template-columns: 1fr; - margin-bottom: 120px; - max-width: 320px; - text-align: center; - } - - .card { - padding: 1rem 2.5rem; - } - - .card h2 { - margin-bottom: 0.5rem; - } - - .center { - padding: 8rem 0 6rem; - } - - .center::before { - transform: none; - height: 300px; - } - - .description { - font-size: 0.8rem; - } - - .description a { - padding: 1rem; - } - - .description p, - .description div { - display: flex; - justify-content: center; - position: fixed; - width: 100%; - } - - .description p { - align-items: center; - inset: 0 0 auto; - padding: 2rem 1rem 1.4rem; - border-radius: 0; - border: none; - border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25); - background: linear-gradient( - to bottom, - rgba(var(--background-start-rgb), 1), - rgba(var(--callout-rgb), 0.5) - ); - background-clip: padding-box; - backdrop-filter: blur(24px); - } - - .description div { - align-items: flex-end; - pointer-events: none; - inset: auto 0 0; - padding: 2rem; - height: 200px; - background: linear-gradient( - to bottom, - transparent 0%, - rgb(var(--background-end-rgb)) 40% - ); - z-index: 1; - } -} - -/* Tablet and Smaller Desktop */ -@media (min-width: 701px) and (max-width: 1120px) { - .grid { - grid-template-columns: repeat(2, 50%); - } -} - -@media (prefers-color-scheme: dark) { - .vercelLogo { - filter: invert(1); - } - - .logo { - filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70); - } -} - -@keyframes rotate { - from { - transform: rotate(360deg); - } - to { - transform: rotate(0deg); - } -} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..6cecb09 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,20 @@ +'use client'; + +import Forecast from './components/Forecast'; +import { ModeToggle } from './components/ModeToggle'; +import ShadSearchForm from './components/ShadSearchForm'; + +export default function Home() { + return ( +
+

+ RTK Weather +

+
+ +
+ + +
+ ); +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..a090a3d --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..b248662 --- /dev/null +++ b/components/ui/alert.tsx @@ -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 p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground', + { + 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 & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = 'Alert'; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = 'AlertTitle'; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = 'AlertDescription'; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..36496a2 --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,56 @@ +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 ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 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 hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..41235e8 --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,86 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = 'Card'; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = 'CardHeader'; + +const CardTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardTitle.displayName = 'CardTitle'; + +const CardDescription = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardDescription.displayName = 'CardDescription'; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardContent.displayName = 'CardContent'; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = 'CardFooter'; + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/components/ui/chart.tsx b/components/ui/chart.tsx new file mode 100644 index 0000000..e0dbd05 --- /dev/null +++ b/components/ui/chart.tsx @@ -0,0 +1,363 @@ +'use client'; + +import * as React from 'react'; +import * as RechartsPrimitive from 'recharts'; + +import { cn } from '@/lib/utils'; + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: '', dark: '.dark' } as const; + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ); +}; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error('useChart must be used within a '); + } + + return context; +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<'div'> & { + config: ChartConfig; + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >['children']; + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`; + + return ( + +
+ + + {children} + +
+
+ ); +}); +ChartContainer.displayName = 'Chart'; + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme || config.color, + ); + + if (!colorConfig.length) { + return null; + } + + return ( +