commit e5682aacbd6210cc83281ba0ff4326ba29624613 Author: hamster1963 <1410514192@qq.com> Date: Fri Nov 22 22:20:38 2024 +0800 update: init diff --git a/.github/workflows/Build.yml b/.github/workflows/Build.yml new file mode 100644 index 0000000..cba4a7c --- /dev/null +++ b/.github/workflows/Build.yml @@ -0,0 +1,55 @@ +name: Build and release static export + +on: + push: + tags: + - "v*" + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: "latest" + + - name: Install dependencies + run: bun install + + - name: Build static export + run: | + bun run build --base=/dashboard + + - name: Compress dist folder + run: zip -r dist.zip dist + + - name: Release + uses: softprops/action-gh-release@v2 + with: + files: dist.zip + + changelog: + needs: release + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set node + uses: actions/setup-node@v4 + with: + registry-url: https://registry.npmjs.org/ + node-version: lts/* + + - run: npx changelogithub + env: + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 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/README.md b/README.md new file mode 100644 index 0000000..fe5e5ce --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Nezha-Dashboard diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..2c56be6 Binary files /dev/null and b/bun.lockb differ diff --git a/components.json b/components.json new file mode 100644 index 0000000..10d70eb --- /dev/null +++ b/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "stone", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..79a552e --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,28 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist"] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + }, + }, +); diff --git a/index.html b/index.html new file mode 100644 index 0000000..e4b78ea --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..3e2c1c0 --- /dev/null +++ b/package.json @@ -0,0 +1,54 @@ +{ + "name": "nazha-dashboard-vite", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@fontsource/inter": "^5.1.0", + "@heroicons/react": "^2.2.0", + "@radix-ui/react-checkbox": "^1.1.2", + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slot": "^1.1.0", + "@tanstack/react-query": "^5.59.16", + "@tanstack/react-query-devtools": "^5.59.16", + "@tanstack/react-table": "^8.20.5", + "@types/luxon": "^3.4.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "framer-motion": "^11.11.10", + "lucide-react": "^0.453.0", + "luxon": "^3.5.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.27.0", + "sonner": "^1.5.0", + "tailwind-merge": "^2.5.4", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@eslint/js": "^9.13.0", + "@types/node": "^22.8.1", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react-swc": "^3.7.1", + "autoprefixer": "^10.4.20", + "eslint": "^9.13.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.14", + "globals": "^15.11.0", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", + "typescript": "~5.6.3", + "typescript-eslint": "^8.11.0", + "vite": "^5.4.10" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..e9c7708 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..68dfb28 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; +import Header from "./components/Header"; +import Footer from "./components/Footer"; +import Server from "./pages/Server"; + +const App: React.FC = () => { + return ( + +
+
+
+ + + } + /> + +
+
+
+ ); +}; + +export default App; diff --git a/src/assets/apple-touch-icon.png b/src/assets/apple-touch-icon.png new file mode 100644 index 0000000..5e07a89 Binary files /dev/null and b/src/assets/apple-touch-icon.png differ diff --git a/src/assets/react.svg b/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000..4870e24 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,19 @@ +// src/components/Footer.tsx +import React from "react"; + +const Footer: React.FC = () => { + return ( + + ); +}; + +export default Footer; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..457581e --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,89 @@ +"use client"; + +// import { LanguageSwitcher } from "@/components/LanguageSwitcher"; +import { ModeToggle } from "@/components/ThemeSwitcher"; +import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; +import { DateTime } from "luxon"; +import { useEffect, useRef, useState } from "react"; + +function Header() { + + return ( +
+
+
+
+ apple-touch-icon +
+ {"NezhaDash"} + +

+ 哪吒监控面板 +

