407da15e6d
- 타이포 스케일: body 12→14px, caption 9.6→12px, display 25.6→32px, 위계 강화 - 헤더 우측 3그룹화 (대시보드액션 | 테마/알림/설정 | 모드+프로필), v5-hdr-sep 구분자 추가 - 사이드바 SUPER_ADMIN 회사 카드 borderless slim 라벨로 압축 - 메뉴명 빈 텍스트 방어 + title 속성 추가 - 빈 대시보드(EmptyDashboard) 리디자인: 탭없음/위젯없음 2상태 분리, 2-CTA 카드 - 로그인 코스믹 공연 축소: 별 150→30, 파티클 20→0, 카피 한글화 (로그인 버튼/서브타이틀) - 모드 전환 burst/sweep/badge-zoom 제거, sidebar stagger morph만 유지 (handleModeSwitch 100→25줄) - View transitions duration 1800ms → 500ms Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
165 lines
6.7 KiB
TypeScript
165 lines
6.7 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useCallback } from "react";
|
|
import { useTheme } from "next-themes";
|
|
import { useLogin } from "@/hooks/useLogin";
|
|
import { animatedThemeChange } from "@/lib/themeTransition";
|
|
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);
|
|
|
|
// next-themes 와 연동 — 메인 화면과 다크모드 상태 공유
|
|
const { theme, setTheme: setNextTheme, resolvedTheme } = useTheme();
|
|
const isDark = (resolvedTheme ?? theme) === "dark";
|
|
|
|
// resolvedTheme 가 바뀌면 .inv-login 에 dark 클래스 동기화 (login.css 가 .inv-login.dark 셀렉터로 작성됨)
|
|
useEffect(() => {
|
|
const root = rootRef.current;
|
|
if (!root) return;
|
|
root.classList.toggle("dark", isDark);
|
|
}, [isDark]);
|
|
|
|
// ===== Cosmic background — stars + particles =====
|
|
useEffect(() => {
|
|
const co = cosmosRef.current;
|
|
if (!co) return;
|
|
const cs = ["rgba(var(--v5-primary-rgb),.8)", "rgba(var(--v5-cyan-rgb),.7)", "rgba(var(--v5-pink-rgb),.7)"];
|
|
for (let i = 0; i < 30; 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 < 0; 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 =====
|
|
// 클릭 위치에서 원형 reveal — 메인 화면과 동일한 효과 (View Transitions API)
|
|
const setTheme = useCallback((t: "light" | "dark", e?: React.MouseEvent) => {
|
|
const cur = isDark ? "dark" : "light";
|
|
if (cur === t) return;
|
|
animatedThemeChange(t, setNextTheme, e ? { x: e.clientX, y: e.clientY } : undefined);
|
|
}, [isDark, setNextTheme]);
|
|
|
|
// ===== 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={(e) => setTheme("light", e)}>Light</button>
|
|
<button className={isDark ? "on" : ""} onClick={(e) => setTheme("dark", e)}>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>Invy.one</h1></div>
|
|
<div className="login-sub">엔터프라이즈 운영 센터에 로그인하세요</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>welcome</span></div>
|
|
|
|
<button type="submit" className="lbtn" disabled={isLoading} onMouseDown={handleRipple}>
|
|
{isLoading ? <span className="spinner" /> : <><span>로그인</span><ArrowRight width={16} height={16} /></>}
|
|
</button>
|
|
</form>
|
|
|
|
<div ref={errRef} className="err-msg">{error || "아이디 또는 비밀번호를 확인해주세요"}</div>
|
|
<div className="login-ft">© 2026 Invy.one</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|