From b78ab7d53974205d7b7942c55fd4b82e033b6927 Mon Sep 17 00:00:00 2001 From: Mohammad Mahdi Date: Thu, 17 Apr 2025 14:39:00 +0330 Subject: [PATCH] first commit --- .gitignore | 24 +++ .prettierrc | 16 ++ README.md | 52 +++++ components.json | 21 ++ eslint.config.js | 123 ++++++++++++ index.html | 17 ++ netlify.toml | 24 +++ package.json | 92 +++++++++ postcss.config.js | 6 + public/CNAME | 1 + public/favicon.svg | 16 ++ src/App.tsx | 62 ++++++ src/components/ApiKeyItem.tsx | 130 +++++++++++++ src/components/FeedActions.tsx | 95 +++++++++ src/components/FeedChart.tsx | 130 +++++++++++++ src/components/Header.tsx | 187 ++++++++++++++++++ src/components/Layout.tsx | 31 +++ src/components/ProtectedRoute.tsx | 21 ++ src/components/ThemeToggle.tsx | 36 ++++ src/components/ui/alert.tsx | 59 ++++++ src/components/ui/avatar.tsx | 48 +++++ src/components/ui/button.tsx | 57 ++++++ src/components/ui/calendar.tsx | 76 ++++++++ src/components/ui/card.tsx | 76 ++++++++ src/components/ui/date-time-picker.tsx | 116 +++++++++++ src/components/ui/dialog.tsx | 120 ++++++++++++ src/components/ui/dropdown-menu.tsx | 201 +++++++++++++++++++ src/components/ui/form.tsx | 176 +++++++++++++++++ src/components/ui/input.tsx | 22 +++ src/components/ui/label.tsx | 26 +++ src/components/ui/popover.tsx | 31 +++ src/components/ui/select.tsx | 157 +++++++++++++++ src/components/ui/separator.tsx | 29 +++ src/components/ui/sheet.tsx | 138 +++++++++++++ src/components/ui/skeleton.tsx | 15 ++ src/components/ui/tabs.tsx | 55 ++++++ src/components/ui/toast.tsx | 129 +++++++++++++ src/components/ui/toaster.tsx | 35 ++++ src/hooks/use-toast.ts | 191 ++++++++++++++++++ src/index.css | 73 +++++++ src/lib/api.ts | 176 +++++++++++++++++ src/lib/auth.ts | 63 ++++++ src/lib/store.ts | 138 +++++++++++++ src/lib/theme-provider.tsx | 72 +++++++ src/lib/timeRanges.ts | 152 +++++++++++++++ src/lib/types.ts | 71 +++++++ src/lib/utils.ts | 6 + src/main.tsx | 30 +++ src/pages/ApiKeysPage.tsx | 190 ++++++++++++++++++ src/pages/DashboardPage.tsx | 257 +++++++++++++++++++++++++ src/pages/HomePage.tsx | 85 ++++++++ src/pages/LoginPage.tsx | 138 +++++++++++++ src/pages/ProfilePage.tsx | 85 ++++++++ src/pages/RegisterPage.tsx | 166 ++++++++++++++++ src/pages/VerifyEmail.tsx | 46 +++++ src/vite-env.d.ts | 1 + tailwind.config.js | 76 ++++++++ tsconfig.json | 26 +++ tsconfig.tsbuildinfo | 1 + vite.config.ts | 26 +++ 60 files changed, 4719 insertions(+) create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 README.md create mode 100644 components.json create mode 100644 eslint.config.js create mode 100644 index.html create mode 100644 netlify.toml create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 public/CNAME create mode 100644 public/favicon.svg create mode 100644 src/App.tsx create mode 100644 src/components/ApiKeyItem.tsx create mode 100644 src/components/FeedActions.tsx create mode 100644 src/components/FeedChart.tsx create mode 100644 src/components/Header.tsx create mode 100644 src/components/Layout.tsx create mode 100644 src/components/ProtectedRoute.tsx create mode 100644 src/components/ThemeToggle.tsx create mode 100644 src/components/ui/alert.tsx create mode 100644 src/components/ui/avatar.tsx create mode 100644 src/components/ui/button.tsx create mode 100644 src/components/ui/calendar.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/date-time-picker.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/dropdown-menu.tsx create mode 100644 src/components/ui/form.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/popover.tsx create mode 100644 src/components/ui/select.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/components/ui/sheet.tsx create mode 100644 src/components/ui/skeleton.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 src/components/ui/toast.tsx create mode 100644 src/components/ui/toaster.tsx create mode 100644 src/hooks/use-toast.ts create mode 100644 src/index.css create mode 100644 src/lib/api.ts create mode 100644 src/lib/auth.ts create mode 100644 src/lib/store.ts create mode 100644 src/lib/theme-provider.tsx create mode 100644 src/lib/timeRanges.ts create mode 100644 src/lib/types.ts create mode 100644 src/lib/utils.ts create mode 100644 src/main.tsx create mode 100644 src/pages/ApiKeysPage.tsx create mode 100644 src/pages/DashboardPage.tsx create mode 100644 src/pages/HomePage.tsx create mode 100644 src/pages/LoginPage.tsx create mode 100644 src/pages/ProfilePage.tsx create mode 100644 src/pages/RegisterPage.tsx create mode 100644 src/pages/VerifyEmail.tsx create mode 100644 src/vite-env.d.ts create mode 100644 tailwind.config.js create mode 100644 tsconfig.json create mode 100644 tsconfig.tsbuildinfo create mode 100644 vite.config.ts 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 ( +