+
+
+ {/* */} + +
+
+ +
+ ); +} + +// https://github.com/streamich/react-use/blob/master/src/useInterval.ts +const useInterval = (callback: () => void, delay: number | null) => { + const savedCallback = useRef<() => void>(() => { }); + useEffect(() => { + savedCallback.current = callback; + }); + useEffect(() => { + if (delay !== null) { + const interval = setInterval(() => savedCallback.current(), delay || 0); + return () => clearInterval(interval); + } + return undefined; + }, [delay]); +}; +function Overview() { + const [mouted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + const timeOption = DateTime.TIME_SIMPLE; + timeOption.hour12 = true; + const [timeString, setTimeString] = useState( + DateTime.now().setLocale("en-US").toLocaleString(timeOption), + ); + useInterval(() => { + setTimeString(DateTime.now().setLocale("en-US").toLocaleString(timeOption)); + }, 1000); + return ( +
+

👋 Overview

+
+

+ where the time is +

+ {mouted ? ( +

{timeString}

+ ) : ( + + )} +
+
+ ); +} +export default Header; diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx new file mode 100644 index 0000000..0eb39bf --- /dev/null +++ b/src/components/ThemeProvider.tsx @@ -0,0 +1,65 @@ +import { createContext, useEffect, useState, ReactNode } from "react"; + +export type Theme = "dark" | "light" | "system"; + +type ThemeProviderProps = { + children: ReactNode; + defaultTheme?: Theme; + storageKey?: string; +}; + +type ThemeProviderState = { + theme: Theme; + setTheme: (theme: Theme) => void; +}; + +const initialState: ThemeProviderState = { + theme: "system", + setTheme: () => null, +}; + +const ThemeProviderContext = createContext(initialState); + +export function ThemeProvider({ + children, + defaultTheme = "system", + storageKey = "vite-ui-theme", +}: ThemeProviderProps) { + const [theme, setTheme] = useState( + () => (localStorage.getItem(storageKey) as Theme) || defaultTheme, + ); + + useEffect(() => { + const root = window.document.documentElement; + + root.classList.remove("light", "dark"); + + if (theme === "system") { + const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") + .matches + ? "dark" + : "light"; + + root.classList.add(systemTheme); + return; + } + + root.classList.add(theme); + }, [theme]); + + const value = { + theme, + setTheme: (theme: Theme) => { + localStorage.setItem(storageKey, theme); + setTheme(theme); + }, + }; + + return ( + + {children} + + ); +} + +export { ThemeProviderContext }; diff --git a/src/components/ThemeSwitcher.tsx b/src/components/ThemeSwitcher.tsx new file mode 100644 index 0000000..86a7dab --- /dev/null +++ b/src/components/ThemeSwitcher.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; +import { CheckCircleIcon } from "@heroicons/react/20/solid"; +import { Moon, Sun } from "lucide-react"; +import { Theme } from "@/components/ThemeProvider"; +import { useTheme } from "../hooks/use-theme"; + +export function ModeToggle() { + const { setTheme, theme } = useTheme(); + + const handleSelect = (e: Event, newTheme: Theme) => { + e.preventDefault(); + setTheme(newTheme); + }; + + return ( + + + + + + handleSelect(e, "light")} + > + Light + {theme === "light" && } + + handleSelect(e, "dark")} + > + Dark + {theme === "dark" && } + + handleSelect(e, "system")} + > + System + {theme === "system" && } + + + + ); +} diff --git a/src/components/loading/Loader.tsx b/src/components/loading/Loader.tsx new file mode 100644 index 0000000..d8f2108 --- /dev/null +++ b/src/components/loading/Loader.tsx @@ -0,0 +1,13 @@ +const bars = Array(8).fill(0); + +export const Loader = ({ visible }: { visible: boolean }) => { + return ( +
+
+ {bars.map((_, i) => ( +
+ ))} +
+
+ ); +}; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..5373829 --- /dev/null +++ b/src/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 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/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..58d1768 --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { Check } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..775c93a --- /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, + DialogClose, + DialogTrigger, + 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..e209caa --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,198 @@ +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) => ( + +)); +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/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..13c4c87 --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ); + }, +); +Input.displayName = "Input"; + +export { Input }; diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..44912af --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", +); + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx new file mode 100644 index 0000000..6d7f122 --- /dev/null +++ b/src/components/ui/separator.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..01b8b6d --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx new file mode 100644 index 0000000..73cf6b4 --- /dev/null +++ b/src/components/ui/table.tsx @@ -0,0 +1,117 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)); +Table.displayName = "Table"; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableHeader.displayName = "TableHeader"; + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = "TableBody"; + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className, + )} + {...props} + /> +)); +TableFooter.displayName = "TableFooter"; + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableRow.displayName = "TableRow"; + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableHead.displayName = "TableHead"; + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableCell.displayName = "TableCell"; + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableCaption.displayName = "TableCaption"; + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +}; diff --git a/src/hooks/use-theme.ts b/src/hooks/use-theme.ts new file mode 100644 index 0000000..fe7a46c --- /dev/null +++ b/src/hooks/use-theme.ts @@ -0,0 +1,12 @@ +import { useContext } from "react"; +import { ThemeProviderContext } from "../components/ThemeProvider"; + +export const useTheme = () => { + const context = useContext(ThemeProviderContext); + + if (context === undefined) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + + return context; +}; diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..2a5cd60 --- /dev/null +++ b/src/index.css @@ -0,0 +1,234 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 20 14.3% 4.1%; + --card: 0 0% 100%; + --card-foreground: 20 14.3% 4.1%; + --popover: 0 0% 100%; + --popover-foreground: 20 14.3% 4.1%; + --primary: 24 9.8% 10%; + --primary-foreground: 60 9.1% 97.8%; + --secondary: 60 4.8% 95.9%; + --secondary-foreground: 24 9.8% 10%; + --muted: 60 4.8% 95.9%; + --muted-foreground: 25 5.3% 44.7%; + --accent: 60 4.8% 95.9%; + --accent-foreground: 24 9.8% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 60 9.1% 97.8%; + --border: 20 5.9% 90%; + --input: 20 5.9% 90%; + --ring: 20 14.3% 4.1%; + --radius: 1rem; + --chart-1: 220 70% 50%; + --chart-2: 340 75% 55%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 160 60% 45%; + --chart-6: 180 50% 50%; + --chart-7: 216 50% 50%; + --chart-8: 252 50% 50%; + --chart-9: 288 50% 50%; + --chart-10: 324 50% 50%; + } + + .dark { + --background: 20 14.3% 4.1%; + --foreground: 60 9.1% 97.8%; + --card: 20 14.3% 4.1%; + --card-foreground: 60 9.1% 97.8%; + --popover: 20 14.3% 4.1%; + --popover-foreground: 60 9.1% 97.8%; + --primary: 60 9.1% 97.8%; + --primary-foreground: 24 9.8% 10%; + --secondary: 12 6.5% 15.1%; + --secondary-foreground: 60 9.1% 97.8%; + --muted: 12 6.5% 15.1%; + --muted-foreground: 24 5.4% 63.9%; + --accent: 12 6.5% 15.1%; + --accent-foreground: 60 9.1% 97.8%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 60 9.1% 97.8%; + --border: 12 6.5% 15.1%; + --input: 12 6.5% 15.1%; + --ring: 24 5.7% 82.9%; + --chart-1: 220 70% 50%; + --chart-2: 340 75% 55%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 160 60% 45%; + --chart-6: 180 50% 50%; + --chart-7: 216 50% 50%; + --chart-8: 252 50% 50%; + --chart-9: 288 50% 50%; + --chart-10: 324 50% 50%; + } +} + +@layer base { + * { + @apply border-border; + } + html { + @apply scroll-smooth; + } + body { + @apply bg-background text-foreground; + /* font-feature-settings: "rlig" 1, "calt" 1; */ + font-synthesis-weight: none; + text-rendering: optimizeLegibility; + } +} + +@layer utilities { + .step { + counter-increment: step; + } + + .step:before { + @apply absolute inline-flex h-9 w-9 items-center justify-center rounded-full border-4 border-background bg-muted text-center -indent-px font-mono text-base font-medium; + @apply ml-[-50px] mt-[-4px]; + content: counter(step); + } +} + +@media (max-width: 640px) { + .container { + @apply px-4; + } +} + +::selection { + @apply bg-stone-300 dark:bg-stone-800; +} + +.hamster-loading-wrapper { + --size: 12px; + height: var(--size); + width: var(--size); + inset: 0; + z-index: 10; +} + +.hamster-loading-wrapper[data-visible="false"] { + transform-origin: center; + animation: hamster-fade-out 0.2s ease forwards; +} + +.hamster-spinner { + position: relative; + top: 50%; + left: 50%; + height: var(--size); + width: var(--size); +} + +.hamster-loading-bar { + --gray11: hsl(0, 0%, 43.5%); + animation: hamster-spin 0.8s linear infinite; + background: var(--gray11); + border-radius: 6px; + height: 13%; + left: -10%; + position: absolute; + top: -3.9%; + width: 30%; +} + +.hamster-loading-bar:nth-child(1) { + animation-delay: -0.8s; + transform: rotate(0deg) translate(120%); +} + +.hamster-loading-bar:nth-child(2) { + animation-delay: -0.7s; + transform: rotate(45deg) translate(120%); +} + +.hamster-loading-bar:nth-child(3) { + animation-delay: -0.6s; + transform: rotate(90deg) translate(120%); +} + +.hamster-loading-bar:nth-child(4) { + animation-delay: -0.5s; + transform: rotate(135deg) translate(120%); +} + +.hamster-loading-bar:nth-child(5) { + animation-delay: -0.4s; + transform: rotate(180deg) translate(120%); +} + +.hamster-loading-bar:nth-child(6) { + animation-delay: -0.3s; + transform: rotate(225deg) translate(120%); +} + +.hamster-loading-bar:nth-child(7) { + animation-delay: -0.2s; + transform: rotate(270deg) translate(120%); +} + +.hamster-loading-bar:nth-child(8) { + animation-delay: -0.1s; + transform: rotate(315deg) translate(120%); +} + +@keyframes hamster-fade-in { + 0% { + opacity: 0; + transform: scale(0.8); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes hamster-fade-out { + 0% { + opacity: 1; + transform: scale(1); + } + 100% { + opacity: 0; + transform: scale(0.8); + } +} + +@keyframes hamster-spin { + 0% { + opacity: 1; + } + 100% { + opacity: 0.15; + } +} + +.scrollbar-hidden { + scrollbar-width: none; /* Firefox */ +} + +.scrollbar-hidden::-webkit-scrollbar { + display: none; /* Chrome, Safari 和 Opera */ +} diff --git a/src/lib/nav-router.ts b/src/lib/nav-router.ts new file mode 100644 index 0000000..c486b69 --- /dev/null +++ b/src/lib/nav-router.ts @@ -0,0 +1,30 @@ +export const navRouter = [ + { + name: "服务器", + path: "/", + }, + { + name: "服务(Dev)", + path: "/service", + }, + { + name: "任务(Dev)", + path: "/task", + }, + { + name: "告警(Dev)", + path: "/alarm", + }, + { + name: "内网穿透(Dev)", + path: "/intranet", + }, + { + name: "用户", + path: "/user", + }, + { + name: "设置(Dev)", + path: "/setting", + }, +]; diff --git a/src/lib/nezha-api.ts b/src/lib/nezha-api.ts new file mode 100644 index 0000000..492c87a --- /dev/null +++ b/src/lib/nezha-api.ts @@ -0,0 +1,80 @@ +export const fetchUsers = async (token: string) => { + const response = await fetch("http://localhost:8008/api/v1/user", { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + const data = await response.json(); + if (data.error) { + throw new Error(data.error); + } + return data; +}; + +export const createUser = async ( + token: string, + username: string, + password: string, +) => { + const response = await fetch(`http://localhost:8008/api/v1/user`, { + method: "POST", + body: JSON.stringify({ username, password }), + headers: { + Authorization: `Bearer ${token}`, + }, + }); + const data = await response.json(); + if (data.error) { + throw new Error(data.error); + } + return data; +}; + +export const deleteUser = async (token: string, ids: number[]) => { + const response = await fetch( + `http://localhost:8008/api/v1/batch-delete/user`, + { + method: "POST", + body: JSON.stringify(ids), + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + const data = await response.json(); + if (data.error) { + throw new Error(data.error); + } + return data; +}; + +export const fetchServers = async (token: string) => { + const response = await fetch("http://localhost:8008/api/v1/server", { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + const data = await response.json(); + if (data.error) { + throw new Error(data.error); + } + return data; +}; + +export const deleteServer = async (token: string, ids: number[]) => { + const response = await fetch( + `http://localhost:8008/api/v1/batch-delete/server`, + { + method: "POST", + body: JSON.stringify(ids), + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + const data = await response.json(); + if (data.error) { + throw new Error(data.error); + } + return data; +}; diff --git a/src/lib/nezha-model.ts b/src/lib/nezha-model.ts new file mode 100644 index 0000000..9e049c5 --- /dev/null +++ b/src/lib/nezha-model.ts @@ -0,0 +1,79 @@ +/** + * model.Server + */ +export interface ModelServer { + created_at: string; + /** + * DDNS配置 + */ + ddns_profiles: number[]; + deleted_at: string; + /** + * 展示排序,越大越靠前 + */ + display_index: number; + /** + * 启用DDNS + */ + enable_ddns: boolean; + /** + * 对游客隐藏 + */ + hide_for_guest?: boolean; + host?: ModelHost; + id: number; + last_active?: string; + name: string; + /** + * 管理员可见备注 + */ + note: string; + /** + * 公开备注 + */ + public_note: string; + state: ModelHostState; + updated_at: string; + uuid: string; +} + +export interface ModelHost { + arch?: string; + boot_time?: number; + country_code?: string; + cpu?: string[]; + disk_total?: number; + gpu?: string[]; + ip?: string; + mem_total?: number; + platform?: string; + platform_version?: string; + swap_total?: number; + version?: string; + virtualization?: string; +} + +export interface ModelHostState { + cpu?: number; + disk_used?: number; + gpu?: number[]; + load_1?: number; + load_15?: number; + load_5?: number; + mem_used?: number; + net_in_speed?: number; + net_in_transfer?: number; + net_out_speed?: number; + net_out_transfer?: number; + process_count?: number; + swap_used?: number; + tcp_conn_count?: number; + temperatures?: ModelSensorTemperature[]; + udp_conn_count?: number; + uptime?: number; +} + +export interface ModelSensorTemperature { + name?: string; + temperature?: number; +} diff --git a/src/lib/useWebsocket.tsx b/src/lib/useWebsocket.tsx new file mode 100644 index 0000000..0ae0f6d --- /dev/null +++ b/src/lib/useWebsocket.tsx @@ -0,0 +1,89 @@ +import { useState, useEffect, useRef, useCallback } from 'react' + +export interface WebSocketHook { + socket: WebSocket | null + connected: boolean + onlineCount: number + message: string | null + sendMessage: (msg: string) => void +} + +export default function useWebSocket(url: string): WebSocketHook { + const [socket, setSocket] = useState(null) + const [message, setMessage] = useState(null) + const [connected, setConnected] = useState(false) + const [onlineCount, setOnlineCount] = useState(0) + const socketRef = useRef(null) + const reconnectAttempts = useRef(0) + const reconnectTimeout = useRef(null) + const isUnmounted = useRef(false) + + const connect = useCallback(() => { + if (isUnmounted.current) return + + const ws = new WebSocket(url) + setSocket(ws) + socketRef.current = ws + + ws.onopen = () => { + setConnected(true) + reconnectAttempts.current = 0 + } + + ws.onmessage = (event: MessageEvent) => { + setMessage(event.data) + const msgJson = JSON.parse(event.data) + if (msgJson.type === 'live') { + setOnlineCount(msgJson.data.count) + } + } + + ws.onerror = (error) => { + console.error('WebSocket Error:', error) + } + + ws.onclose = () => { + setConnected(false) + if (!isUnmounted.current) { + // Attempt to reconnect + if (reconnectAttempts.current < 5) { + const timeout = Math.pow(2, reconnectAttempts.current) * 1000 // Exponential backoff + reconnectAttempts.current += 1 + reconnectTimeout.current = setTimeout(() => { + connect() + }, timeout) + } else { + console.warn('Max reconnect attempts reached.') + } + } + } + }, [url]) + + useEffect(() => { + connect() + + return () => { + isUnmounted.current = true + if (socketRef.current) { + socketRef.current.close() + } + if (reconnectTimeout.current) { + clearTimeout(reconnectTimeout.current) + } + } + }, [connect]) + + // Function to send messages + const sendMessage = useCallback((msg: string) => { + if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) { + socketRef.current.send(msg) + } else { + console.warn( + 'WebSocket is not open. Ready state:', + socketRef.current?.readyState + ) + } + }, []) + + return { socket, message, sendMessage, connected, onlineCount } +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..a5ef193 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/lib/websocketProvider.tsx b/src/lib/websocketProvider.tsx new file mode 100644 index 0000000..81ae95f --- /dev/null +++ b/src/lib/websocketProvider.tsx @@ -0,0 +1,26 @@ +import { createContext, useContext, ReactNode } from 'react' +import useWebSocket, { WebSocketHook } from './useWebsocket' + + +interface WebSocketProviderProps { + children: ReactNode +} + +const WebSocketContext = createContext(undefined) + +export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { + const ws = useWebSocket('wss://dev-next.buycoffee.top:4433/api/v1/ws/server') + return ( + {children} + ) +} + +export const useWebSocketContext = (): WebSocketHook => { + const context = useContext(WebSocketContext) + if (!context) { + throw new Error( + 'useWebSocketContext must be used within a WebSocketProvider' + ) + } + return context +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..293e182 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import "./index.css"; +import { ThemeProvider } from "./components/ThemeProvider"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Toaster } from "sonner"; +import { WebSocketProvider } from "./lib/websocketProvider"; + +const queryClient = new QueryClient(); + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + + + + + + + , +); diff --git a/src/pages/Server.tsx b/src/pages/Server.tsx new file mode 100644 index 0000000..147044f --- /dev/null +++ b/src/pages/Server.tsx @@ -0,0 +1,22 @@ +export default function Servers() { + return ( +
+
+
+

