52 changed files with 0 additions and 13635 deletions
@ -1 +0,0 @@ |
|||||
VITE_API_BASE_URL=https://localhost:5001/api/v1 |
|
@ -1 +0,0 @@ |
|||||
VITE_API_BASE_URL=https://your-api-url/api/v1 |
|
@ -1 +0,0 @@ |
|||||
VITE_API_BASE_URL=https://localhost:5001/api/v1 |
|
@ -1,31 +0,0 @@ |
|||||
# 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.
@ -1 +0,0 @@ |
|||||
nodeLinker: node-modules |
|
@ -1,16 +0,0 @@ |
|||||
{ |
|
||||
"$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" |
|
||||
} |
|
||||
} |
|
@ -1,13 +0,0 @@ |
|||||
<!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
@ -1,59 +0,0 @@ |
|||||
{ |
|
||||
"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" |
|
||||
} |
|
@ -1,6 +0,0 @@ |
|||||
export default { |
|
||||
plugins: { |
|
||||
tailwindcss: {}, |
|
||||
autoprefixer: {}, |
|
||||
}, |
|
||||
} |
|
@ -1,54 +0,0 @@ |
|||||
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 |
|
@ -1,28 +0,0 @@ |
|||||
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> |
|
||||
); |
|
||||
} |
|
@ -1,19 +0,0 @@ |
|||||
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> |
|
||||
); |
|
||||
} |
|
@ -1,57 +0,0 @@ |
|||||
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> |
|
||||
); |
|
||||
} |
|
@ -1,43 +0,0 @@ |
|||||
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> |
|
||||
) |
|
||||
} |
|
@ -1,17 +0,0 @@ |
|||||
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> |
|
||||
) |
|
||||
} |
|
@ -1,38 +0,0 @@ |
|||||
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> |
|
||||
) |
|
||||
} |
|
@ -1,56 +0,0 @@ |
|||||
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 } |
|
@ -1,78 +0,0 @@ |
|||||
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 } |
|
@ -1,28 +0,0 @@ |
|||||
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 } |
|
@ -1,50 +0,0 @@ |
|||||
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, |
|
||||
} |
|
@ -1,24 +0,0 @@ |
|||||
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 } |
|
@ -1,20 +0,0 @@ |
|||||
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 } |
|
@ -1,127 +0,0 @@ |
|||||
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, |
|
||||
} |
|
@ -1,33 +0,0 @@ |
|||||
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> |
|
||||
) |
|
||||
} |
|
@ -1 +0,0 @@ |
|||||
|
|
@ -1,10 +0,0 @@ |
|||||
/// <reference types="vite/client" />
|
|
||||
|
|
||||
interface ImportMetaEnv { |
|
||||
readonly VITE_API_URL: string |
|
||||
// Add other environment variables here
|
|
||||
} |
|
||||
|
|
||||
interface ImportMeta { |
|
||||
readonly env: ImportMetaEnv |
|
||||
} |
|
@ -1,21 +0,0 @@ |
|||||
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 } |
|
||||
} |
|
@ -1,191 +0,0 @@ |
|||||
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 } |
|
@ -1,76 +0,0 @@ |
|||||
@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; |
|
||||
} |
|
||||
} |
|
@ -1,73 +0,0 @@ |
|||||
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 |
|
@ -1,191 +0,0 @@ |
|||||
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 } |
|
@ -1,6 +0,0 @@ |
|||||
import { type ClassValue, clsx } from 'clsx' |
|
||||
import { twMerge } from 'tailwind-merge' |
|
||||
|
|
||||
export function cn(...inputs: ClassValue[]) { |
|
||||
return twMerge(clsx(inputs)) |
|
||||
} |
|
@ -1,13 +0,0 @@ |
|||||
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>, |
|
||||
) |
|
@ -1,48 +0,0 @@ |
|||||
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> |
|
||||
) |
|
||||
} |
|
@ -1,120 +0,0 @@ |
|||||
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 |
|
@ -1,148 +0,0 @@ |
|||||
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 |
|
@ -1,82 +0,0 @@ |
|||||
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> |
|
||||
) |
|
||||
} |
|
@ -1,103 +0,0 @@ |
|||||
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> |
|
||||
); |
|
||||
}; |
|
@ -1,147 +0,0 @@ |
|||||
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 |
|
@ -1,151 +0,0 @@ |
|||||
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 |
|
@ -1,102 +0,0 @@ |
|||||
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> |
|
||||
); |
|
||||
} |
|
@ -1,94 +0,0 @@ |
|||||
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); |
|
||||
} |
|
||||
); |
|
@ -1,77 +0,0 @@ |
|||||
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() |
|
@ -1,17 +0,0 @@ |
|||||
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 |
|
||||
} |
|
@ -1,106 +0,0 @@ |
|||||
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; |
|
||||
} |
|
||||
} |
|
||||
}; |
|
@ -1,74 +0,0 @@ |
|||||
/** @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")], |
|
||||
} |
|
@ -1,25 +0,0 @@ |
|||||
{ |
|
||||
"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" }] |
|
||||
} |
|
@ -1,10 +0,0 @@ |
|||||
{ |
|
||||
"compilerOptions": { |
|
||||
"composite": true, |
|
||||
"skipLibCheck": true, |
|
||||
"module": "ESNext", |
|
||||
"moduleResolution": "bundler", |
|
||||
"allowSyntheticDefaultImports": true |
|
||||
}, |
|
||||
"include": ["vite.config.ts"] |
|
||||
} |
|
@ -1,23 +0,0 @@ |
|||||
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