Add JWT authentication with animated login page
- Backend: JWT auth with /api/auth/login and /api/auth/verify endpoints - Middleware: requireAuth protects all /api routes except /api/auth - Frontend: Animated characters login page with eye-tracking effects - Auth state persisted in localStorage with token verification on mount - Sidebar logout button, 401 auto-logout, 30-day token expiry - shadcn button, input, checkbox, label components added Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,9 @@
|
||||
"vite": "^8.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@tanstack/react-query": "^5.95.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -25,7 +28,7 @@
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useState, useCallback } from "react"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { Cpu, MemoryStick, HardDrive, Container } from "lucide-react"
|
||||
import { Sidebar } from "@/components/layout/Sidebar"
|
||||
@@ -14,10 +14,25 @@ import { ContainersPage } from "@/components/pages/ContainersPage"
|
||||
import { MemoryPage } from "@/components/pages/MemoryPage"
|
||||
import { StoragePage } from "@/components/pages/StoragePage"
|
||||
import { NetworkPage } from "@/components/pages/NetworkPage"
|
||||
import { LoginPage } from "@/components/ui/animated-characters-login-page"
|
||||
import { useServerHealth } from "@/hooks/useServerHealth"
|
||||
import { useCpuHistory } from "@/hooks/useCpuHistory"
|
||||
import { formatBytes } from "@/lib/utils"
|
||||
|
||||
const TOKEN_KEY = "eventify-auth-token"
|
||||
|
||||
function getStoredToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
function setStoredToken(token: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
function clearStoredToken() {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
@@ -35,7 +50,7 @@ const PAGE_TITLES: Record<string, string> = {
|
||||
network: "Network",
|
||||
}
|
||||
|
||||
function DashboardContent({ activePage }: { activePage: string }) {
|
||||
function DashboardContent({ activePage, onLogout }: { activePage: string; onLogout: () => void }) {
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
@@ -55,6 +70,13 @@ function DashboardContent({ activePage }: { activePage: string }) {
|
||||
}
|
||||
}, [data, addDataPoint])
|
||||
|
||||
// If we get a 401, the token is invalid — log out
|
||||
useEffect(() => {
|
||||
if (isError && error instanceof Error && error.message.includes("401")) {
|
||||
onLogout()
|
||||
}
|
||||
}, [isError, error, onLogout])
|
||||
|
||||
const lastUpdated = dataUpdatedAt ? new Date(dataUpdatedAt).toISOString() : null
|
||||
|
||||
if (isError) {
|
||||
@@ -76,7 +98,7 @@ function DashboardContent({ activePage }: { activePage: string }) {
|
||||
</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="mt-3 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
|
||||
className="mt-3 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 cursor-pointer"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
@@ -229,13 +251,62 @@ function DashboardContent({ activePage }: { activePage: string }) {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [token, setToken] = useState<string | null>(getStoredToken)
|
||||
const [activePage, setActivePage] = useState("dashboard")
|
||||
const [verifying, setVerifying] = useState(true)
|
||||
|
||||
// Verify stored token on mount
|
||||
useEffect(() => {
|
||||
const stored = getStoredToken()
|
||||
if (!stored) {
|
||||
setVerifying(false)
|
||||
return
|
||||
}
|
||||
|
||||
fetch("/api/auth/verify", {
|
||||
headers: { Authorization: `Bearer ${stored}` },
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
clearStoredToken()
|
||||
setToken(null)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
clearStoredToken()
|
||||
setToken(null)
|
||||
})
|
||||
.finally(() => setVerifying(false))
|
||||
}, [])
|
||||
|
||||
const handleLogin = useCallback((newToken: string) => {
|
||||
setStoredToken(newToken)
|
||||
setToken(newToken)
|
||||
}, [])
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
clearStoredToken()
|
||||
setToken(null)
|
||||
queryClient.clear()
|
||||
}, [])
|
||||
|
||||
if (verifying) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return <LoginPage onLogin={handleLogin} />
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<Sidebar activePage={activePage} onNavigate={setActivePage} />
|
||||
<DashboardContent activePage={activePage} />
|
||||
<Sidebar activePage={activePage} onNavigate={setActivePage} onLogout={handleLogout} />
|
||||
<DashboardContent activePage={activePage} onLogout={handleLogout} />
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Settings,
|
||||
HelpCircle,
|
||||
Server,
|
||||
LogOut,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
@@ -33,6 +34,7 @@ const bottomNav: NavItem[] = [
|
||||
interface SidebarProps {
|
||||
activePage: string
|
||||
onNavigate: (page: string) => void
|
||||
onLogout?: () => void
|
||||
}
|
||||
|
||||
function NavButton({
|
||||
@@ -61,7 +63,7 @@ function NavButton({
|
||||
)
|
||||
}
|
||||
|
||||
export function Sidebar({ activePage, onNavigate }: SidebarProps) {
|
||||
export function Sidebar({ activePage, onNavigate, onLogout }: SidebarProps) {
|
||||
return (
|
||||
<aside className="flex h-screen w-60 shrink-0 flex-col border-r bg-sidebar-background">
|
||||
{/* Brand */}
|
||||
@@ -114,6 +116,17 @@ export function Sidebar({ activePage, onNavigate }: SidebarProps) {
|
||||
All Systems Operational
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
{onLogout && (
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="mb-4 flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-red-50 hover:text-red-600 cursor-pointer"
|
||||
>
|
||||
<LogOut className="size-5 shrink-0" />
|
||||
<span>Sign Out</span>
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
|
||||
560
client/src/components/ui/animated-characters-login-page.tsx
Normal file
560
client/src/components/ui/animated-characters-login-page.tsx
Normal file
@@ -0,0 +1,560 @@
|
||||
"use client";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
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 { Eye, EyeOff, Sparkles } from "lucide-react";
|
||||
|
||||
interface PupilProps {
|
||||
size?: number;
|
||||
maxDistance?: number;
|
||||
pupilColor?: string;
|
||||
forceLookX?: number;
|
||||
forceLookY?: number;
|
||||
}
|
||||
|
||||
const Pupil = ({
|
||||
size = 12,
|
||||
maxDistance = 5,
|
||||
pupilColor = "black",
|
||||
forceLookX,
|
||||
forceLookY
|
||||
}: PupilProps) => {
|
||||
const [mouseX, setMouseX] = useState<number>(0);
|
||||
const [mouseY, setMouseY] = useState<number>(0);
|
||||
const pupilRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
setMouseX(e.clientX);
|
||||
setMouseY(e.clientY);
|
||||
};
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const calculatePupilPosition = () => {
|
||||
if (!pupilRef.current) return { x: 0, y: 0 };
|
||||
if (forceLookX !== undefined && forceLookY !== undefined) {
|
||||
return { x: forceLookX, y: forceLookY };
|
||||
}
|
||||
|
||||
const pupil = pupilRef.current.getBoundingClientRect();
|
||||
const pupilCenterX = pupil.left + pupil.width / 2;
|
||||
const pupilCenterY = pupil.top + pupil.height / 2;
|
||||
|
||||
const deltaX = mouseX - pupilCenterX;
|
||||
const deltaY = mouseY - pupilCenterY;
|
||||
const distance = Math.min(Math.sqrt(deltaX ** 2 + deltaY ** 2), maxDistance);
|
||||
const angle = Math.atan2(deltaY, deltaX);
|
||||
|
||||
const x = Math.cos(angle) * distance;
|
||||
const y = Math.sin(angle) * distance;
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
const pupilPosition = calculatePupilPosition();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={pupilRef}
|
||||
className="rounded-full"
|
||||
style={{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
backgroundColor: pupilColor,
|
||||
transform: `translate(${pupilPosition.x}px, ${pupilPosition.y}px)`,
|
||||
transition: 'transform 0.1s ease-out',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface EyeBallProps {
|
||||
size?: number;
|
||||
pupilSize?: number;
|
||||
maxDistance?: number;
|
||||
eyeColor?: string;
|
||||
pupilColor?: string;
|
||||
isBlinking?: boolean;
|
||||
forceLookX?: number;
|
||||
forceLookY?: number;
|
||||
}
|
||||
|
||||
const EyeBall = ({
|
||||
size = 48,
|
||||
pupilSize = 16,
|
||||
maxDistance = 10,
|
||||
eyeColor = "white",
|
||||
pupilColor = "black",
|
||||
isBlinking = false,
|
||||
forceLookX,
|
||||
forceLookY
|
||||
}: EyeBallProps) => {
|
||||
const [mouseX, setMouseX] = useState<number>(0);
|
||||
const [mouseY, setMouseY] = useState<number>(0);
|
||||
const eyeRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
setMouseX(e.clientX);
|
||||
setMouseY(e.clientY);
|
||||
};
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const calculatePupilPosition = () => {
|
||||
if (!eyeRef.current) return { x: 0, y: 0 };
|
||||
if (forceLookX !== undefined && forceLookY !== undefined) {
|
||||
return { x: forceLookX, y: forceLookY };
|
||||
}
|
||||
|
||||
const eye = eyeRef.current.getBoundingClientRect();
|
||||
const eyeCenterX = eye.left + eye.width / 2;
|
||||
const eyeCenterY = eye.top + eye.height / 2;
|
||||
|
||||
const deltaX = mouseX - eyeCenterX;
|
||||
const deltaY = mouseY - eyeCenterY;
|
||||
const distance = Math.min(Math.sqrt(deltaX ** 2 + deltaY ** 2), maxDistance);
|
||||
const angle = Math.atan2(deltaY, deltaX);
|
||||
|
||||
const x = Math.cos(angle) * distance;
|
||||
const y = Math.sin(angle) * distance;
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
const pupilPosition = calculatePupilPosition();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={eyeRef}
|
||||
className="rounded-full flex items-center justify-center transition-all duration-150"
|
||||
style={{
|
||||
width: `${size}px`,
|
||||
height: isBlinking ? '2px' : `${size}px`,
|
||||
backgroundColor: eyeColor,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{!isBlinking && (
|
||||
<div
|
||||
className="rounded-full"
|
||||
style={{
|
||||
width: `${pupilSize}px`,
|
||||
height: `${pupilSize}px`,
|
||||
backgroundColor: pupilColor,
|
||||
transform: `translate(${pupilPosition.x}px, ${pupilPosition.y}px)`,
|
||||
transition: 'transform 0.1s ease-out',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface LoginPageProps {
|
||||
onLogin: (token: string) => void;
|
||||
}
|
||||
|
||||
function LoginPage({ onLogin }: LoginPageProps) {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [mouseX, setMouseX] = useState<number>(0);
|
||||
const [mouseY, setMouseY] = useState<number>(0);
|
||||
const [isPurpleBlinking, setIsPurpleBlinking] = useState(false);
|
||||
const [isBlackBlinking, setIsBlackBlinking] = useState(false);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [isLookingAtEachOther, setIsLookingAtEachOther] = useState(false);
|
||||
const [isPurplePeeking, setIsPurplePeeking] = useState(false);
|
||||
const purpleRef = useRef<HTMLDivElement>(null);
|
||||
const blackRef = useRef<HTMLDivElement>(null);
|
||||
const yellowRef = useRef<HTMLDivElement>(null);
|
||||
const orangeRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
setMouseX(e.clientX);
|
||||
setMouseY(e.clientY);
|
||||
};
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
return () => window.removeEventListener("mousemove", handleMouseMove);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const getRandomBlinkInterval = () => Math.random() * 4000 + 3000;
|
||||
const scheduleBlink = () => {
|
||||
const blinkTimeout = setTimeout(() => {
|
||||
setIsPurpleBlinking(true);
|
||||
setTimeout(() => {
|
||||
setIsPurpleBlinking(false);
|
||||
scheduleBlink();
|
||||
}, 150);
|
||||
}, getRandomBlinkInterval());
|
||||
return blinkTimeout;
|
||||
};
|
||||
const timeout = scheduleBlink();
|
||||
return () => clearTimeout(timeout);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const getRandomBlinkInterval = () => Math.random() * 4000 + 3000;
|
||||
const scheduleBlink = () => {
|
||||
const blinkTimeout = setTimeout(() => {
|
||||
setIsBlackBlinking(true);
|
||||
setTimeout(() => {
|
||||
setIsBlackBlinking(false);
|
||||
scheduleBlink();
|
||||
}, 150);
|
||||
}, getRandomBlinkInterval());
|
||||
return blinkTimeout;
|
||||
};
|
||||
const timeout = scheduleBlink();
|
||||
return () => clearTimeout(timeout);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTyping) {
|
||||
setIsLookingAtEachOther(true);
|
||||
const timer = setTimeout(() => {
|
||||
setIsLookingAtEachOther(false);
|
||||
}, 800);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setIsLookingAtEachOther(false);
|
||||
}
|
||||
}, [isTyping]);
|
||||
|
||||
useEffect(() => {
|
||||
if (password.length > 0 && showPassword) {
|
||||
const schedulePeek = () => {
|
||||
const peekInterval = setTimeout(() => {
|
||||
setIsPurplePeeking(true);
|
||||
setTimeout(() => {
|
||||
setIsPurplePeeking(false);
|
||||
}, 800);
|
||||
}, Math.random() * 3000 + 2000);
|
||||
return peekInterval;
|
||||
};
|
||||
const firstPeek = schedulePeek();
|
||||
return () => clearTimeout(firstPeek);
|
||||
} else {
|
||||
setIsPurplePeeking(false);
|
||||
}
|
||||
}, [password, showPassword, isPurplePeeking]);
|
||||
|
||||
const calculatePosition = (ref: React.RefObject<HTMLDivElement | null>) => {
|
||||
if (!ref.current) return { faceX: 0, faceY: 0, bodySkew: 0 };
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 3;
|
||||
const deltaX = mouseX - centerX;
|
||||
const deltaY = mouseY - centerY;
|
||||
const faceX = Math.max(-15, Math.min(15, deltaX / 20));
|
||||
const faceY = Math.max(-10, Math.min(10, deltaY / 30));
|
||||
const bodySkew = Math.max(-6, Math.min(6, -deltaX / 120));
|
||||
return { faceX, faceY, bodySkew };
|
||||
};
|
||||
|
||||
const purplePos = calculatePosition(purpleRef);
|
||||
const blackPos = calculatePosition(blackRef);
|
||||
const yellowPos = calculatePosition(yellowRef);
|
||||
const orangePos = calculatePosition(orangeRef);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Invalid email or password.");
|
||||
} else {
|
||||
onLogin(data.token);
|
||||
}
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen grid lg:grid-cols-2">
|
||||
{/* Left Content Section */}
|
||||
<div className="relative hidden lg:flex flex-col justify-between bg-gradient-to-br from-primary/90 via-primary to-primary/80 p-12 text-primary-foreground">
|
||||
<div className="relative z-20">
|
||||
<div className="flex items-center gap-2 text-lg font-semibold">
|
||||
<div className="size-8 rounded-lg bg-primary-foreground/10 backdrop-blur-sm flex items-center justify-center">
|
||||
<Sparkles className="size-4" />
|
||||
</div>
|
||||
<span>Eventify</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-20 flex items-end justify-center h-[500px]">
|
||||
{/* Cartoon Characters */}
|
||||
<div className="relative" style={{ width: '550px', height: '400px' }}>
|
||||
{/* Purple tall rectangle character */}
|
||||
<div
|
||||
ref={purpleRef}
|
||||
className="absolute bottom-0 transition-all duration-700 ease-in-out"
|
||||
style={{
|
||||
left: '70px',
|
||||
width: '180px',
|
||||
height: (isTyping || (password.length > 0 && !showPassword)) ? '440px' : '400px',
|
||||
backgroundColor: '#6C3FF5',
|
||||
borderRadius: '10px 10px 0 0',
|
||||
zIndex: 1,
|
||||
transform: (password.length > 0 && showPassword)
|
||||
? `skewX(0deg)`
|
||||
: (isTyping || (password.length > 0 && !showPassword))
|
||||
? `skewX(${(purplePos.bodySkew || 0) - 12}deg) translateX(40px)`
|
||||
: `skewX(${purplePos.bodySkew || 0}deg)`,
|
||||
transformOrigin: 'bottom center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute flex gap-8 transition-all duration-700 ease-in-out"
|
||||
style={{
|
||||
left: (password.length > 0 && showPassword) ? `${20}px` : isLookingAtEachOther ? `${55}px` : `${45 + purplePos.faceX}px`,
|
||||
top: (password.length > 0 && showPassword) ? `${35}px` : isLookingAtEachOther ? `${65}px` : `${40 + purplePos.faceY}px`,
|
||||
}}
|
||||
>
|
||||
<EyeBall
|
||||
size={18} pupilSize={7} maxDistance={5} eyeColor="white" pupilColor="#2D2D2D"
|
||||
isBlinking={isPurpleBlinking}
|
||||
forceLookX={(password.length > 0 && showPassword) ? (isPurplePeeking ? 4 : -4) : isLookingAtEachOther ? 3 : undefined}
|
||||
forceLookY={(password.length > 0 && showPassword) ? (isPurplePeeking ? 5 : -4) : isLookingAtEachOther ? 4 : undefined}
|
||||
/>
|
||||
<EyeBall
|
||||
size={18} pupilSize={7} maxDistance={5} eyeColor="white" pupilColor="#2D2D2D"
|
||||
isBlinking={isPurpleBlinking}
|
||||
forceLookX={(password.length > 0 && showPassword) ? (isPurplePeeking ? 4 : -4) : isLookingAtEachOther ? 3 : undefined}
|
||||
forceLookY={(password.length > 0 && showPassword) ? (isPurplePeeking ? 5 : -4) : isLookingAtEachOther ? 4 : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Black tall rectangle character */}
|
||||
<div
|
||||
ref={blackRef}
|
||||
className="absolute bottom-0 transition-all duration-700 ease-in-out"
|
||||
style={{
|
||||
left: '240px',
|
||||
width: '120px',
|
||||
height: '310px',
|
||||
backgroundColor: '#2D2D2D',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
zIndex: 2,
|
||||
transform: (password.length > 0 && showPassword)
|
||||
? `skewX(0deg)`
|
||||
: isLookingAtEachOther
|
||||
? `skewX(${(blackPos.bodySkew || 0) * 1.5 + 10}deg) translateX(20px)`
|
||||
: (isTyping || (password.length > 0 && !showPassword))
|
||||
? `skewX(${(blackPos.bodySkew || 0) * 1.5}deg)`
|
||||
: `skewX(${blackPos.bodySkew || 0}deg)`,
|
||||
transformOrigin: 'bottom center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute flex gap-6 transition-all duration-700 ease-in-out"
|
||||
style={{
|
||||
left: (password.length > 0 && showPassword) ? `${10}px` : isLookingAtEachOther ? `${32}px` : `${26 + blackPos.faceX}px`,
|
||||
top: (password.length > 0 && showPassword) ? `${28}px` : isLookingAtEachOther ? `${12}px` : `${32 + blackPos.faceY}px`,
|
||||
}}
|
||||
>
|
||||
<EyeBall
|
||||
size={16} pupilSize={6} maxDistance={4} eyeColor="white" pupilColor="#2D2D2D"
|
||||
isBlinking={isBlackBlinking}
|
||||
forceLookX={(password.length > 0 && showPassword) ? -4 : isLookingAtEachOther ? 0 : undefined}
|
||||
forceLookY={(password.length > 0 && showPassword) ? -4 : isLookingAtEachOther ? -4 : undefined}
|
||||
/>
|
||||
<EyeBall
|
||||
size={16} pupilSize={6} maxDistance={4} eyeColor="white" pupilColor="#2D2D2D"
|
||||
isBlinking={isBlackBlinking}
|
||||
forceLookX={(password.length > 0 && showPassword) ? -4 : isLookingAtEachOther ? 0 : undefined}
|
||||
forceLookY={(password.length > 0 && showPassword) ? -4 : isLookingAtEachOther ? -4 : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Orange semi-circle character */}
|
||||
<div
|
||||
ref={orangeRef}
|
||||
className="absolute bottom-0 transition-all duration-700 ease-in-out"
|
||||
style={{
|
||||
left: '0px',
|
||||
width: '240px',
|
||||
height: '200px',
|
||||
zIndex: 3,
|
||||
backgroundColor: '#FF9B6B',
|
||||
borderRadius: '120px 120px 0 0',
|
||||
transform: (password.length > 0 && showPassword) ? `skewX(0deg)` : `skewX(${orangePos.bodySkew || 0}deg)`,
|
||||
transformOrigin: 'bottom center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute flex gap-8 transition-all duration-200 ease-out"
|
||||
style={{
|
||||
left: (password.length > 0 && showPassword) ? `${50}px` : `${82 + (orangePos.faceX || 0)}px`,
|
||||
top: (password.length > 0 && showPassword) ? `${85}px` : `${90 + (orangePos.faceY || 0)}px`,
|
||||
}}
|
||||
>
|
||||
<Pupil size={12} maxDistance={5} pupilColor="#2D2D2D" forceLookX={(password.length > 0 && showPassword) ? -5 : undefined} forceLookY={(password.length > 0 && showPassword) ? -4 : undefined} />
|
||||
<Pupil size={12} maxDistance={5} pupilColor="#2D2D2D" forceLookX={(password.length > 0 && showPassword) ? -5 : undefined} forceLookY={(password.length > 0 && showPassword) ? -4 : undefined} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Yellow tall rectangle character */}
|
||||
<div
|
||||
ref={yellowRef}
|
||||
className="absolute bottom-0 transition-all duration-700 ease-in-out"
|
||||
style={{
|
||||
left: '310px',
|
||||
width: '140px',
|
||||
height: '230px',
|
||||
backgroundColor: '#E8D754',
|
||||
borderRadius: '70px 70px 0 0',
|
||||
zIndex: 4,
|
||||
transform: (password.length > 0 && showPassword) ? `skewX(0deg)` : `skewX(${yellowPos.bodySkew || 0}deg)`,
|
||||
transformOrigin: 'bottom center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute flex gap-6 transition-all duration-200 ease-out"
|
||||
style={{
|
||||
left: (password.length > 0 && showPassword) ? `${20}px` : `${52 + (yellowPos.faceX || 0)}px`,
|
||||
top: (password.length > 0 && showPassword) ? `${35}px` : `${40 + (yellowPos.faceY || 0)}px`,
|
||||
}}
|
||||
>
|
||||
<Pupil size={12} maxDistance={5} pupilColor="#2D2D2D" forceLookX={(password.length > 0 && showPassword) ? -5 : undefined} forceLookY={(password.length > 0 && showPassword) ? -4 : undefined} />
|
||||
<Pupil size={12} maxDistance={5} pupilColor="#2D2D2D" forceLookX={(password.length > 0 && showPassword) ? -5 : undefined} forceLookY={(password.length > 0 && showPassword) ? -4 : undefined} />
|
||||
</div>
|
||||
<div
|
||||
className="absolute w-20 h-[4px] bg-[#2D2D2D] rounded-full transition-all duration-200 ease-out"
|
||||
style={{
|
||||
left: (password.length > 0 && showPassword) ? `${10}px` : `${40 + (yellowPos.faceX || 0)}px`,
|
||||
top: (password.length > 0 && showPassword) ? `${88}px` : `${88 + (yellowPos.faceY || 0)}px`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-20 flex items-center gap-8 text-sm text-primary-foreground/60">
|
||||
<a href="#" className="hover:text-primary-foreground transition-colors">Privacy Policy</a>
|
||||
<a href="#" className="hover:text-primary-foreground transition-colors">Terms of Service</a>
|
||||
<a href="#" className="hover:text-primary-foreground transition-colors">Contact</a>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 bg-grid-white/[0.05] bg-[size:20px_20px]" />
|
||||
<div className="absolute top-1/4 right-1/4 size-64 bg-primary-foreground/10 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 left-1/4 size-96 bg-primary-foreground/5 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
{/* Right Login Section */}
|
||||
<div className="flex items-center justify-center p-8 bg-background">
|
||||
<div className="w-full max-w-[420px]">
|
||||
<div className="lg:hidden flex items-center justify-center gap-2 text-lg font-semibold mb-12">
|
||||
<div className="size-8 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Sparkles className="size-4 text-primary" />
|
||||
</div>
|
||||
<span>Eventify</span>
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Server Monitor</h1>
|
||||
<p className="text-muted-foreground text-sm">Sign in to access the dashboard</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-sm font-medium">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="admin@eventifyplus.com"
|
||||
value={email}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onFocus={() => setIsTyping(true)}
|
||||
onBlur={() => setIsTyping(false)}
|
||||
required
|
||||
className="h-12 bg-background border-border/60 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-sm font-medium">Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="h-12 pr-10 bg-background border-border/60 focus:border-primary"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
{showPassword ? <EyeOff className="size-5" /> : <Eye className="size-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="remember" />
|
||||
<Label htmlFor="remember" className="text-sm font-normal cursor-pointer">
|
||||
Remember for 30 days
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 text-base font-medium cursor-pointer"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Signing in..." : "Log in"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground mt-8">
|
||||
Eventify Server Health Monitor
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { LoginPage };
|
||||
56
client/src/components/ui/button.tsx
Normal file
56
client/src/components/ui/button.tsx
Normal file
@@ -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 ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<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 }
|
||||
30
client/src/components/ui/checkbox.tsx
Normal file
30
client/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
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 }
|
||||
25
client/src/components/ui/input.tsx
Normal file
25
client/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
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-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
26
client/src/components/ui/label.tsx
Normal file
26
client/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
@@ -23,7 +23,10 @@ export function useServerHealth() {
|
||||
const query = useQuery<ServerOverview>({
|
||||
queryKey: ["server-overview"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/overview")
|
||||
const token = localStorage.getItem("eventify-auth-token")
|
||||
const res = await fetch("/api/overview", {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
if (!res.ok) throw new Error(`Server responded with ${res.status}`)
|
||||
return res.json()
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user