+ 服务器 +

+

+ 你可以在这里查看和管理全部的服务器。 + + 了解更多↗ + +

+
+
+
+ ); +} \ No newline at end of file diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..1fd7518 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,59 @@ +module.exports = { + darkMode: ["class"], + content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + theme: { + extend: { + fontFamily: { + sans: "var(--font-sans)", + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + colors: { + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + chart: { + 1: "hsl(var(--chart-1))", + 2: "hsl(var(--chart-2))", + 3: "hsl(var(--chart-3))", + 4: "hsl(var(--chart-4))", + 5: "hsl(var(--chart-5))", + }, + }, + }, + }, + plugins: [require("tailwindcss-animate")], +}; diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..d1f4998 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo new file mode 100644 index 0000000..1847453 --- /dev/null +++ b/tsconfig.app.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/footer.tsx","./src/components/header.tsx","./src/components/modetoggle.tsx","./src/components/privateroute.tsx","./src/components/profile.tsx","./src/components/themeprovider.tsx","./src/components/loading/loader.tsx","./src/components/ui/button.tsx","./src/components/ui/dropdown-menu.tsx","./src/hooks/useauth.tsx","./src/lib/nav-router.ts","./src/lib/utils.ts","./src/pages/alarm.tsx","./src/pages/intranet.tsx","./src/pages/login.tsx","./src/pages/server.tsx","./src/pages/service.tsx","./src/pages/setting.tsx","./src/pages/task.tsx","./src/pages/user.tsx"],"version":"5.6.3"} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fec8c8e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..9dad701 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/tsconfig.node.tsbuildinfo b/tsconfig.node.tsbuildinfo new file mode 100644 index 0000000..75ea001 --- /dev/null +++ b/tsconfig.node.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./vite.config.ts"],"version":"5.6.3"} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..4e939e1 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,13 @@ +import path from "path"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +});