53 changed files with 13639 additions and 1 deletions
@ -0,0 +1 @@ |
|||
VITE_API_BASE_URL=https://localhost:5001/api/v1 |
@ -0,0 +1 @@ |
|||
VITE_API_BASE_URL=https://your-api-url/api/v1 |
@ -0,0 +1 @@ |
|||
VITE_API_BASE_URL=https://localhost:5001/api/v1 |
@ -0,0 +1,31 @@ |
|||
# 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? |
|||
|
|||
# Environment variables |
|||
.env |
|||
.env.local |
|||
.env.development.local |
|||
.env.test.local |
|||
.env.production.local |
Binary file not shown.
@ -0,0 +1 @@ |
|||
nodeLinker: node-modules |
@ -0,0 +1,16 @@ |
|||
{ |
|||
"$schema": "https://ui.shadcn.com/schema.json", |
|||
"style": "default", |
|||
"rsc": false, |
|||
"tsx": true, |
|||
"tailwind": { |
|||
"config": "tailwind.config.js", |
|||
"css": "src/index.css", |
|||
"baseColor": "slate", |
|||
"cssVariables": true |
|||
}, |
|||
"aliases": { |
|||
"components": "@/components", |
|||
"utils": "@/lib/utils" |
|||
} |
|||
} |
@ -0,0 +1,13 @@ |
|||
<!DOCTYPE html> |
|||
<html lang="en"> |
|||
<head> |
|||
<meta charset="UTF-8" /> |
|||
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|||
<title>Cellular Management</title> |
|||
</head> |
|||
<body> |
|||
<div id="root"></div> |
|||
<script type="module" src="/src/main.tsx"></script> |
|||
</body> |
|||
</html> |
File diff suppressed because it is too large
@ -0,0 +1,59 @@ |
|||
{ |
|||
"name": "CellularManagementAdmin", |
|||
"private": true, |
|||
"version": "1.0.0", |
|||
"type": "module", |
|||
"scripts": { |
|||
"dev": "vite --host 0.0.0.0", |
|||
"build": "tsc && vite build", |
|||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", |
|||
"preview": "vite preview", |
|||
"prepare": "husky install" |
|||
}, |
|||
"dependencies": { |
|||
"@radix-ui/react-alert-dialog": "^1.0.5", |
|||
"@radix-ui/react-avatar": "^1.0.4", |
|||
"@radix-ui/react-dialog": "^1.0.5", |
|||
"@radix-ui/react-dropdown-menu": "^2.0.6", |
|||
"@radix-ui/react-label": "^2.0.2", |
|||
"@radix-ui/react-slot": "^1.0.2", |
|||
"@radix-ui/react-toast": "^1.2.10", |
|||
"@types/axios": "^0.14.4", |
|||
"axios": "^1.8.4", |
|||
"class-variance-authority": "^0.7.1", |
|||
"clsx": "^2.1.0", |
|||
"crypto-js": "^4.2.0", |
|||
"jsencrypt": "^3.3.2", |
|||
"lucide-react": "^0.503.0", |
|||
"react": "^19.1.0", |
|||
"react-dom": "^19.1.0", |
|||
"react-router-dom": "^7.5.1", |
|||
"recoil": "^0.7.7", |
|||
"tailwind-merge": "^2.2.1", |
|||
"tailwindcss-animate": "^1.0.7" |
|||
}, |
|||
"devDependencies": { |
|||
"@types/node": "^22.14.1", |
|||
"@types/react": "^19.1.2", |
|||
"@types/react-dom": "^19.1.2", |
|||
"@typescript-eslint/eslint-plugin": "^7.0.2", |
|||
"@typescript-eslint/parser": "^7.0.2", |
|||
"@vitejs/plugin-react": "^4.4.1", |
|||
"autoprefixer": "^10.4.17", |
|||
"eslint": "^9.20.1", |
|||
"eslint-plugin-react-hooks": "^5.1.0", |
|||
"eslint-plugin-react-refresh": "^0.4.19", |
|||
"husky": "^9.0.11", |
|||
"lint-staged": "^15.2.2", |
|||
"postcss": "^8.4.35", |
|||
"prettier": "^3.2.5", |
|||
"prettier-plugin-tailwindcss": "^0.5.11", |
|||
"tailwindcss": "^3.4.1", |
|||
"typescript": "^5.3.3", |
|||
"vite": "^6.1.0" |
|||
}, |
|||
"engines": { |
|||
"yarn": ">=1.22.0" |
|||
}, |
|||
"packageManager": "yarn@4.1.0" |
|||
} |
@ -0,0 +1,6 @@ |
|||
export default { |
|||
plugins: { |
|||
tailwindcss: {}, |
|||
autoprefixer: {}, |
|||
}, |
|||
} |
@ -0,0 +1,54 @@ |
|||
import React from 'react' |
|||
import { Routes, Route, Navigate } from 'react-router-dom' |
|||
import { AuthProvider, useAuth } from '@/contexts/AuthContext' |
|||
import Layout from '@/components/Layout' |
|||
import Dashboard from '@/pages/Dashboard' |
|||
import Login from '@/pages/Login' |
|||
import Register from '@/pages/Register' |
|||
import ForgotPassword from '@/pages/ForgotPassword' |
|||
import ResetPassword from '@/pages/ResetPassword' |
|||
import UserManagement from '@/pages/UserManagement' |
|||
import { ToastProvider } from '@/components/ui/toast' |
|||
|
|||
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { |
|||
const { isAuthenticated, loading } = useAuth() |
|||
|
|||
if (loading) { |
|||
return <div>Loading...</div> |
|||
} |
|||
|
|||
if (!isAuthenticated) { |
|||
return <Navigate to="/login" replace /> |
|||
} |
|||
|
|||
return <>{children}</> |
|||
} |
|||
|
|||
function App() { |
|||
return ( |
|||
<AuthProvider> |
|||
<ToastProvider> |
|||
<Routes> |
|||
<Route path="/login" element={<Login />} /> |
|||
<Route path="/register" element={<Register />} /> |
|||
<Route path="/forgot-password" element={<ForgotPassword />} /> |
|||
<Route path="/reset-password" element={<ResetPassword />} /> |
|||
<Route |
|||
path="/" |
|||
element={ |
|||
<ProtectedRoute> |
|||
<Layout /> |
|||
</ProtectedRoute> |
|||
} |
|||
> |
|||
<Route index element={<Navigate to="/dashboard" replace />} /> |
|||
<Route path="dashboard" element={<Dashboard />} /> |
|||
<Route path="users" element={<UserManagement />} /> |
|||
</Route> |
|||
</Routes> |
|||
</ToastProvider> |
|||
</AuthProvider> |
|||
) |
|||
} |
|||
|
|||
export default App |
@ -0,0 +1,28 @@ |
|||
import { useTheme } from '@/hooks/use-theme'; |
|||
import { Button } from '@/components/ui/button'; |
|||
import { Sun, Moon } from 'lucide-react'; |
|||
|
|||
export default function Header() { |
|||
const { theme, setTheme } = useTheme(); |
|||
|
|||
return ( |
|||
<header className="h-16 border-b flex items-center justify-between px-4"> |
|||
<div className="flex items-center"> |
|||
<h1 className="text-xl font-semibold">Cellular Management</h1> |
|||
</div> |
|||
<div className="flex items-center space-x-4"> |
|||
<Button |
|||
variant="ghost" |
|||
size="icon" |
|||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} |
|||
> |
|||
{theme === 'dark' ? ( |
|||
<Sun className="h-5 w-5" /> |
|||
) : ( |
|||
<Moon className="h-5 w-5" /> |
|||
)} |
|||
</Button> |
|||
</div> |
|||
</header> |
|||
); |
|||
} |
@ -0,0 +1,19 @@ |
|||
import { Outlet } from 'react-router-dom'; |
|||
import Sidebar from './Sidebar'; |
|||
import Header from './Header'; |
|||
|
|||
export default function Layout() { |
|||
return ( |
|||
<div className="h-full"> |
|||
<div className="hidden md:flex h-full w-72 flex-col fixed inset-y-0"> |
|||
<Sidebar /> |
|||
</div> |
|||
<main className="md:pl-72 h-full"> |
|||
<Header /> |
|||
<div className="h-[calc(100vh-4rem)] overflow-y-auto"> |
|||
<Outlet /> |
|||
</div> |
|||
</main> |
|||
</div> |
|||
); |
|||
} |
@ -0,0 +1,57 @@ |
|||
import { Link, useLocation } from 'react-router-dom'; |
|||
import { cn } from '@/lib/utils'; |
|||
import { LayoutDashboard, Users, Settings } from 'lucide-react'; |
|||
|
|||
const routes = [ |
|||
{ |
|||
label: 'Dashboard', |
|||
icon: LayoutDashboard, |
|||
href: '/dashboard', |
|||
color: 'text-sky-500' |
|||
}, |
|||
{ |
|||
label: 'Users', |
|||
icon: Users, |
|||
href: '/users', |
|||
color: 'text-violet-500' |
|||
}, |
|||
{ |
|||
label: 'Settings', |
|||
icon: Settings, |
|||
href: '/settings', |
|||
color: 'text-gray-500' |
|||
} |
|||
]; |
|||
|
|||
export default function Sidebar() { |
|||
const location = useLocation(); |
|||
|
|||
return ( |
|||
<div className="space-y-4 py-4 flex flex-col h-full bg-[#111827] text-white"> |
|||
<div className="px-3 py-2 flex-1"> |
|||
<Link to="/dashboard" className="flex items-center pl-3 mb-14"> |
|||
<h1 className="text-2xl font-bold"> |
|||
CellularManagement |
|||
</h1> |
|||
</Link> |
|||
<div className="space-y-1"> |
|||
{routes.map((route) => ( |
|||
<Link |
|||
key={route.href} |
|||
to={route.href} |
|||
className={cn( |
|||
'text-sm group flex p-3 w-full justify-start font-medium cursor-pointer hover:text-white hover:bg-white/10 rounded-lg transition', |
|||
location.pathname === route.href ? 'text-white bg-white/10' : 'text-zinc-400' |
|||
)} |
|||
> |
|||
<div className="flex items-center flex-1"> |
|||
<route.icon className={cn('h-5 w-5 mr-3', route.color)} /> |
|||
{route.label} |
|||
</div> |
|||
</Link> |
|||
))} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
@ -0,0 +1,43 @@ |
|||
import { Button } from '@/components/ui/button' |
|||
import { |
|||
DropdownMenu, |
|||
DropdownMenuContent, |
|||
DropdownMenuItem, |
|||
DropdownMenuTrigger, |
|||
} from '@/components/ui/dropdown-menu' |
|||
import { useTheme } from '@/hooks/use-theme' |
|||
import { Moon, Sun, User } from 'lucide-react' |
|||
|
|||
export function Header() { |
|||
const { theme, setTheme } = useTheme() |
|||
|
|||
return ( |
|||
<header className="border-b"> |
|||
<div className="flex h-16 items-center px-4"> |
|||
<div className="ml-auto flex items-center space-x-4"> |
|||
<Button |
|||
variant="ghost" |
|||
size="icon" |
|||
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')} |
|||
> |
|||
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> |
|||
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> |
|||
<span className="sr-only">Toggle theme</span> |
|||
</Button> |
|||
<DropdownMenu> |
|||
<DropdownMenuTrigger asChild> |
|||
<Button variant="ghost" className="relative h-8 w-8 rounded-full"> |
|||
<User className="h-5 w-5" /> |
|||
</Button> |
|||
</DropdownMenuTrigger> |
|||
<DropdownMenuContent align="end"> |
|||
<DropdownMenuItem>Profile</DropdownMenuItem> |
|||
<DropdownMenuItem>Settings</DropdownMenuItem> |
|||
<DropdownMenuItem>Logout</DropdownMenuItem> |
|||
</DropdownMenuContent> |
|||
</DropdownMenu> |
|||
</div> |
|||
</div> |
|||
</header> |
|||
) |
|||
} |
@ -0,0 +1,17 @@ |
|||
import { Outlet } from 'react-router-dom' |
|||
import { Sidebar } from './Sidebar' |
|||
import { Header } from './Header' |
|||
|
|||
export default function Layout() { |
|||
return ( |
|||
<div className="flex h-screen bg-background"> |
|||
<Sidebar /> |
|||
<div className="flex flex-col flex-1 overflow-hidden"> |
|||
<Header /> |
|||
<main className="flex-1 overflow-y-auto p-4"> |
|||
<Outlet /> |
|||
</main> |
|||
</div> |
|||
</div> |
|||
) |
|||
} |
@ -0,0 +1,38 @@ |
|||
import { NavLink } from 'react-router-dom' |
|||
import { cn } from '@/lib/utils' |
|||
import { LayoutDashboard, Settings, Users } from 'lucide-react' |
|||
|
|||
const navigation = [ |
|||
{ name: 'Dashboard', href: '/', icon: LayoutDashboard }, |
|||
{ name: 'Users', href: '/users', icon: Users }, |
|||
{ name: 'Settings', href: '/settings', icon: Settings }, |
|||
] |
|||
|
|||
export function Sidebar() { |
|||
return ( |
|||
<div className="flex h-full w-64 flex-col border-r"> |
|||
<div className="flex h-16 items-center border-b px-4"> |
|||
<h1 className="text-xl font-semibold">Cellular Management</h1> |
|||
</div> |
|||
<nav className="flex-1 space-y-1 p-2"> |
|||
{navigation.map((item) => ( |
|||
<NavLink |
|||
key={item.name} |
|||
to={item.href} |
|||
className={({ isActive }) => |
|||
cn( |
|||
'flex items-center rounded-md px-3 py-2 text-sm font-medium', |
|||
isActive |
|||
? 'bg-primary text-primary-foreground' |
|||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground' |
|||
) |
|||
} |
|||
> |
|||
<item.icon className="mr-3 h-5 w-5" /> |
|||
{item.name} |
|||
</NavLink> |
|||
))} |
|||
</nav> |
|||
</div> |
|||
) |
|||
} |
@ -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 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", |
|||
{ |
|||
variants: { |
|||
variant: { |
|||
default: |
|||
"bg-primary text-primary-foreground shadow hover:bg-primary/90", |
|||
destructive: |
|||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", |
|||
outline: |
|||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", |
|||
secondary: |
|||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", |
|||
ghost: "hover:bg-accent hover:text-accent-foreground", |
|||
link: "text-primary underline-offset-4 hover:underline", |
|||
}, |
|||
size: { |
|||
default: "h-9 px-4 py-2", |
|||
sm: "h-8 rounded-md px-3 text-xs", |
|||
lg: "h-10 rounded-md px-8", |
|||
icon: "h-9 w-9", |
|||
}, |
|||
}, |
|||
defaultVariants: { |
|||
variant: "default", |
|||
size: "default", |
|||
}, |
|||
} |
|||
) |
|||
|
|||
export interface ButtonProps |
|||
extends React.ButtonHTMLAttributes<HTMLButtonElement>, |
|||
VariantProps<typeof buttonVariants> { |
|||
asChild?: boolean |
|||
} |
|||
|
|||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( |
|||
({ className, variant, size, asChild = false, ...props }, ref) => { |
|||
const Comp = asChild ? Slot : "button" |
|||
return ( |
|||
<Comp |
|||
className={cn(buttonVariants({ variant, size, className }))} |
|||
ref={ref} |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
) |
|||
Button.displayName = "Button" |
|||
|
|||
export { Button, buttonVariants } |
@ -0,0 +1,78 @@ |
|||
import * as React from "react" |
|||
import { cn } from "@/lib/utils" |
|||
|
|||
const Card = React.forwardRef< |
|||
HTMLDivElement, |
|||
React.HTMLAttributes<HTMLDivElement> |
|||
>(({ className, ...props }, ref) => ( |
|||
<div |
|||
ref={ref} |
|||
className={cn( |
|||
"rounded-lg border bg-card text-card-foreground shadow-sm", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
Card.displayName = "Card" |
|||
|
|||
const CardHeader = React.forwardRef< |
|||
HTMLDivElement, |
|||
React.HTMLAttributes<HTMLDivElement> |
|||
>(({ className, ...props }, ref) => ( |
|||
<div |
|||
ref={ref} |
|||
className={cn("flex flex-col space-y-1.5 p-6", className)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
CardHeader.displayName = "CardHeader" |
|||
|
|||
const CardTitle = React.forwardRef< |
|||
HTMLParagraphElement, |
|||
React.HTMLAttributes<HTMLHeadingElement> |
|||
>(({ className, ...props }, ref) => ( |
|||
<h3 |
|||
ref={ref} |
|||
className={cn( |
|||
"text-2xl font-semibold leading-none tracking-tight", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
CardTitle.displayName = "CardTitle" |
|||
|
|||
const CardDescription = React.forwardRef< |
|||
HTMLParagraphElement, |
|||
React.HTMLAttributes<HTMLParagraphElement> |
|||
>(({ className, ...props }, ref) => ( |
|||
<p |
|||
ref={ref} |
|||
className={cn("text-sm text-muted-foreground", className)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
CardDescription.displayName = "CardDescription" |
|||
|
|||
const CardContent = React.forwardRef< |
|||
HTMLDivElement, |
|||
React.HTMLAttributes<HTMLDivElement> |
|||
>(({ className, ...props }, ref) => ( |
|||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> |
|||
)) |
|||
CardContent.displayName = "CardContent" |
|||
|
|||
const CardFooter = React.forwardRef< |
|||
HTMLDivElement, |
|||
React.HTMLAttributes<HTMLDivElement> |
|||
>(({ className, ...props }, ref) => ( |
|||
<div |
|||
ref={ref} |
|||
className={cn("flex items-center p-6 pt-0", className)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
CardFooter.displayName = "CardFooter" |
|||
|
|||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } |
@ -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<typeof CheckboxPrimitive.Root>, |
|||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> |
|||
>(({ className, ...props }, ref) => ( |
|||
<CheckboxPrimitive.Root |
|||
ref={ref} |
|||
className={cn( |
|||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground", |
|||
className |
|||
)} |
|||
{...props} |
|||
> |
|||
<CheckboxPrimitive.Indicator |
|||
className={cn("flex items-center justify-center text-current")} |
|||
> |
|||
<Check className="h-4 w-4" /> |
|||
</CheckboxPrimitive.Indicator> |
|||
</CheckboxPrimitive.Root> |
|||
)) |
|||
Checkbox.displayName = CheckboxPrimitive.Root.displayName |
|||
|
|||
export { Checkbox } |
@ -0,0 +1,50 @@ |
|||
import * as React from "react" |
|||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" |
|||
import { cn } from "@/lib/utils" |
|||
|
|||
const DropdownMenu = DropdownMenuPrimitive.Root |
|||
|
|||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger |
|||
|
|||
const DropdownMenuContent = React.forwardRef< |
|||
React.ElementRef<typeof DropdownMenuPrimitive.Content>, |
|||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> |
|||
>(({ className, sideOffset = 4, ...props }, ref) => ( |
|||
<DropdownMenuPrimitive.Portal> |
|||
<DropdownMenuPrimitive.Content |
|||
ref={ref} |
|||
sideOffset={sideOffset} |
|||
className={cn( |
|||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
</DropdownMenuPrimitive.Portal> |
|||
)) |
|||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName |
|||
|
|||
const DropdownMenuItem = React.forwardRef< |
|||
React.ElementRef<typeof DropdownMenuPrimitive.Item>, |
|||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { |
|||
inset?: boolean |
|||
} |
|||
>(({ className, inset, ...props }, ref) => ( |
|||
<DropdownMenuPrimitive.Item |
|||
ref={ref} |
|||
className={cn( |
|||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", |
|||
inset && "pl-8", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName |
|||
|
|||
export { |
|||
DropdownMenu, |
|||
DropdownMenuTrigger, |
|||
DropdownMenuContent, |
|||
DropdownMenuItem, |
|||
} |
@ -0,0 +1,24 @@ |
|||
import * as React from "react" |
|||
import { cn } from "@/lib/utils" |
|||
|
|||
export interface InputProps |
|||
extends React.InputHTMLAttributes<HTMLInputElement> {} |
|||
|
|||
const Input = React.forwardRef<HTMLInputElement, InputProps>( |
|||
({ className, type, ...props }, ref) => { |
|||
return ( |
|||
<input |
|||
type={type} |
|||
className={cn( |
|||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", |
|||
className |
|||
)} |
|||
ref={ref} |
|||
{...props} |
|||
/> |
|||
) |
|||
} |
|||
) |
|||
Input.displayName = "Input" |
|||
|
|||
export { Input } |
@ -0,0 +1,20 @@ |
|||
import * as React from "react" |
|||
import * as LabelPrimitive from "@radix-ui/react-label" |
|||
import { cn } from "@/lib/utils" |
|||
|
|||
const Label = React.forwardRef< |
|||
React.ElementRef<typeof LabelPrimitive.Root>, |
|||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> |
|||
>(({ className, ...props }, ref) => ( |
|||
<LabelPrimitive.Root |
|||
ref={ref} |
|||
className={cn( |
|||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
Label.displayName = LabelPrimitive.Root.displayName |
|||
|
|||
export { Label } |
@ -0,0 +1,127 @@ |
|||
import * as React from "react" |
|||
import * as ToastPrimitives from "@radix-ui/react-toast" |
|||
import { cva, type VariantProps } from "class-variance-authority" |
|||
import { X } from "lucide-react" |
|||
|
|||
import { cn } from "@/lib/utils" |
|||
|
|||
const ToastProvider = ToastPrimitives.Provider |
|||
|
|||
const ToastViewport = React.forwardRef< |
|||
React.ElementRef<typeof ToastPrimitives.Viewport>, |
|||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> |
|||
>(({ className, ...props }, ref) => ( |
|||
<ToastPrimitives.Viewport |
|||
ref={ref} |
|||
className={cn( |
|||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName |
|||
|
|||
const toastVariants = cva( |
|||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", |
|||
{ |
|||
variants: { |
|||
variant: { |
|||
default: "border bg-background text-foreground", |
|||
destructive: |
|||
"destructive group border-destructive bg-destructive text-destructive-foreground", |
|||
}, |
|||
}, |
|||
defaultVariants: { |
|||
variant: "default", |
|||
}, |
|||
} |
|||
) |
|||
|
|||
const Toast = React.forwardRef< |
|||
React.ElementRef<typeof ToastPrimitives.Root>, |
|||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & |
|||
VariantProps<typeof toastVariants> |
|||
>(({ className, variant, ...props }, ref) => { |
|||
return ( |
|||
<ToastPrimitives.Root |
|||
ref={ref} |
|||
className={cn(toastVariants({ variant }), className)} |
|||
{...props} |
|||
/> |
|||
) |
|||
}) |
|||
Toast.displayName = ToastPrimitives.Root.displayName |
|||
|
|||
const ToastAction = React.forwardRef< |
|||
React.ElementRef<typeof ToastPrimitives.Action>, |
|||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> |
|||
>(({ className, ...props }, ref) => ( |
|||
<ToastPrimitives.Action |
|||
ref={ref} |
|||
className={cn( |
|||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive", |
|||
className |
|||
)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
ToastAction.displayName = ToastPrimitives.Action.displayName |
|||
|
|||
const ToastClose = React.forwardRef< |
|||
React.ElementRef<typeof ToastPrimitives.Close>, |
|||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> |
|||
>(({ className, ...props }, ref) => ( |
|||
<ToastPrimitives.Close |
|||
ref={ref} |
|||
className={cn( |
|||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", |
|||
className |
|||
)} |
|||
toast-close="" |
|||
{...props} |
|||
> |
|||
<X className="h-4 w-4" /> |
|||
</ToastPrimitives.Close> |
|||
)) |
|||
ToastClose.displayName = ToastPrimitives.Close.displayName |
|||
|
|||
const ToastTitle = React.forwardRef< |
|||
React.ElementRef<typeof ToastPrimitives.Title>, |
|||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> |
|||
>(({ className, ...props }, ref) => ( |
|||
<ToastPrimitives.Title |
|||
ref={ref} |
|||
className={cn("text-sm font-semibold", className)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
ToastTitle.displayName = ToastPrimitives.Title.displayName |
|||
|
|||
const ToastDescription = React.forwardRef< |
|||
React.ElementRef<typeof ToastPrimitives.Description>, |
|||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> |
|||
>(({ className, ...props }, ref) => ( |
|||
<ToastPrimitives.Description |
|||
ref={ref} |
|||
className={cn("text-sm opacity-90", className)} |
|||
{...props} |
|||
/> |
|||
)) |
|||
ToastDescription.displayName = ToastPrimitives.Description.displayName |
|||
|
|||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast> |
|||
|
|||
type ToastActionElement = React.ReactElement<typeof ToastAction> |
|||
|
|||
export { |
|||
type ToastProps, |
|||
type ToastActionElement, |
|||
ToastProvider, |
|||
ToastViewport, |
|||
Toast, |
|||
ToastTitle, |
|||
ToastDescription, |
|||
ToastClose, |
|||
ToastAction, |
|||
} |
@ -0,0 +1,33 @@ |
|||
import { |
|||
Toast, |
|||
ToastClose, |
|||
ToastDescription, |
|||
ToastProvider, |
|||
ToastTitle, |
|||
ToastViewport, |
|||
} from "@/components/ui/toast" |
|||
import { useToast } from "@/hooks/use-toast" |
|||
|
|||
export function Toaster() { |
|||
const { toasts } = useToast() |
|||
|
|||
return ( |
|||
<ToastProvider> |
|||
{toasts.map(function ({ id, title, description, action, ...props }) { |
|||
return ( |
|||
<Toast key={id} {...props}> |
|||
<div className="grid gap-1"> |
|||
{title && <ToastTitle>{title}</ToastTitle>} |
|||
{description && ( |
|||
<ToastDescription>{description}</ToastDescription> |
|||
)} |
|||
</div> |
|||
{action} |
|||
<ToastClose /> |
|||
</Toast> |
|||
) |
|||
})} |
|||
<ToastViewport /> |
|||
</ToastProvider> |
|||
) |
|||
} |
@ -0,0 +1 @@ |
|||
|
@ -0,0 +1,10 @@ |
|||
/// <reference types="vite/client" />
|
|||
|
|||
interface ImportMetaEnv { |
|||
readonly VITE_API_URL: string |
|||
// Add other environment variables here
|
|||
} |
|||
|
|||
interface ImportMeta { |
|||
readonly env: ImportMetaEnv |
|||
} |
@ -0,0 +1,21 @@ |
|||
import { useEffect, useState } from 'react' |
|||
|
|||
type Theme = 'light' | 'dark' |
|||
|
|||
export function useTheme() { |
|||
const [theme, setTheme] = useState<Theme>(() => { |
|||
if (typeof window !== 'undefined') { |
|||
return (localStorage.getItem('theme') as Theme) || 'light' |
|||
} |
|||
return 'light' |
|||
}) |
|||
|
|||
useEffect(() => { |
|||
const root = window.document.documentElement |
|||
root.classList.remove('light', 'dark') |
|||
root.classList.add(theme) |
|||
localStorage.setItem('theme', theme) |
|||
}, [theme]) |
|||
|
|||
return { theme, setTheme } |
|||
} |
@ -0,0 +1,191 @@ |
|||
import * as React from "react" |
|||
|
|||
import type { |
|||
ToastActionElement, |
|||
ToastProps, |
|||
} from "@/components/ui/toast" |
|||
|
|||
const TOAST_LIMIT = 1 |
|||
const TOAST_REMOVE_DELAY = 1000000 |
|||
|
|||
type ToasterToast = ToastProps & { |
|||
id: string |
|||
title?: React.ReactNode |
|||
description?: React.ReactNode |
|||
action?: ToastActionElement |
|||
} |
|||
|
|||
const actionTypes = { |
|||
ADD_TOAST: "ADD_TOAST", |
|||
UPDATE_TOAST: "UPDATE_TOAST", |
|||
DISMISS_TOAST: "DISMISS_TOAST", |
|||
REMOVE_TOAST: "REMOVE_TOAST", |
|||
} as const |
|||
|
|||
let count = 0 |
|||
|
|||
function genId() { |
|||
count = (count + 1) % Number.MAX_VALUE |
|||
return count.toString() |
|||
} |
|||
|
|||
type ActionType = typeof actionTypes |
|||
|
|||
type Action = |
|||
| { |
|||
type: ActionType["ADD_TOAST"] |
|||
toast: ToasterToast |
|||
} |
|||
| { |
|||
type: ActionType["UPDATE_TOAST"] |
|||
toast: Partial<ToasterToast> |
|||
} |
|||
| { |
|||
type: ActionType["DISMISS_TOAST"] |
|||
toastId?: ToasterToast["id"] |
|||
} |
|||
| { |
|||
type: ActionType["REMOVE_TOAST"] |
|||
toastId?: ToasterToast["id"] |
|||
} |
|||
|
|||
interface State { |
|||
toasts: ToasterToast[] |
|||
} |
|||
|
|||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>() |
|||
|
|||
const addToRemoveQueue = (toastId: string) => { |
|||
if (toastTimeouts.has(toastId)) { |
|||
return |
|||
} |
|||
|
|||
const timeout = setTimeout(() => { |
|||
toastTimeouts.delete(toastId) |
|||
dispatch({ |
|||
type: "REMOVE_TOAST", |
|||
toastId: toastId, |
|||
}) |
|||
}, TOAST_REMOVE_DELAY) |
|||
|
|||
toastTimeouts.set(toastId, timeout) |
|||
} |
|||
|
|||
export const reducer = (state: State, action: Action): State => { |
|||
switch (action.type) { |
|||
case "ADD_TOAST": |
|||
return { |
|||
...state, |
|||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), |
|||
} |
|||
|
|||
case "UPDATE_TOAST": |
|||
return { |
|||
...state, |
|||
toasts: state.toasts.map((t) => |
|||
t.id === action.toast.id ? { ...t, ...action.toast } : t |
|||
), |
|||
} |
|||
|
|||
case "DISMISS_TOAST": { |
|||
const { toastId } = action |
|||
|
|||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
|||
// but I'll keep it here for simplicity
|
|||
if (toastId) { |
|||
addToRemoveQueue(toastId) |
|||
} else { |
|||
state.toasts.forEach((toast) => { |
|||
addToRemoveQueue(toast.id) |
|||
}) |
|||
} |
|||
|
|||
return { |
|||
...state, |
|||
toasts: state.toasts.map((t) => |
|||
t.id === toastId || toastId === undefined |
|||
? { |
|||
...t, |
|||
open: false, |
|||
} |
|||
: t |
|||
), |
|||
} |
|||
} |
|||
case "REMOVE_TOAST": |
|||
if (action.toastId === undefined) { |
|||
return { |
|||
...state, |
|||
toasts: [], |
|||
} |
|||
} |
|||
return { |
|||
...state, |
|||
toasts: state.toasts.filter((t) => t.id !== action.toastId), |
|||
} |
|||
} |
|||
} |
|||
|
|||
const listeners: Array<(state: State) => void> = [] |
|||
|
|||
let memoryState: State = { toasts: [] } |
|||
|
|||
function dispatch(action: Action) { |
|||
memoryState = reducer(memoryState, action) |
|||
listeners.forEach((listener) => { |
|||
listener(memoryState) |
|||
}) |
|||
} |
|||
|
|||
type Toast = Omit<ToasterToast, "id"> |
|||
|
|||
function toast({ ...props }: Toast) { |
|||
const id = genId() |
|||
|
|||
const update = (props: ToasterToast) => |
|||
dispatch({ |
|||
type: "UPDATE_TOAST", |
|||
toast: { ...props, id }, |
|||
}) |
|||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) |
|||
|
|||
dispatch({ |
|||
type: "ADD_TOAST", |
|||
toast: { |
|||
...props, |
|||
id, |
|||
open: true, |
|||
onOpenChange: (open) => { |
|||
if (!open) dismiss() |
|||
}, |
|||
}, |
|||
}) |
|||
|
|||
return { |
|||
id: id, |
|||
dismiss, |
|||
update, |
|||
} |
|||
} |
|||
|
|||
function useToast() { |
|||
const [state, setState] = React.useState<State>(memoryState) |
|||
|
|||
React.useEffect(() => { |
|||
listeners.push(setState) |
|||
return () => { |
|||
const index = listeners.indexOf(setState) |
|||
if (index > -1) { |
|||
listeners.splice(index, 1) |
|||
} |
|||
} |
|||
}, [state]) |
|||
|
|||
return { |
|||
...state, |
|||
toast, |
|||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), |
|||
} |
|||
} |
|||
|
|||
export { useToast, toast } |
@ -0,0 +1,76 @@ |
|||
@tailwind base; |
|||
@tailwind components; |
|||
@tailwind utilities; |
|||
|
|||
@layer base { |
|||
:root { |
|||
--background: 0 0% 100%; |
|||
--foreground: 222.2 84% 4.9%; |
|||
|
|||
--card: 0 0% 100%; |
|||
--card-foreground: 222.2 84% 4.9%; |
|||
|
|||
--popover: 0 0% 100%; |
|||
--popover-foreground: 222.2 84% 4.9%; |
|||
|
|||
--primary: 222.2 47.4% 11.2%; |
|||
--primary-foreground: 210 40% 98%; |
|||
|
|||
--secondary: 210 40% 96.1%; |
|||
--secondary-foreground: 222.2 47.4% 11.2%; |
|||
|
|||
--muted: 210 40% 96.1%; |
|||
--muted-foreground: 215.4 16.3% 46.9%; |
|||
|
|||
--accent: 210 40% 96.1%; |
|||
--accent-foreground: 222.2 47.4% 11.2%; |
|||
|
|||
--destructive: 0 84.2% 60.2%; |
|||
--destructive-foreground: 210 40% 98%; |
|||
|
|||
--border: 214.3 31.8% 91.4%; |
|||
--input: 214.3 31.8% 91.4%; |
|||
--ring: 222.2 84% 4.9%; |
|||
|
|||
--radius: 0.5rem; |
|||
} |
|||
|
|||
.dark { |
|||
--background: 222.2 84% 4.9%; |
|||
--foreground: 210 40% 98%; |
|||
|
|||
--card: 222.2 84% 4.9%; |
|||
--card-foreground: 210 40% 98%; |
|||
|
|||
--popover: 222.2 84% 4.9%; |
|||
--popover-foreground: 210 40% 98%; |
|||
|
|||
--primary: 210 40% 98%; |
|||
--primary-foreground: 222.2 47.4% 11.2%; |
|||
|
|||
--secondary: 217.2 32.6% 17.5%; |
|||
--secondary-foreground: 210 40% 98%; |
|||
|
|||
--muted: 217.2 32.6% 17.5%; |
|||
--muted-foreground: 215 20.2% 65.1%; |
|||
|
|||
--accent: 217.2 32.6% 17.5%; |
|||
--accent-foreground: 210 40% 98%; |
|||
|
|||
--destructive: 0 62.8% 30.6%; |
|||
--destructive-foreground: 210 40% 98%; |
|||
|
|||
--border: 217.2 32.6% 17.5%; |
|||
--input: 217.2 32.6% 17.5%; |
|||
--ring: 212.7 26.8% 83.9%; |
|||
} |
|||
} |
|||
|
|||
@layer base { |
|||
* { |
|||
@apply border-border; |
|||
} |
|||
body { |
|||
@apply bg-background text-foreground; |
|||
} |
|||
} |
@ -0,0 +1,73 @@ |
|||
import axios, { AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios' |
|||
import { toast } from '@/hooks/use-toast' |
|||
|
|||
interface ImportMetaEnv { |
|||
VITE_API_URL?: string |
|||
} |
|||
|
|||
interface ImportMeta { |
|||
env: ImportMetaEnv |
|||
} |
|||
|
|||
const apiClient = axios.create({ |
|||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
}, |
|||
}) |
|||
|
|||
// Request interceptor
|
|||
apiClient.interceptors.request.use( |
|||
(config: AxiosRequestConfig) => { |
|||
const token = localStorage.getItem('token') |
|||
if (token) { |
|||
config.headers = { |
|||
...config.headers, |
|||
Authorization: `Bearer ${token}`, |
|||
} |
|||
} |
|||
return config |
|||
}, |
|||
(error: AxiosError) => { |
|||
return Promise.reject(error) |
|||
} |
|||
) |
|||
|
|||
// Response interceptor
|
|||
apiClient.interceptors.response.use( |
|||
(response: AxiosResponse) => response, |
|||
(error: AxiosError) => { |
|||
if (error.response) { |
|||
switch (error.response.status) { |
|||
case 401: |
|||
// Handle unauthorized access
|
|||
localStorage.removeItem('token') |
|||
window.location.href = '/login' |
|||
break |
|||
case 403: |
|||
toast({ |
|||
title: 'Access Denied', |
|||
description: 'You do not have permission to perform this action.', |
|||
variant: 'destructive', |
|||
}) |
|||
break |
|||
case 500: |
|||
toast({ |
|||
title: 'Server Error', |
|||
description: 'Something went wrong on our end. Please try again later.', |
|||
variant: 'destructive', |
|||
}) |
|||
break |
|||
default: |
|||
toast({ |
|||
title: 'Error', |
|||
description: error.response.data?.message || 'An error occurred', |
|||
variant: 'destructive', |
|||
}) |
|||
} |
|||
} |
|||
return Promise.reject(error) |
|||
} |
|||
) |
|||
|
|||
export default apiClient |
@ -0,0 +1,191 @@ |
|||
import * as React from "react" |
|||
|
|||
import type { |
|||
ToastActionElement, |
|||
ToastProps, |
|||
} from "../components/ui/toast" |
|||
|
|||
const TOAST_LIMIT = 1 |
|||
const TOAST_REMOVE_DELAY = 1000000 |
|||
|
|||
type ToasterToast = ToastProps & { |
|||
id: string |
|||
title?: React.ReactNode |
|||
description?: React.ReactNode |
|||
action?: ToastActionElement |
|||
} |
|||
|
|||
const actionTypes = { |
|||
ADD_TOAST: "ADD_TOAST", |
|||
UPDATE_TOAST: "UPDATE_TOAST", |
|||
DISMISS_TOAST: "DISMISS_TOAST", |
|||
REMOVE_TOAST: "REMOVE_TOAST", |
|||
} as const |
|||
|
|||
let count = 0 |
|||
|
|||
function genId() { |
|||
count = (count + 1) % Number.MAX_VALUE |
|||
return count.toString() |
|||
} |
|||
|
|||
type ActionType = typeof actionTypes |
|||
|
|||
type Action = |
|||
| { |
|||
type: ActionType["ADD_TOAST"] |
|||
toast: ToasterToast |
|||
} |
|||
| { |
|||
type: ActionType["UPDATE_TOAST"] |
|||
toast: Partial<ToasterToast> |
|||
} |
|||
| { |
|||
type: ActionType["DISMISS_TOAST"] |
|||
toastId?: ToasterToast["id"] |
|||
} |
|||
| { |
|||
type: ActionType["REMOVE_TOAST"] |
|||
toastId?: ToasterToast["id"] |
|||
} |
|||
|
|||
interface State { |
|||
toasts: ToasterToast[] |
|||
} |
|||
|
|||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>() |
|||
|
|||
const addToRemoveQueue = (toastId: string) => { |
|||
if (toastTimeouts.has(toastId)) { |
|||
return |
|||
} |
|||
|
|||
const timeout = setTimeout(() => { |
|||
toastTimeouts.delete(toastId) |
|||
dispatch({ |
|||
type: "REMOVE_TOAST", |
|||
toastId: toastId, |
|||
}) |
|||
}, TOAST_REMOVE_DELAY) |
|||
|
|||
toastTimeouts.set(toastId, timeout) |
|||
} |
|||
|
|||
export const reducer = (state: State, action: Action): State => { |
|||
switch (action.type) { |
|||
case "ADD_TOAST": |
|||
return { |
|||
...state, |
|||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), |
|||
} |
|||
|
|||
case "UPDATE_TOAST": |
|||
return { |
|||
...state, |
|||
toasts: state.toasts.map((t) => |
|||
t.id === action.toast.id ? { ...t, ...action.toast } : t |
|||
), |
|||
} |
|||
|
|||
case "DISMISS_TOAST": { |
|||
const { toastId } = action |
|||
|
|||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
|||
// but I'll keep it here for simplicity
|
|||
if (toastId) { |
|||
addToRemoveQueue(toastId) |
|||
} else { |
|||
state.toasts.forEach((toast) => { |
|||
addToRemoveQueue(toast.id) |
|||
}) |
|||
} |
|||
|
|||
return { |
|||
...state, |
|||
toasts: state.toasts.map((t) => |
|||
t.id === toastId || toastId === undefined |
|||
? { |
|||
...t, |
|||
open: false, |
|||
} |
|||
: t |
|||
), |
|||
} |
|||
} |
|||
case "REMOVE_TOAST": |
|||
if (action.toastId === undefined) { |
|||
return { |
|||
...state, |
|||
toasts: [], |
|||
} |
|||
} |
|||
return { |
|||
...state, |
|||
toasts: state.toasts.filter((t) => t.id !== action.toastId), |
|||
} |
|||
} |
|||
} |
|||
|
|||
const listeners: Array<(state: State) => void> = [] |
|||
|
|||
let memoryState: State = { toasts: [] } |
|||
|
|||
function dispatch(action: Action) { |
|||
memoryState = reducer(memoryState, action) |
|||
listeners.forEach((listener) => { |
|||
listener(memoryState) |
|||
}) |
|||
} |
|||
|
|||
type Toast = Omit<ToasterToast, "id"> |
|||
|
|||
function toast({ ...props }: Toast) { |
|||
const id = genId() |
|||
|
|||
const update = (props: ToasterToast) => |
|||
dispatch({ |
|||
type: "UPDATE_TOAST", |
|||
toast: { ...props, id }, |
|||
}) |
|||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) |
|||
|
|||
dispatch({ |
|||
type: "ADD_TOAST", |
|||
toast: { |
|||
...props, |
|||
id, |
|||
open: true, |
|||
onOpenChange: (open) => { |
|||
if (!open) dismiss() |
|||
}, |
|||
}, |
|||
}) |
|||
|
|||
return { |
|||
id: id, |
|||
dismiss, |
|||
update, |
|||
} |
|||
} |
|||
|
|||
function useToast() { |
|||
const [state, setState] = React.useState<State>(memoryState) |
|||
|
|||
React.useEffect(() => { |
|||
listeners.push(setState) |
|||
return () => { |
|||
const index = listeners.indexOf(setState) |
|||
if (index > -1) { |
|||
listeners.splice(index, 1) |
|||
} |
|||
} |
|||
}, [state]) |
|||
|
|||
return { |
|||
...state, |
|||
toast, |
|||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), |
|||
} |
|||
} |
|||
|
|||
export { useToast, toast } |
@ -0,0 +1,6 @@ |
|||
import { type ClassValue, clsx } from 'clsx' |
|||
import { twMerge } from 'tailwind-merge' |
|||
|
|||
export function cn(...inputs: ClassValue[]) { |
|||
return twMerge(clsx(inputs)) |
|||
} |
@ -0,0 +1,13 @@ |
|||
import React from 'react' |
|||
import ReactDOM from 'react-dom/client' |
|||
import { BrowserRouter } from 'react-router-dom' |
|||
import App from './App' |
|||
import './index.css' |
|||
|
|||
ReactDOM.createRoot(document.getElementById('root')!).render( |
|||
<React.StrictMode> |
|||
<BrowserRouter> |
|||
<App /> |
|||
</BrowserRouter> |
|||
</React.StrictMode>, |
|||
) |
@ -0,0 +1,48 @@ |
|||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' |
|||
import { Users, Activity, Signal } from 'lucide-react' |
|||
|
|||
export default function Dashboard() { |
|||
return ( |
|||
<div className="space-y-4"> |
|||
<h1 className="text-3xl font-bold">Dashboard</h1> |
|||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> |
|||
<Card> |
|||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> |
|||
<CardTitle className="text-sm font-medium">Total Users</CardTitle> |
|||
<Users className="h-4 w-4 text-muted-foreground" /> |
|||
</CardHeader> |
|||
<CardContent> |
|||
<div className="text-2xl font-bold">1,234</div> |
|||
<p className="text-xs text-muted-foreground"> |
|||
+20.1% from last month |
|||
</p> |
|||
</CardContent> |
|||
</Card> |
|||
<Card> |
|||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> |
|||
<CardTitle className="text-sm font-medium">Active Cells</CardTitle> |
|||
<Signal className="h-4 w-4 text-muted-foreground" /> |
|||
</CardHeader> |
|||
<CardContent> |
|||
<div className="text-2xl font-bold">45</div> |
|||
<p className="text-xs text-muted-foreground"> |
|||
+2 from last week |
|||
</p> |
|||
</CardContent> |
|||
</Card> |
|||
<Card> |
|||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> |
|||
<CardTitle className="text-sm font-medium">Network Activity</CardTitle> |
|||
<Activity className="h-4 w-4 text-muted-foreground" /> |
|||
</CardHeader> |
|||
<CardContent> |
|||
<div className="text-2xl font-bold">2.3 TB</div> |
|||
<p className="text-xs text-muted-foreground"> |
|||
+12% from last month |
|||
</p> |
|||
</CardContent> |
|||
</Card> |
|||
</div> |
|||
</div> |
|||
) |
|||
} |
@ -0,0 +1,120 @@ |
|||
import React, { useState } from 'react' |
|||
import { Link } from 'react-router-dom' |
|||
import { Button } from '@/components/ui/button' |
|||
import { Input } from '@/components/ui/input' |
|||
import { Label } from '@/components/ui/label' |
|||
import { useToast } from '@/hooks/use-toast' |
|||
import { Loader2 } from 'lucide-react' |
|||
import { authService } from '@/services/auth.service' |
|||
|
|||
const ForgotPassword: React.FC = () => { |
|||
const [email, setEmail] = useState('') |
|||
const [isLoading, setIsLoading] = useState(false) |
|||
const [isSubmitted, setIsSubmitted] = useState(false) |
|||
const { toast } = useToast() |
|||
|
|||
const handleSubmit = async (e: React.FormEvent) => { |
|||
e.preventDefault() |
|||
setIsLoading(true) |
|||
|
|||
try { |
|||
await authService.requestPasswordReset(email) |
|||
setIsSubmitted(true) |
|||
toast({ |
|||
title: 'Success', |
|||
description: 'If an account exists with this email, you will receive password reset instructions.', |
|||
}) |
|||
} catch (error) { |
|||
toast({ |
|||
title: 'Error', |
|||
description: 'An error occurred while processing your request.', |
|||
variant: 'destructive', |
|||
}) |
|||
} finally { |
|||
setIsLoading(false) |
|||
} |
|||
} |
|||
|
|||
if (isSubmitted) { |
|||
return ( |
|||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900"> |
|||
<div className="max-w-md w-full space-y-8 p-8 bg-white dark:bg-gray-800 rounded-lg shadow-lg"> |
|||
<div> |
|||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white"> |
|||
Check your email |
|||
</h2> |
|||
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400"> |
|||
We've sent password reset instructions to your email address. |
|||
</p> |
|||
<div className="mt-4 text-center"> |
|||
<Link |
|||
to="/login" |
|||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400" |
|||
> |
|||
Return to login |
|||
</Link> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
) |
|||
} |
|||
|
|||
return ( |
|||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900"> |
|||
<div className="max-w-md w-full space-y-8 p-8 bg-white dark:bg-gray-800 rounded-lg shadow-lg"> |
|||
<div> |
|||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white"> |
|||
Reset your password |
|||
</h2> |
|||
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400"> |
|||
Enter your email address and we'll send you instructions to reset your password. |
|||
</p> |
|||
</div> |
|||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}> |
|||
<div className="rounded-md shadow-sm space-y-4"> |
|||
<div> |
|||
<Label htmlFor="email">Email address</Label> |
|||
<Input |
|||
id="email" |
|||
name="email" |
|||
type="email" |
|||
autoComplete="email" |
|||
required |
|||
value={email} |
|||
onChange={(e) => setEmail(e.target.value)} |
|||
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm" |
|||
placeholder="Email address" |
|||
/> |
|||
</div> |
|||
</div> |
|||
|
|||
<div> |
|||
<Button |
|||
type="submit" |
|||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" |
|||
disabled={isLoading} |
|||
> |
|||
{isLoading ? ( |
|||
<Loader2 className="h-4 w-4 animate-spin" /> |
|||
) : ( |
|||
'Send reset instructions' |
|||
)} |
|||
</Button> |
|||
</div> |
|||
|
|||
<div className="text-center"> |
|||
<Link |
|||
to="/login" |
|||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400" |
|||
> |
|||
Back to login |
|||
</Link> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
) |
|||
} |
|||
|
|||
export default ForgotPassword |
@ -0,0 +1,148 @@ |
|||
import React, { useState, useEffect } from 'react' |
|||
import { useNavigate, Link } from 'react-router-dom' |
|||
import { useAuth } from '@/contexts/AuthContext' |
|||
import { Button } from '@/components/ui/button' |
|||
import { Input } from '@/components/ui/input' |
|||
import { Label } from '@/components/ui/label' |
|||
import { useToast } from '@/hooks/use-toast' |
|||
import { Loader2 } from 'lucide-react' |
|||
import { authService } from '@/services/authService' |
|||
|
|||
const Login: React.FC = () => { |
|||
const [usernameOrEmail, setUsernameOrEmail] = useState('') |
|||
const [password, setPassword] = useState('') |
|||
const [rememberMe, setRememberMe] = useState(authService.isRemembered()) |
|||
const [isLoading, setIsLoading] = useState(false) |
|||
const { login } = useAuth() |
|||
const { toast } = useToast() |
|||
const navigate = useNavigate() |
|||
|
|||
useEffect(() => { |
|||
if (authService.isRemembered()) { |
|||
const lastUsername = localStorage.getItem('last_username'); |
|||
if (lastUsername) { |
|||
setUsernameOrEmail(lastUsername); |
|||
} |
|||
} |
|||
}, []); |
|||
|
|||
const handleSubmit = async (e: React.FormEvent) => { |
|||
e.preventDefault() |
|||
setIsLoading(true) |
|||
|
|||
try { |
|||
await login({ usernameOrEmail, password, rememberMe }) |
|||
if (rememberMe) { |
|||
localStorage.setItem('last_username', usernameOrEmail); |
|||
} else { |
|||
localStorage.removeItem('last_username'); |
|||
} |
|||
toast({ |
|||
title: 'Login successful', |
|||
description: 'Welcome back!', |
|||
}) |
|||
} catch (error) { |
|||
toast({ |
|||
title: 'Login failed', |
|||
description: 'Invalid username/email or password', |
|||
variant: 'destructive', |
|||
}) |
|||
} finally { |
|||
setIsLoading(false) |
|||
} |
|||
} |
|||
|
|||
return ( |
|||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900"> |
|||
<div className="max-w-md w-full space-y-8 p-8 bg-white dark:bg-gray-800 rounded-lg shadow-lg"> |
|||
<div> |
|||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white"> |
|||
Sign in to your account |
|||
</h2> |
|||
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400"> |
|||
Or{' '} |
|||
<Link |
|||
to="/register" |
|||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400" |
|||
> |
|||
create a new account |
|||
</Link> |
|||
</p> |
|||
</div> |
|||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}> |
|||
<div className="rounded-md shadow-sm space-y-4"> |
|||
<div> |
|||
<Label htmlFor="usernameOrEmail">Username or Email</Label> |
|||
<Input |
|||
id="usernameOrEmail" |
|||
name="usernameOrEmail" |
|||
type="text" |
|||
autoComplete="username" |
|||
required |
|||
value={usernameOrEmail} |
|||
onChange={(e) => setUsernameOrEmail(e.target.value)} |
|||
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm" |
|||
placeholder="Username or Email" |
|||
/> |
|||
</div> |
|||
<div> |
|||
<Label htmlFor="password">Password</Label> |
|||
<Input |
|||
id="password" |
|||
name="password" |
|||
type="password" |
|||
autoComplete="current-password" |
|||
required |
|||
value={password} |
|||
onChange={(e) => setPassword(e.target.value)} |
|||
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm" |
|||
placeholder="Password" |
|||
/> |
|||
</div> |
|||
</div> |
|||
|
|||
<div className="flex items-center justify-between"> |
|||
<div className="flex items-center"> |
|||
<input |
|||
id="remember-me" |
|||
name="remember-me" |
|||
type="checkbox" |
|||
checked={rememberMe} |
|||
onChange={(e) => setRememberMe(e.target.checked)} |
|||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" |
|||
/> |
|||
<Label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900 dark:text-white"> |
|||
Remember me |
|||
</Label> |
|||
</div> |
|||
|
|||
<div className="text-sm"> |
|||
<Link |
|||
to="/forgot-password" |
|||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400" |
|||
> |
|||
Forgot your password? |
|||
</Link> |
|||
</div> |
|||
</div> |
|||
|
|||
<div> |
|||
<Button |
|||
type="submit" |
|||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" |
|||
disabled={isLoading} |
|||
> |
|||
{isLoading ? ( |
|||
<Loader2 className="h-4 w-4 animate-spin" /> |
|||
) : ( |
|||
'Sign in' |
|||
)} |
|||
</Button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
) |
|||
} |
|||
|
|||
export default Login |
@ -0,0 +1,82 @@ |
|||
import { useState } from "react" |
|||
import { useNavigate } from "react-router-dom" |
|||
import { Button } from "@/components/ui/button" |
|||
import { Input } from "@/components/ui/input" |
|||
import { Label } from "@/components/ui/label" |
|||
import { useToast } from "@/lib/use-toast" |
|||
import { login } from "@/services/auth" |
|||
import { useAuth } from "@/hooks/useAuth" |
|||
|
|||
export function LoginForm() { |
|||
const navigate = useNavigate() |
|||
const { toast } = useToast() |
|||
const { setToken } = useAuth() |
|||
const [loading, setLoading] = useState(false) |
|||
const [formData, setFormData] = useState({ |
|||
username: "", |
|||
password: "", |
|||
}) |
|||
|
|||
const handleSubmit = async (e: React.FormEvent) => { |
|||
e.preventDefault() |
|||
setLoading(true) |
|||
|
|||
try { |
|||
const response = await login(formData) |
|||
setToken(response.data.token) |
|||
toast({ |
|||
title: "登录成功", |
|||
description: "欢迎回来!", |
|||
}) |
|||
navigate("/dashboard") |
|||
} catch (error) { |
|||
toast({ |
|||
title: "登录失败", |
|||
description: "用户名或密码错误", |
|||
variant: "destructive", |
|||
}) |
|||
} finally { |
|||
setLoading(false) |
|||
} |
|||
} |
|||
|
|||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
|||
const { name, value } = e.target |
|||
setFormData((prev) => ({ |
|||
...prev, |
|||
[name]: value, |
|||
})) |
|||
} |
|||
|
|||
return ( |
|||
<form onSubmit={handleSubmit} className="space-y-4"> |
|||
<div className="space-y-2"> |
|||
<Label htmlFor="username">用户名</Label> |
|||
<Input |
|||
id="username" |
|||
name="username" |
|||
type="text" |
|||
placeholder="请输入用户名" |
|||
value={formData.username} |
|||
onChange={handleChange} |
|||
required |
|||
/> |
|||
</div> |
|||
<div className="space-y-2"> |
|||
<Label htmlFor="password">密码</Label> |
|||
<Input |
|||
id="password" |
|||
name="password" |
|||
type="password" |
|||
placeholder="请输入密码" |
|||
value={formData.password} |
|||
onChange={handleChange} |
|||
required |
|||
/> |
|||
</div> |
|||
<Button type="submit" className="w-full" disabled={loading}> |
|||
{loading ? "登录中..." : "登录"} |
|||
</Button> |
|||
</form> |
|||
) |
|||
} |
@ -0,0 +1,103 @@ |
|||
import React, { useState } from 'react'; |
|||
import { useNavigate } from 'react-router-dom'; |
|||
import { useAuth } from '../contexts/AuthContext'; |
|||
import { Button } from '../components/ui/button'; |
|||
import { Input } from '../components/ui/input'; |
|||
import { Label } from '../components/ui/label'; |
|||
import { Checkbox } from '../components/ui/checkbox'; |
|||
import { useToast } from '../components/ui/use-toast'; |
|||
|
|||
export const LoginPage: React.FC = () => { |
|||
const [usernameOrEmail, setUsernameOrEmail] = useState(''); |
|||
const [password, setPassword] = useState(''); |
|||
const [rememberMe, setRememberMe] = useState(false); |
|||
const [isLoading, setIsLoading] = useState(false); |
|||
const { login } = useAuth(); |
|||
const navigate = useNavigate(); |
|||
const { toast } = useToast(); |
|||
|
|||
const handleSubmit = async (e: React.FormEvent) => { |
|||
e.preventDefault(); |
|||
setIsLoading(true); |
|||
|
|||
try { |
|||
await login(usernameOrEmail, password, rememberMe); |
|||
toast({ |
|||
title: 'Login successful', |
|||
description: 'Welcome back!', |
|||
}); |
|||
navigate('/dashboard'); |
|||
} catch (error) { |
|||
toast({ |
|||
title: 'Login failed', |
|||
description: 'Invalid username/email or password', |
|||
variant: 'destructive', |
|||
}); |
|||
} finally { |
|||
setIsLoading(false); |
|||
} |
|||
}; |
|||
|
|||
return ( |
|||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900"> |
|||
<div className="max-w-md w-full space-y-8 p-8 bg-white dark:bg-gray-800 rounded-lg shadow-lg"> |
|||
<div> |
|||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white"> |
|||
Sign in to your account |
|||
</h2> |
|||
</div> |
|||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}> |
|||
<div className="rounded-md shadow-sm space-y-4"> |
|||
<div> |
|||
<Label htmlFor="usernameOrEmail">Username or Email</Label> |
|||
<Input |
|||
id="usernameOrEmail" |
|||
name="usernameOrEmail" |
|||
type="text" |
|||
required |
|||
value={usernameOrEmail} |
|||
onChange={(e) => setUsernameOrEmail(e.target.value)} |
|||
className="mt-1" |
|||
/> |
|||
</div> |
|||
<div> |
|||
<Label htmlFor="password">Password</Label> |
|||
<Input |
|||
id="password" |
|||
name="password" |
|||
type="password" |
|||
required |
|||
value={password} |
|||
onChange={(e) => setPassword(e.target.value)} |
|||
className="mt-1" |
|||
/> |
|||
</div> |
|||
</div> |
|||
|
|||
<div className="flex items-center justify-between"> |
|||
<div className="flex items-center"> |
|||
<Checkbox |
|||
id="rememberMe" |
|||
checked={rememberMe} |
|||
onCheckedChange={(checked) => setRememberMe(checked as boolean)} |
|||
/> |
|||
<Label htmlFor="rememberMe" className="ml-2"> |
|||
Remember me |
|||
</Label> |
|||
</div> |
|||
</div> |
|||
|
|||
<div> |
|||
<Button |
|||
type="submit" |
|||
className="w-full" |
|||
disabled={isLoading} |
|||
> |
|||
{isLoading ? 'Signing in...' : 'Sign in'} |
|||
</Button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
); |
|||
}; |
@ -0,0 +1,147 @@ |
|||
import React, { useState } from 'react' |
|||
import { useNavigate, Link } from 'react-router-dom' |
|||
import { useAuth } from '@/contexts/AuthContext' |
|||
import { Button } from '@/components/ui/button' |
|||
import { Input } from '@/components/ui/input' |
|||
import { Label } from '@/components/ui/label' |
|||
import { useToast } from '@/hooks/use-toast' |
|||
import { Loader2 } from 'lucide-react' |
|||
|
|||
const Register: React.FC = () => { |
|||
const [name, setName] = useState('') |
|||
const [email, setEmail] = useState('') |
|||
const [password, setPassword] = useState('') |
|||
const [confirmPassword, setConfirmPassword] = useState('') |
|||
const [isLoading, setIsLoading] = useState(false) |
|||
const { register } = useAuth() |
|||
const { toast } = useToast() |
|||
const navigate = useNavigate() |
|||
|
|||
const handleSubmit = async (e: React.FormEvent) => { |
|||
e.preventDefault() |
|||
setIsLoading(true) |
|||
|
|||
if (password !== confirmPassword) { |
|||
toast({ |
|||
title: 'Error', |
|||
description: 'Passwords do not match', |
|||
variant: 'destructive', |
|||
}) |
|||
setIsLoading(false) |
|||
return |
|||
} |
|||
|
|||
try { |
|||
await register(name, email, password) |
|||
toast({ |
|||
title: 'Registration successful', |
|||
description: 'Welcome to our platform!', |
|||
}) |
|||
} catch (error) { |
|||
toast({ |
|||
title: 'Registration failed', |
|||
description: 'An error occurred during registration', |
|||
variant: 'destructive', |
|||
}) |
|||
} finally { |
|||
setIsLoading(false) |
|||
} |
|||
} |
|||
|
|||
return ( |
|||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900"> |
|||
<div className="max-w-md w-full space-y-8 p-8 bg-white dark:bg-gray-800 rounded-lg shadow-lg"> |
|||
<div> |
|||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white"> |
|||
Create your account |
|||
</h2> |
|||
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400"> |
|||
Or{' '} |
|||
<Link |
|||
to="/login" |
|||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400" |
|||
> |
|||
sign in to your account |
|||
</Link> |
|||
</p> |
|||
</div> |
|||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}> |
|||
<div className="rounded-md shadow-sm space-y-4"> |
|||
<div> |
|||
<Label htmlFor="name">Full name</Label> |
|||
<Input |
|||
id="name" |
|||
name="name" |
|||
type="text" |
|||
autoComplete="name" |
|||
required |
|||
value={name} |
|||
onChange={(e) => setName(e.target.value)} |
|||
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm" |
|||
placeholder="Full name" |
|||
/> |
|||
</div> |
|||
<div> |
|||
<Label htmlFor="email">Email address</Label> |
|||
<Input |
|||
id="email" |
|||
name="email" |
|||
type="email" |
|||
autoComplete="email" |
|||
required |
|||
value={email} |
|||
onChange={(e) => setEmail(e.target.value)} |
|||
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm" |
|||
placeholder="Email address" |
|||
/> |
|||
</div> |
|||
<div> |
|||
<Label htmlFor="password">Password</Label> |
|||
<Input |
|||
id="password" |
|||
name="password" |
|||
type="password" |
|||
autoComplete="new-password" |
|||
required |
|||
value={password} |
|||
onChange={(e) => setPassword(e.target.value)} |
|||
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm" |
|||
placeholder="Password" |
|||
/> |
|||
</div> |
|||
<div> |
|||
<Label htmlFor="confirm-password">Confirm password</Label> |
|||
<Input |
|||
id="confirm-password" |
|||
name="confirm-password" |
|||
type="password" |
|||
autoComplete="new-password" |
|||
required |
|||
value={confirmPassword} |
|||
onChange={(e) => setConfirmPassword(e.target.value)} |
|||
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm" |
|||
placeholder="Confirm password" |
|||
/> |
|||
</div> |
|||
</div> |
|||
|
|||
<div> |
|||
<Button |
|||
type="submit" |
|||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" |
|||
disabled={isLoading} |
|||
> |
|||
{isLoading ? ( |
|||
<Loader2 className="h-4 w-4 animate-spin" /> |
|||
) : ( |
|||
'Create account' |
|||
)} |
|||
</Button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
) |
|||
} |
|||
|
|||
export default Register |
@ -0,0 +1,151 @@ |
|||
import React, { useState, useEffect } from 'react' |
|||
import { useNavigate, useSearchParams } from 'react-router-dom' |
|||
import { Button } from '@/components/ui/button' |
|||
import { Input } from '@/components/ui/input' |
|||
import { Label } from '@/components/ui/label' |
|||
import { useToast } from '@/hooks/use-toast' |
|||
import { Loader2 } from 'lucide-react' |
|||
import { authService } from '@/services/auth.service' |
|||
|
|||
const ResetPassword: React.FC = () => { |
|||
const [searchParams] = useSearchParams() |
|||
const [password, setPassword] = useState('') |
|||
const [confirmPassword, setConfirmPassword] = useState('') |
|||
const [isLoading, setIsLoading] = useState(false) |
|||
const [isValidToken, setIsValidToken] = useState(true) |
|||
const { toast } = useToast() |
|||
const navigate = useNavigate() |
|||
|
|||
const token = searchParams.get('token') |
|||
|
|||
useEffect(() => { |
|||
if (!token) { |
|||
setIsValidToken(false) |
|||
toast({ |
|||
title: 'Invalid token', |
|||
description: 'The password reset link is invalid or has expired.', |
|||
variant: 'destructive', |
|||
}) |
|||
} |
|||
}, [token, toast]) |
|||
|
|||
const handleSubmit = async (e: React.FormEvent) => { |
|||
e.preventDefault() |
|||
setIsLoading(true) |
|||
|
|||
if (password !== confirmPassword) { |
|||
toast({ |
|||
title: 'Error', |
|||
description: 'Passwords do not match', |
|||
variant: 'destructive', |
|||
}) |
|||
setIsLoading(false) |
|||
return |
|||
} |
|||
|
|||
try { |
|||
await authService.resetPassword(token!, password) |
|||
toast({ |
|||
title: 'Success', |
|||
description: 'Your password has been reset successfully.', |
|||
}) |
|||
navigate('/login') |
|||
} catch (error) { |
|||
toast({ |
|||
title: 'Error', |
|||
description: 'An error occurred while resetting your password.', |
|||
variant: 'destructive', |
|||
}) |
|||
} finally { |
|||
setIsLoading(false) |
|||
} |
|||
} |
|||
|
|||
if (!isValidToken) { |
|||
return ( |
|||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900"> |
|||
<div className="max-w-md w-full space-y-8 p-8 bg-white dark:bg-gray-800 rounded-lg shadow-lg"> |
|||
<div> |
|||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white"> |
|||
Invalid Reset Link |
|||
</h2> |
|||
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400"> |
|||
The password reset link is invalid or has expired. Please request a new one. |
|||
</p> |
|||
<div className="mt-4 text-center"> |
|||
<a |
|||
href="/forgot-password" |
|||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400" |
|||
> |
|||
Request new reset link |
|||
</a> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
) |
|||
} |
|||
|
|||
return ( |
|||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900"> |
|||
<div className="max-w-md w-full space-y-8 p-8 bg-white dark:bg-gray-800 rounded-lg shadow-lg"> |
|||
<div> |
|||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white"> |
|||
Reset your password |
|||
</h2> |
|||
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400"> |
|||
Please enter your new password below. |
|||
</p> |
|||
</div> |
|||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}> |
|||
<div className="rounded-md shadow-sm space-y-4"> |
|||
<div> |
|||
<Label htmlFor="password">New password</Label> |
|||
<Input |
|||
id="password" |
|||
name="password" |
|||
type="password" |
|||
autoComplete="new-password" |
|||
required |
|||
value={password} |
|||
onChange={(e) => setPassword(e.target.value)} |
|||
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm" |
|||
placeholder="New password" |
|||
/> |
|||
</div> |
|||
<div> |
|||
<Label htmlFor="confirm-password">Confirm new password</Label> |
|||
<Input |
|||
id="confirm-password" |
|||
name="confirm-password" |
|||
type="password" |
|||
autoComplete="new-password" |
|||
required |
|||
value={confirmPassword} |
|||
onChange={(e) => setConfirmPassword(e.target.value)} |
|||
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm" |
|||
placeholder="Confirm new password" |
|||
/> |
|||
</div> |
|||
</div> |
|||
|
|||
<div> |
|||
<Button |
|||
type="submit" |
|||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" |
|||
disabled={isLoading} |
|||
> |
|||
{isLoading ? ( |
|||
<Loader2 className="h-4 w-4 animate-spin" /> |
|||
) : ( |
|||
'Reset password' |
|||
)} |
|||
</Button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
) |
|||
} |
|||
|
|||
export default ResetPassword |
@ -0,0 +1,102 @@ |
|||
import { useState } from 'react'; |
|||
import { Button } from '@/components/ui/button'; |
|||
import { Input } from '@/components/ui/input'; |
|||
import { Card } from '@/components/ui/card'; |
|||
import { Label } from '@/components/ui/label'; |
|||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; |
|||
import { MoreVertical, Search, Plus } from 'lucide-react'; |
|||
|
|||
interface User { |
|||
id: string; |
|||
name: string; |
|||
email: string; |
|||
role: string; |
|||
status: 'active' | 'inactive'; |
|||
} |
|||
|
|||
export default function UserManagement() { |
|||
const [searchQuery, setSearchQuery] = useState(''); |
|||
const [users, setUsers] = useState<User[]>([ |
|||
{ |
|||
id: '1', |
|||
name: 'John Doe', |
|||
email: 'john@example.com', |
|||
role: 'Admin', |
|||
status: 'active' |
|||
}, |
|||
{ |
|||
id: '2', |
|||
name: 'Jane Smith', |
|||
email: 'jane@example.com', |
|||
role: 'User', |
|||
status: 'active' |
|||
} |
|||
]); |
|||
|
|||
const filteredUsers = users.filter(user => |
|||
user.name.toLowerCase().includes(searchQuery.toLowerCase()) || |
|||
user.email.toLowerCase().includes(searchQuery.toLowerCase()) |
|||
); |
|||
|
|||
return ( |
|||
<div className="p-6"> |
|||
<div className="flex justify-between items-center mb-6"> |
|||
<h1 className="text-2xl font-bold">User Management</h1> |
|||
<Button> |
|||
<Plus className="w-4 h-4 mr-2" /> |
|||
Add User |
|||
</Button> |
|||
</div> |
|||
|
|||
<Card className="p-4 mb-6"> |
|||
<div className="flex items-center space-x-4"> |
|||
<div className="relative flex-1"> |
|||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" /> |
|||
<Input |
|||
placeholder="Search users..." |
|||
value={searchQuery} |
|||
onChange={(e) => setSearchQuery(e.target.value)} |
|||
className="pl-10" |
|||
/> |
|||
</div> |
|||
</div> |
|||
</Card> |
|||
|
|||
<div className="space-y-4"> |
|||
{filteredUsers.map((user) => ( |
|||
<Card key={user.id} className="p-4"> |
|||
<div className="flex items-center justify-between"> |
|||
<div className="flex items-center space-x-4"> |
|||
<div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center"> |
|||
{user.name.charAt(0)} |
|||
</div> |
|||
<div> |
|||
<h3 className="font-medium">{user.name}</h3> |
|||
<p className="text-sm text-gray-500">{user.email}</p> |
|||
</div> |
|||
</div> |
|||
<div className="flex items-center space-x-4"> |
|||
<span className={`px-2 py-1 rounded-full text-xs ${ |
|||
user.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' |
|||
}`}>
|
|||
{user.status} |
|||
</span> |
|||
<DropdownMenu> |
|||
<DropdownMenuTrigger asChild> |
|||
<Button variant="ghost" size="icon"> |
|||
<MoreVertical className="w-4 h-4" /> |
|||
</Button> |
|||
</DropdownMenuTrigger> |
|||
<DropdownMenuContent> |
|||
<DropdownMenuItem>Edit</DropdownMenuItem> |
|||
<DropdownMenuItem>Delete</DropdownMenuItem> |
|||
</DropdownMenuContent> |
|||
</DropdownMenu> |
|||
</div> |
|||
</div> |
|||
</Card> |
|||
))} |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
@ -0,0 +1,94 @@ |
|||
import axios from 'axios'; |
|||
import { authService } from './authService'; |
|||
|
|||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; |
|||
|
|||
if (!API_BASE_URL) { |
|||
throw new Error('VITE_API_BASE_URL is not defined in environment variables'); |
|||
} |
|||
|
|||
// 简单的 toast 函数
|
|||
const showToast = (title: string, description: string, variant: 'default' | 'destructive' = 'default') => { |
|||
const toast = document.createElement('div'); |
|||
toast.className = `fixed top-4 right-4 p-4 rounded-md shadow-lg ${ |
|||
variant === 'destructive' ? 'bg-red-100 text-red-900' : 'bg-green-100 text-green-900' |
|||
}`;
|
|||
toast.innerHTML = ` |
|||
<h3 class="font-medium">${title}</h3> |
|||
<p class="text-sm">${description}</p> |
|||
`;
|
|||
document.body.appendChild(toast); |
|||
setTimeout(() => { |
|||
toast.remove(); |
|||
}, 3000); |
|||
}; |
|||
|
|||
export const api = axios.create({ |
|||
baseURL: API_BASE_URL, |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
}, |
|||
}); |
|||
|
|||
// 请求拦截器
|
|||
api.interceptors.request.use( |
|||
(config) => { |
|||
const token = authService.getToken(); |
|||
if (token) { |
|||
config.headers.Authorization = `Bearer ${token}`; |
|||
} |
|||
return config; |
|||
}, |
|||
(error) => { |
|||
return Promise.reject(error); |
|||
} |
|||
); |
|||
|
|||
// 响应拦截器
|
|||
api.interceptors.response.use( |
|||
(response) => response, |
|||
async (error) => { |
|||
const originalRequest = error.config; |
|||
|
|||
// 如果是 401 错误且不是刷新 token 的请求,尝试刷新 token
|
|||
if (error.response?.status === 401 && !originalRequest._retry && originalRequest.url !== '/Auth/refresh') { |
|||
originalRequest._retry = true; |
|||
|
|||
try { |
|||
await authService.refreshToken(); |
|||
const newToken = authService.getToken(); |
|||
if (newToken) { |
|||
originalRequest.headers.Authorization = `Bearer ${newToken}`; |
|||
return api(originalRequest); |
|||
} |
|||
} catch (refreshError) { |
|||
// 刷新 token 失败,清除所有认证信息并重定向到登录页
|
|||
await authService.logout(); |
|||
window.location.href = '/login'; |
|||
return Promise.reject(refreshError); |
|||
} |
|||
} |
|||
|
|||
// 处理其他错误
|
|||
if (error.response) { |
|||
switch (error.response.status) { |
|||
case 403: |
|||
showToast('权限不足', '您没有权限执行此操作', 'destructive'); |
|||
break; |
|||
case 404: |
|||
showToast('资源未找到', '请求的资源不存在', 'destructive'); |
|||
break; |
|||
case 500: |
|||
showToast('服务器错误', '服务器内部错误,请稍后重试', 'destructive'); |
|||
break; |
|||
default: |
|||
showToast('请求错误', error.response.data?.message || '发生未知错误', 'destructive'); |
|||
} |
|||
} else if (error.request) { |
|||
showToast('网络错误', '请检查网络连接', 'destructive'); |
|||
} else { |
|||
showToast('请求错误', error.message || '发生未知错误', 'destructive'); |
|||
} |
|||
return Promise.reject(error); |
|||
} |
|||
); |
@ -0,0 +1,77 @@ |
|||
import apiClient from '@/lib/api-client' |
|||
|
|||
export interface LoginCredentials { |
|||
email: string |
|||
password: string |
|||
} |
|||
|
|||
export interface RegisterCredentials { |
|||
email: string |
|||
password: string |
|||
name: string |
|||
} |
|||
|
|||
export interface AuthResponse { |
|||
token: string |
|||
user: { |
|||
id: string |
|||
name: string |
|||
email: string |
|||
} |
|||
} |
|||
|
|||
export interface ResetPasswordResponse { |
|||
message: string |
|||
success: boolean |
|||
} |
|||
|
|||
class AuthService { |
|||
async login(credentials: LoginCredentials): Promise<AuthResponse> { |
|||
const response = await apiClient.post<AuthResponse>('/auth/login', credentials) |
|||
return response.data |
|||
} |
|||
|
|||
async register(credentials: RegisterCredentials): Promise<AuthResponse> { |
|||
const response = await apiClient.post<AuthResponse>('/auth/register', credentials) |
|||
return response.data |
|||
} |
|||
|
|||
async logout(): Promise<void> { |
|||
localStorage.removeItem('token') |
|||
} |
|||
|
|||
async requestPasswordReset(email: string): Promise<ResetPasswordResponse> { |
|||
const response = await apiClient.post<ResetPasswordResponse>('/auth/forgot-password', { email }) |
|||
return response.data |
|||
} |
|||
|
|||
async resetPassword(token: string, newPassword: string): Promise<ResetPasswordResponse> { |
|||
const response = await apiClient.post<ResetPasswordResponse>('/auth/reset-password', { |
|||
token, |
|||
newPassword, |
|||
}) |
|||
return response.data |
|||
} |
|||
|
|||
getCurrentUser(): { id: string; name: string; email: string } | null { |
|||
const token = localStorage.getItem('token') |
|||
if (!token) return null |
|||
|
|||
try { |
|||
const payload = JSON.parse(atob(token.split('.')[1])) |
|||
return { |
|||
id: payload.sub, |
|||
name: payload.name, |
|||
email: payload.email, |
|||
} |
|||
} catch (error) { |
|||
return null |
|||
} |
|||
} |
|||
|
|||
isAuthenticated(): boolean { |
|||
return !!localStorage.getItem('token') |
|||
} |
|||
} |
|||
|
|||
export const authService = new AuthService() |
@ -0,0 +1,17 @@ |
|||
import axios from "axios" |
|||
|
|||
const API_URL = import.meta.env.VITE_API_URL |
|||
|
|||
export interface LoginRequest { |
|||
username: string |
|||
password: string |
|||
} |
|||
|
|||
export interface LoginResponse { |
|||
token: string |
|||
} |
|||
|
|||
export const login = async (data: LoginRequest): Promise<{ data: LoginResponse }> => { |
|||
const response = await axios.post(`${API_URL}/auth/login`, data) |
|||
return response |
|||
} |
@ -0,0 +1,106 @@ |
|||
import axios from 'axios'; |
|||
import { api } from './api'; |
|||
|
|||
export interface LoginCredentials { |
|||
usernameOrEmail: string; |
|||
password: string; |
|||
rememberMe?: boolean; |
|||
} |
|||
|
|||
export interface LoginResponse { |
|||
accessToken: string; |
|||
refreshToken: string; |
|||
} |
|||
|
|||
const TOKEN_KEY = 'auth_token'; |
|||
const REFRESH_TOKEN_KEY = 'refresh_token'; |
|||
const REMEMBER_ME_KEY = 'remember_me'; |
|||
const TOKEN_EXPIRY_KEY = 'token_expiry'; |
|||
|
|||
export const authService = { |
|||
getToken: (): string | null => { |
|||
return localStorage.getItem('accessToken'); |
|||
}, |
|||
|
|||
setToken: (token: string): void => { |
|||
localStorage.setItem('accessToken', token); |
|||
}, |
|||
|
|||
getRefreshToken: (): string | null => { |
|||
return localStorage.getItem('refreshToken'); |
|||
}, |
|||
|
|||
setRefreshToken: (token: string): void => { |
|||
localStorage.setItem('refreshToken', token); |
|||
}, |
|||
|
|||
isTokenExpired: (): boolean => { |
|||
const token = authService.getToken(); |
|||
if (!token) return true; |
|||
|
|||
try { |
|||
const payload = JSON.parse(atob(token.split('.')[1])); |
|||
return payload.exp * 1000 < Date.now(); |
|||
} catch (error) { |
|||
return true; |
|||
} |
|||
}, |
|||
|
|||
login: async (usernameOrEmail: string, password: string, rememberMe: boolean): Promise<LoginResponse> => { |
|||
const response = await api.post<LoginResponse>('/Auth/login', { |
|||
usernameOrEmail, |
|||
password, |
|||
rememberMe |
|||
}); |
|||
|
|||
const { accessToken, refreshToken } = response.data; |
|||
authService.setToken(accessToken); |
|||
authService.setRefreshToken(refreshToken); |
|||
|
|||
return response.data; |
|||
}, |
|||
|
|||
logout: async (): Promise<void> => { |
|||
try { |
|||
const refreshToken = authService.getRefreshToken(); |
|||
if (refreshToken) { |
|||
await api.post('/Auth/revoke-token', { token: refreshToken }); |
|||
} |
|||
} catch (error) { |
|||
console.error('Failed to revoke token:', error); |
|||
} finally { |
|||
localStorage.removeItem('accessToken'); |
|||
localStorage.removeItem('refreshToken'); |
|||
} |
|||
}, |
|||
|
|||
refreshToken: async (): Promise<string> => { |
|||
const refreshToken = authService.getRefreshToken(); |
|||
if (!refreshToken) { |
|||
throw new Error('No refresh token available'); |
|||
} |
|||
|
|||
const response = await api.post<LoginResponse>('/Auth/refresh-token', { |
|||
token: refreshToken |
|||
}); |
|||
|
|||
const { accessToken, refreshToken: newRefreshToken } = response.data; |
|||
authService.setToken(accessToken); |
|||
authService.setRefreshToken(newRefreshToken); |
|||
|
|||
return accessToken; |
|||
}, |
|||
|
|||
isRemembered: (): boolean => { |
|||
return localStorage.getItem(REMEMBER_ME_KEY) === 'true'; |
|||
}, |
|||
|
|||
getCurrentUser: async (): Promise<LoginResponse['user'] | null> => { |
|||
try { |
|||
const response = await api.get<LoginResponse['user']>('/Auth/me'); |
|||
return response.data; |
|||
} catch (error) { |
|||
return null; |
|||
} |
|||
} |
|||
}; |
@ -0,0 +1,74 @@ |
|||
/** @type {import('tailwindcss').Config} */ |
|||
export default { |
|||
darkMode: ["class"], |
|||
content: [ |
|||
"./index.html", |
|||
"./src/**/*.{js,ts,jsx,tsx}", |
|||
], |
|||
theme: { |
|||
container: { |
|||
center: true, |
|||
padding: "2rem", |
|||
screens: { |
|||
"2xl": "1400px", |
|||
}, |
|||
}, |
|||
extend: { |
|||
colors: { |
|||
border: "hsl(var(--border))", |
|||
input: "hsl(var(--input))", |
|||
ring: "hsl(var(--ring))", |
|||
background: "hsl(var(--background))", |
|||
foreground: "hsl(var(--foreground))", |
|||
primary: { |
|||
DEFAULT: "hsl(var(--primary))", |
|||
foreground: "hsl(var(--primary-foreground))", |
|||
}, |
|||
secondary: { |
|||
DEFAULT: "hsl(var(--secondary))", |
|||
foreground: "hsl(var(--secondary-foreground))", |
|||
}, |
|||
destructive: { |
|||
DEFAULT: "hsl(var(--destructive))", |
|||
foreground: "hsl(var(--destructive-foreground))", |
|||
}, |
|||
muted: { |
|||
DEFAULT: "hsl(var(--muted))", |
|||
foreground: "hsl(var(--muted-foreground))", |
|||
}, |
|||
accent: { |
|||
DEFAULT: "hsl(var(--accent))", |
|||
foreground: "hsl(var(--accent-foreground))", |
|||
}, |
|||
popover: { |
|||
DEFAULT: "hsl(var(--popover))", |
|||
foreground: "hsl(var(--popover-foreground))", |
|||
}, |
|||
card: { |
|||
DEFAULT: "hsl(var(--card))", |
|||
foreground: "hsl(var(--card-foreground))", |
|||
}, |
|||
}, |
|||
borderRadius: { |
|||
lg: "var(--radius)", |
|||
md: "calc(var(--radius) - 2px)", |
|||
sm: "calc(var(--radius) - 4px)", |
|||
}, |
|||
keyframes: { |
|||
"accordion-down": { |
|||
from: { height: "0" }, |
|||
to: { height: "var(--radix-accordion-content-height)" }, |
|||
}, |
|||
"accordion-up": { |
|||
from: { height: "var(--radix-accordion-content-height)" }, |
|||
to: { height: "0" }, |
|||
}, |
|||
}, |
|||
animation: { |
|||
"accordion-down": "accordion-down 0.2s ease-out", |
|||
"accordion-up": "accordion-up 0.2s ease-out", |
|||
}, |
|||
}, |
|||
}, |
|||
plugins: [require("tailwindcss-animate")], |
|||
} |
@ -0,0 +1,25 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"target": "ES2020", |
|||
"useDefineForClassFields": true, |
|||
"lib": ["ES2020", "DOM", "DOM.Iterable"], |
|||
"module": "ESNext", |
|||
"skipLibCheck": true, |
|||
"moduleResolution": "bundler", |
|||
"allowImportingTsExtensions": true, |
|||
"resolveJsonModule": true, |
|||
"isolatedModules": true, |
|||
"noEmit": true, |
|||
"jsx": "react-jsx", |
|||
"strict": true, |
|||
"noUnusedLocals": true, |
|||
"noUnusedParameters": true, |
|||
"noFallthroughCasesInSwitch": true, |
|||
"baseUrl": ".", |
|||
"paths": { |
|||
"@/*": ["src/*"] |
|||
} |
|||
}, |
|||
"include": ["src"], |
|||
"references": [{ "path": "./tsconfig.node.json" }] |
|||
} |
@ -0,0 +1,10 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"composite": true, |
|||
"skipLibCheck": true, |
|||
"module": "ESNext", |
|||
"moduleResolution": "bundler", |
|||
"allowSyntheticDefaultImports": true |
|||
}, |
|||
"include": ["vite.config.ts"] |
|||
} |
@ -0,0 +1,23 @@ |
|||
import { defineConfig } from 'vite' |
|||
import react from '@vitejs/plugin-react' |
|||
import path from 'path' |
|||
|
|||
// https://vitejs.dev/config/
|
|||
export default defineConfig({ |
|||
plugins: [react()], |
|||
resolve: { |
|||
alias: { |
|||
'@': path.resolve(__dirname, './src'), |
|||
}, |
|||
}, |
|||
server: { |
|||
host: '0.0.0.0', |
|||
port: 3000, |
|||
proxy: { |
|||
'/ws': { |
|||
target: 'ws://localhost:5001', |
|||
ws: true, |
|||
}, |
|||
}, |
|||
}, |
|||
}) |
File diff suppressed because it is too large
Loading…
Reference in new issue