commit b78ab7d53974205d7b7942c55fd4b82e033b6927 Author: Mohammad Mahdi Date: Thu Apr 17 14:39:00 2025 +0330 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# 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? diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..b30f675 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,16 @@ +{ + "semi": true, + "tabWidth": 2, + "printWidth": 100, + "trailingComma": "all", + "singleQuote": false, + "jsxSingleQuote": false, + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "always", + "endOfLine": "lf", + "quoteProps": "as-needed", + "plugins": ["prettier-plugin-tailwindcss"], + "tailwindConfig": "./tailwind.config.js", + "embeddedLanguageFormatting": "auto" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..02a0e85 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# IoPlot Dashboard UI - Fully writen by AI + +# 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, + }, +}) +``` diff --git a/components.json b/components.json new file mode 100644 index 0000000..51d59d2 --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "zinc", + "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/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..edc02dc --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,123 @@ +// @ts-check + +import js from "@eslint/js"; +import * as importPlugin from "eslint-plugin-import"; +import reactPlugin from "eslint-plugin-react"; +import reactHooks from "eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.development.js"; +import * as reactRefresh from "eslint-plugin-react-refresh"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist"] }, + { + extends: [ + js.configs.recommended, + ...tseslint.configs.recommended, + ...tseslint.configs.stylistic, + ], + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2020, + globals: { + ...globals.browser, + React: "readonly", + }, + parser: tseslint.parser, + parserOptions: { + project: "./tsconfig.json", + tsconfigRootDir: import.meta.dirname, + ecmaFeatures: { + jsx: true, + }, + }, + }, + plugins: { + react: reactPlugin, + "@typescript-eslint": tseslint.plugin, + import: importPlugin, + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + settings: { + react: { + version: "detect", + }, + "import/parsers": { + "@typescript-eslint/parser": [".ts", ".tsx"], + }, + "import/resolver": { + typescript: { + project: "./tsconfig.json", + }, + node: true, + }, + }, + rules: { + ...reactHooks.configs.recommended.rules, + ...reactPlugin.configs.recommended.rules, + + // Import rules + "import/no-unresolved": "error", + "import/named": "off", + "import/default": "warn", + "import/export": "error", + "import/no-duplicates": "warn", + + // React Refresh - modified to be less strict for component files + "react-refresh/only-export-components": [ + "off", + { allowConstantExport: true }, + ], + + // TypeScript + "@typescript-eslint/no-unused-vars": "off", + + // Tailwind-friendly rules + "react/no-unknown-property": [ + "error", + { + ignore: [ + "class", + "css", + "tw", + "args", + "position", + "intensity", + "rotation", + "vertexShader", + "fragmentShader", + "uniforms", + "side", + "cmdk-input-wrapper", + ], + }, + ], + + // Turn off rules that are too strict + "@typescript-eslint/naming-convention": "off", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-return": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/restrict-template-expressions": "off", + "@typescript-eslint/no-redundant-type-constituents": "off", + "@typescript-eslint/no-floating-promises": "off", + "@typescript-eslint/unbound-method": "off", + "@typescript-eslint/require-await": "warn", + + // React specific + "react/react-in-jsx-scope": "off", + "react/prop-types": "off", + "react/jsx-no-target-blank": "warn", + "react/jsx-key": ["warn", { checkFragmentShorthand: true }], + "react/no-unescaped-entities": "off", + + // React Hooks + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + }, + }, +); diff --git a/index.html b/index.html new file mode 100644 index 0000000..2732780 --- /dev/null +++ b/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + + IoT Plotter + + +
+ + + diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..4e2e20b --- /dev/null +++ b/netlify.toml @@ -0,0 +1,24 @@ +[images] + remote_images = ["https://source.unsplash.com/.*", "https://images.unsplash.com/.*", "https://ext.same-assets.com/.*", "https://ugc.same-assets.com/.*"] + +[build] + command = "bun install && bun run build" + publish = "dist" + functions = "netlify/functions" + +[build.environment] + NODE_VERSION = "20" + VITE_API_URL = "https://io-a.monasefloadbalancer.ir" + +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 + +[dev] + command = "bun run dev" + port = 5173 + targetPort = 5173 + publish = "dist" + autoLaunch = true + framework = "vite" diff --git a/package.json b/package.json new file mode 100644 index 0000000..0c96689 --- /dev/null +++ b/package.json @@ -0,0 +1,92 @@ +{ + "name": "react-vite-tailwind", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "tsc -b && vite build --outDir dist", + "lint": "eslint src --fix && tsc --noEmit", + "format": "prettier --write \"src/**/*.{ts,tsx}\"", + "preview": "vite preview" + }, + "dependencies": { + "@hookform/resolvers": "^4.1.3", + "@radix-ui/react-accordion": "^1.2.2", + "@radix-ui/react-alert-dialog": "^1.1.4", + "@radix-ui/react-aspect-ratio": "^1.1.1", + "@radix-ui/react-avatar": "^1.1.3", + "@radix-ui/react-checkbox": "^1.1.3", + "@radix-ui/react-collapsible": "^1.1.2", + "@radix-ui/react-context-menu": "^2.2.4", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-hover-card": "^1.1.4", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-menubar": "^1.1.4", + "@radix-ui/react-navigation-menu": "^1.2.3", + "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-progress": "^1.1.1", + "@radix-ui/react-radio-group": "^1.2.2", + "@radix-ui/react-scroll-area": "^1.2.2", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slider": "^1.2.2", + "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-switch": "^1.1.2", + "@radix-ui/react-tabs": "^1.1.3", + "@radix-ui/react-toast": "^1.2.6", + "@radix-ui/react-toggle": "^1.1.1", + "@radix-ui/react-toggle-group": "^1.1.1", + "@radix-ui/react-tooltip": "^1.1.6", + "@tanstack/react-query": "^5.69.0", + "axios": "^1.8.3", + "cmdk": "^1.0.0", + "date-fns": "^4.1.0", + "embla-carousel-react": "^8.5.2", + "input-otp": "^1.4.2", + "jwt-decode": "^4.0.0", + "next-themes": "^0.4.4", + "react": "^18.3.1", + "react-day-picker": "8.10.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.54.2", + "react-resizable-panels": "^2.1.7", + "react-router-dom": "^7.4.0", + "recharts": "^2.15.1", + "sonner": "^2.0.1", + "vaul": "^1.1.2", + "zod": "^3.24.2", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@eslint/js": "^9.18.0", + "@types/node": "^22.10.7", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@typescript-eslint/eslint-plugin": "^8.20.0", + "@typescript-eslint/parser": "^8.20.0", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-import-resolver-typescript": "^3.7.0", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-react": "^7.37.4", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.18", + "globals": "^15.14.0", + "lucide-react": "^0.471.2", + "postcss": "^8.5.1", + "prettier": "^3.4.2", + "prettier-plugin-tailwindcss": "^0.6.10", + "tailwind-merge": "^2.6.0", + "tailwindcss": "^3.4.17", + "tailwindcss-animate": "^1.0.7", + "typescript": "~5.6.2", + "typescript-eslint": "^8.20.0", + "vite": "^6.0.5" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/public/CNAME b/public/CNAME new file mode 100644 index 0000000..c7b4778 --- /dev/null +++ b/public/CNAME @@ -0,0 +1 @@ +io.mahdium.ir diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..973b0f0 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..3ca7955 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,62 @@ +import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router-dom"; +import { ThemeProvider } from "@/lib/theme-provider"; +import { Layout } from "@/components/Layout"; +import { ProtectedRoute } from "@/components/ProtectedRoute"; +import { HomePage } from "@/pages/HomePage"; +import { LoginPage } from "@/pages/LoginPage"; +import { RegisterPage } from "@/pages/RegisterPage"; +import { DashboardPage } from "@/pages/DashboardPage"; +import { ApiKeysPage } from "@/pages/ApiKeysPage"; +import { ProfilePage } from "@/pages/ProfilePage"; +import { VerifyEmail } from "@/pages/VerifyEmail"; + +function App() { + return ( + + + + }> + } /> + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + } + /> + } /> + + + + + ); +} + +export default App; diff --git a/src/components/ApiKeyItem.tsx b/src/components/ApiKeyItem.tsx new file mode 100644 index 0000000..1af9008 --- /dev/null +++ b/src/components/ApiKeyItem.tsx @@ -0,0 +1,130 @@ +import { useState } from "react"; +import { formatDateTime } from "@/lib/timeRanges"; +import { ApiKey } from "@/lib/types"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { toast } from "sonner"; +import { Copy, Eye, EyeOff, Trash2 } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface ApiKeyItemProps { + apiKey: ApiKey; + onDelete: (key: string) => Promise; +} + +export function ApiKeyItem({ apiKey, onDelete }: ApiKeyItemProps) { + const [isVisible, setIsVisible] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const toggleVisibility = () => { + setIsVisible(!isVisible); + }; + + const handleCopyKey = () => { + navigator.clipboard.writeText(apiKey.key); + toast.success("API key copied to clipboard"); + }; + + const handleDelete = async () => { + setIsDeleting(true); + try { + await onDelete(apiKey.key); + toast.success("API key deleted successfully"); + setIsDeleteDialogOpen(false); + } catch (error) { + toast.error("Failed to delete API key"); + console.error(error); + } finally { + setIsDeleting(false); + } + }; + + const displayKey = isVisible + ? apiKey.key + : apiKey.key.substring(0, 4) + "•".repeat(apiKey.key.length - 8) + apiKey.key.substring(apiKey.key.length - 4); + + return ( + + +
+
+
+ Created: {formatDateTime(apiKey.createdAt)} +
+
+ + + +
+
+
+ {displayKey} +
+
+
+ + + + + Delete API Key + + Are you sure you want to delete this API key? This action cannot be undone. Any devices using this key will no longer be able to send data. + + + + + + + + +
+ ); +} diff --git a/src/components/FeedActions.tsx b/src/components/FeedActions.tsx new file mode 100644 index 0000000..30380c9 --- /dev/null +++ b/src/components/FeedActions.tsx @@ -0,0 +1,95 @@ +import { useState } from "react"; +import { Copy, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Feed } from "@/lib/types"; + +interface FeedActionsProps { + feed: Feed; + onDelete: (feedId: string) => Promise; +} + +export function FeedActions({ feed, onDelete }: FeedActionsProps) { + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const handleCopyId = () => { + navigator.clipboard.writeText(feed.id); + toast.success("Feed ID copied to clipboard"); + }; + + const handleDelete = async () => { + setIsDeleting(true); + try { + await onDelete(feed.id); + toast.success("Feed deleted successfully"); + setIsDeleteDialogOpen(false); + } catch (error) { + toast.error("Failed to delete feed"); + console.error(error); + } finally { + setIsDeleting(false); + } + }; + + return ( + <> +
+ + +
+ + + + + Delete Feed + + Are you sure you want to delete this feed? This action cannot be undone and all data + associated with this feed will be permanently deleted. + + + + + + + + + + ); +} diff --git a/src/components/FeedChart.tsx b/src/components/FeedChart.tsx new file mode 100644 index 0000000..c9a5738 --- /dev/null +++ b/src/components/FeedChart.tsx @@ -0,0 +1,130 @@ +import { useMemo } from "react"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + TooltipProps, +} from "recharts"; +import { format } from "date-fns"; +import { FeedDataPoint } from "@/lib/types"; + +interface FeedChartProps { + data: FeedDataPoint[]; +} + +interface FormattedDataPoint { + timestamp: number; + value: number; + formattedTime: string; +} + +// Function to downsample data points for cleaner visualization +const downsampleData = (data: FormattedDataPoint[], targetPoints = 100): FormattedDataPoint[] => { + if (data.length <= targetPoints) return data; + + const result: FormattedDataPoint[] = []; + const step = Math.floor(data.length / targetPoints); + + for (let i = 0; i < data.length; i += step) { + // Calculate average for this segment + let sum = 0; + let count = 0; + + for (let j = i; j < i + step && j < data.length; j++) { + sum += data[j].value; + count++; + } + + // Use the first timestamp in this segment but the average value + if (count > 0) { + result.push({ + ...data[i], + value: sum / count + }); + } + } + + // Always include the last point for completeness + if (data.length > 0 && (result.length === 0 || result[result.length - 1].timestamp !== data[data.length - 1].timestamp)) { + result.push(data[data.length - 1]); + } + + return result; +}; + +export function FeedChart({ data }: FeedChartProps) { + // Format and downsample data for the chart + const formattedData = useMemo(() => { + const rawFormatted = data.map((point) => ({ + timestamp: new Date(point.timestamp).getTime(), + value: parseFloat(point.data), + formattedTime: format(new Date(point.timestamp), "MMM dd, yyyy HH:mm:ss"), + })); + + // Sort by timestamp to ensure proper line connection + rawFormatted.sort((a, b) => a.timestamp - b.timestamp); + + // Downsample to reduce visual clutter + return downsampleData(rawFormatted); + }, [data]); + + // Custom tooltip + const CustomTooltip = ({ active, payload }: TooltipProps) => { + if (active && payload && payload.length) { + const data = payload[0].payload as FormattedDataPoint; + return ( +
+

{data.formattedTime}

+

Value: {data.value.toFixed(2)}

+
+ ); + } + + return null; + }; + + return ( +
+ + + + format(new Date(unixTime), "HH:mm:ss")} + tickCount={7} + stroke="var(--muted-foreground)" + fontSize={12} + /> + value.toString()} + fontSize={12} + width={50} + /> + } /> + + + +
+ ); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..331c2c3 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,187 @@ +import { Link, useNavigate } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { ThemeToggle } from "@/components/ThemeToggle"; +import { isAuthenticated, clearToken, getUsername } from "@/lib/auth"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { + ActivitySquare, + ChevronDown, + ExternalLink, + LogOut, + Menu, + Settings, + UserCircle, +} from "lucide-react"; +import { toast } from "sonner"; + +export function Header() { + const navigate = useNavigate(); + const authenticated = isAuthenticated(); + const username = getUsername(); + + const handleLogout = () => { + clearToken(); + toast.success("You have been logged out"); + navigate("/login"); + }; + + const getUserInitials = () => { + if (!username) return "U"; + return username.substring(0, 2).toUpperCase(); + }; + + return ( +
+ {/* Alpha Disclaimer */} +
+ Alpha Version – Report any issues to{" "} + + me@mahdium.ir + +
+ +
+
+ {/* Logo and title */} +
+ + + IoT Plotter + +
+ + {/* Desktop view */} +
+ + + + + + + {authenticated ? ( + + + + + + My Account + + navigate("/dashboard")}> + + Dashboard + + navigate("/api-keys")}> + + API Keys + + navigate("/profile")}> + + Profile + + + + + Logout + + + + ) : ( +
+ + +
+ )} +
+ + {/* Mobile view */} +
+ + + + + + (window.location.href = "mailto:me@mahdium.ir")} + > + Contact + + + window.open("https://io-doc.monasefloadbalancer.ir", "_blank") + } + > + API Documentation + + + + + + {authenticated ? ( + <> + navigate("/dashboard")}> + Dashboard + + navigate("/api-keys")}> + API Keys + + navigate("/profile")}> + Profile + + + + Logout + + + ) : ( + <> + navigate("/login")}> + Login + + navigate("/register")}> + Sign Up + + + )} + + +
+
+
+
+ ); +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx new file mode 100644 index 0000000..d5c3cca --- /dev/null +++ b/src/components/Layout.tsx @@ -0,0 +1,31 @@ +import { Outlet } from "react-router-dom"; +import { Header } from "@/components/Header"; +import { Toaster } from "sonner"; + +export function Layout() { + return ( +
+
+
+ +
+
+
+

+ IoT Plotter © {new Date().getFullYear()} -{" "} + + mahdium.ir + +

+

Version 0.0.3 - Alpha

+
+
+ +
+ ); +} diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..277dbe5 --- /dev/null +++ b/src/components/ProtectedRoute.tsx @@ -0,0 +1,21 @@ +import { ReactNode, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { isAuthenticated } from "@/lib/auth"; +import { toast } from "sonner"; + +interface ProtectedRouteProps { + children: ReactNode; +} + +export function ProtectedRoute({ children }: ProtectedRouteProps) { + const navigate = useNavigate(); + + useEffect(() => { + if (!isAuthenticated()) { + toast.error("Please login to access this page"); + navigate("/login"); + } + }, [navigate]); + + return <>{children}; +} diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..f22dec6 --- /dev/null +++ b/src/components/ThemeToggle.tsx @@ -0,0 +1,36 @@ +import { Moon, Sun } from "lucide-react"; +import { useTheme } from "@/lib/theme-provider"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +export function ThemeToggle() { + const { theme, setTheme } = useTheme(); + + return ( + + + + + + setTheme("light")}> + Light + + setTheme("dark")}> + Dark + + setTheme("system")}> + System + + + + ); +} diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..5afd41d --- /dev/null +++ b/src/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 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 & 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/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..991f56e --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..65d4fcd --- /dev/null +++ b/src/components/ui/button.tsx @@ -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, + 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/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx new file mode 100644 index 0000000..115cff9 --- /dev/null +++ b/src/components/ui/calendar.tsx @@ -0,0 +1,76 @@ +"use client" + +import * as React from "react" +import { ChevronLeft, ChevronRight } from "lucide-react" +import { DayPicker } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +export type CalendarProps = React.ComponentProps + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" + : "[&:has([aria-selected])]:rounded-md" + ), + day: cn( + buttonVariants({ variant: "ghost" }), + "h-8 w-8 p-0 font-normal aria-selected:opacity-100" + ), + day_range_start: "day-range-start", + day_range_end: "day-range-end", + day_selected: + "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: + "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground", + day_disabled: "text-muted-foreground opacity-50", + day_range_middle: + "aria-selected:bg-accent aria-selected:text-accent-foreground", + day_hidden: "invisible", + ...classNames, + }} + components={{ + IconLeft: ({ className, ...props }) => ( + + ), + IconRight: ({ className, ...props }) => ( + + ), + }} + {...props} + /> + ) +} +Calendar.displayName = "Calendar" + +export { Calendar } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..cabfbfc --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,76 @@ +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/src/components/ui/date-time-picker.tsx b/src/components/ui/date-time-picker.tsx new file mode 100644 index 0000000..096295d --- /dev/null +++ b/src/components/ui/date-time-picker.tsx @@ -0,0 +1,116 @@ +import * as React from "react"; +import { format } from "date-fns"; +import { Calendar as CalendarIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Calendar } from "@/components/ui/calendar"; + +interface DateTimePickerProps { + date: Date; + setDate: (date: Date) => void; + disabled?: boolean; +} + +export function DateTimePicker({ + date, + setDate, + disabled = false, +}: DateTimePickerProps) { + const [selectedDate, setSelectedDate] = React.useState(date); + + // Update the date when selectedDate changes + React.useEffect(() => { + if (selectedDate) { + setDate(selectedDate); + } + }, [selectedDate, setDate]); + + // Update the selectedDate when date prop changes + React.useEffect(() => { + setSelectedDate(date); + }, [date]); + + const handleSelect = (selected: Date | undefined) => { + if (selected) { + const currentDate = selectedDate || new Date(); + const newDate = new Date( + selected.getFullYear(), + selected.getMonth(), + selected.getDate(), + currentDate.getHours(), + currentDate.getMinutes(), + currentDate.getSeconds() + ); + setSelectedDate(newDate); + } + }; + + const handleTimeChange = (event: React.ChangeEvent) => { + if (!selectedDate) return; + + const [hours, minutes] = event.target.value.split(":").map(Number); + + if (!isNaN(hours) && !isNaN(minutes)) { + const newDate = new Date(selectedDate); + newDate.setHours(hours); + newDate.setMinutes(minutes); + setSelectedDate(newDate); + } + }; + + const formattedDate = selectedDate + ? format(selectedDate, "PPP") + : "Pick a date"; + + const formattedTime = selectedDate + ? format(selectedDate, "HH:mm") + : ""; + + return ( +
+ + + + + + + + + + +
+ ); +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..9dbeaa0 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,120 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..5a20503 --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,201 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + svg]:size-4 [&>svg]:shrink-0", + inset && "pl-8", + className + )} + {...props} + /> +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000..82c512f --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,176 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + FormProvider, + useFormContext, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +interface FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +interface FormItemContextValue { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +