Files
invyone/frontend/app/(auth)/login/page.tsx
T
2026-04-06 15:54:35 +09:00

168 lines
6.5 KiB
TypeScript

"use client";
import { useEffect, useRef, useCallback, useState } from "react";
import { useLogin } from "@/hooks/useLogin";
import { User, Lock, Eye, EyeOff, ArrowRight } from "lucide-react";
import "./login.css";
export default function LoginPage() {
const {
formData,
isLoading,
error,
showPassword,
handleInputChange,
handleLogin,
togglePasswordVisibility,
} = useLogin();
const rootRef = useRef<HTMLDivElement>(null);
const cosmosRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const fadeRef = useRef<HTMLDivElement>(null);
const errRef = useRef<HTMLDivElement>(null);
const [isDark, setIsDark] = useState(false);
// ===== Cosmic background — stars + particles =====
useEffect(() => {
const co = cosmosRef.current;
if (!co) return;
const cs = ["rgba(162,155,254,.8)", "rgba(85,239,196,.7)", "rgba(253,121,168,.7)"];
for (let i = 0; i < 150; i++) {
const s = document.createElement("div");
s.className = "star" + (Math.random() > 0.83 ? " c" : "");
if (s.classList.contains("c")) s.style.setProperty("--sc", cs[(Math.random() * 3) | 0]);
s.style.left = Math.random() * 100 + "%";
s.style.top = Math.random() * 100 + "%";
s.style.setProperty("--d", (2 + Math.random() * 5) + "s");
s.style.setProperty("--dl", Math.random() * 5 + "s");
s.style.setProperty("--mo", (0.3 + Math.random() * 0.7) + "");
co.appendChild(s);
}
const pc = ["var(--primary)", "var(--cyan)", "var(--pink)"];
for (let i = 0; i < 20; i++) {
const p = document.createElement("div");
p.className = "particle";
p.style.left = Math.random() * 100 + "%";
p.style.setProperty("--sz", (2 + Math.random() * 4) + "px");
p.style.setProperty("--pc", pc[(Math.random() * 3) | 0]);
p.style.setProperty("--fd", (7 + Math.random() * 12) + "s");
p.style.setProperty("--fdl", Math.random() * 10 + "s");
co.appendChild(p);
}
return () => {
co.querySelectorAll(".star, .particle").forEach((el) => el.remove());
};
}, []);
// ===== Theme toggle =====
const setTheme = useCallback((t: "light" | "dark") => {
const root = rootRef.current;
const fade = fadeRef.current;
if (!root || !fade || (window as any)._themeSwitching) return;
const cur = root.classList.contains("dark") ? "dark" : "light";
if (cur === t) return;
(window as any)._themeSwitching = true;
fade.style.background =
t === "dark"
? "radial-gradient(ellipse at center,#0c0b18,#06050e)"
: "radial-gradient(ellipse at center,#f3f2fa,#fafaff)";
fade.classList.add("in");
setTimeout(() => {
root.classList.toggle("dark", t === "dark");
setIsDark(t === "dark");
setTimeout(() => {
fade.classList.remove("in");
(window as any)._themeSwitching = false;
}, 50);
}, 420);
}, []);
// ===== Show error with denied animation =====
useEffect(() => {
if (!error || !cardRef.current || !errRef.current) return;
const card = cardRef.current;
const errEl = errRef.current;
card.classList.remove("denied");
void card.offsetWidth;
card.classList.add("denied");
errEl.classList.add("show");
const t1 = setTimeout(() => card.classList.remove("denied"), 600);
const t2 = setTimeout(() => errEl.classList.remove("show"), 3000);
return () => { clearTimeout(t1); clearTimeout(t2); };
}, [error]);
// ===== Ripple on button =====
const handleRipple = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
const btn = e.currentTarget;
const r = btn.getBoundingClientRect();
const d = document.createElement("div");
d.className = "rip";
const sz = Math.max(r.width, r.height) * 2;
d.style.width = d.style.height = sz + "px";
d.style.left = e.clientX - r.left - sz / 2 + "px";
d.style.top = e.clientY - r.top - sz / 2 + "px";
btn.appendChild(d);
setTimeout(() => d.remove(), 600);
}, []);
return (
<div ref={rootRef} className="inv-login">
<div ref={fadeRef} className="theme-fade" />
<div ref={cosmosRef} className="cosmos">
<div className="neb neb-1" />
<div className="neb neb-2" />
<div className="neb neb-3" />
<div className="neb neb-4" />
<div className="shooting-star" style={{ top: "12%", left: "70%" }} />
<div className="shooting-star" style={{ top: "35%", left: "55%" }} />
</div>
<div className="pill">
<button className={!isDark ? "on" : ""} onClick={() => setTheme("light")}>Light</button>
<button className={isDark ? "on" : ""} onClick={() => setTheme("dark")}>Dark</button>
</div>
<div ref={cardRef} className="login-card">
<div className="login-orbit">
<div className="orbit-ring" />
<div className="orbit-ring-2" />
<div className="orbit-dot" />
<div className="orbit-core" />
</div>
<div className="logo"><h1>INVION</h1></div>
<div className="login-sub">Cosmic Command Center</div>
<form onSubmit={handleLogin}>
<div className="fg">
<div className="fi-wrap">
<User className="fi-icon" width={16} height={16} />
<input className="fi" name="user_id" placeholder="User ID" value={formData.user_id} onChange={handleInputChange} disabled={isLoading} autoComplete="username" />
</div>
</div>
<div className="fg">
<div className="fi-wrap pw-w">
<Lock className="fi-icon" width={16} height={16} />
<input className={`fi${error ? " error" : ""}`} name="password" type={showPassword ? "text" : "password"} placeholder="Password" value={formData.password} onChange={handleInputChange} disabled={isLoading} autoComplete="current-password" />
<button type="button" className="pw-b" onClick={togglePasswordVisibility} disabled={isLoading}>
{showPassword ? <EyeOff width={16} height={16} /> : <Eye width={16} height={16} />}
</button>
</div>
</div>
<div className="login-divider"><span>ready to launch</span></div>
<button type="submit" className="lbtn" disabled={isLoading} onMouseDown={handleRipple}>
{isLoading ? <span className="spinner" /> : <><span>Launch</span><ArrowRight width={16} height={16} /></>}
</button>
</form>
<div ref={errRef} className="err-msg">{error || "아이디 또는 비밀번호를 확인해주세요"}</div>
<div className="login-ft">&copy; 2026 INVION</div>
</div>
</div>
);
}