ㄱㄱㄱ

This commit is contained in:
mota
2025-11-18 07:53:09 +09:00
parent 0452ca2c28
commit 4f7b98dffb
20 changed files with 1348 additions and 196 deletions

View File

@@ -4,6 +4,7 @@ import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import MainLogoSvg from "./svgs/mainlogosvg";
import ChevronDownSvg from "./svgs/chevrondownsvg";
const NAV_ITEMS = [
{ label: "교육 과정 목록", href: "/menu" },
@@ -77,12 +78,14 @@ export default function NavBar() {
onClick={() => setIsUserMenuOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={isUserMenuOpen}
className="flex items-center gap-1 px-4 py-2 text-[16px] font-semibold text-white"
className="flex items-center gap-1 px-4 py-2 text-[16px] font-semibold text-white cursor-pointer"
>
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden className="-rotate-90">
<path d="M8 5l8 7-8 7" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<ChevronDownSvg
width={16}
height={16}
className={["transition-transform", isUserMenuOpen ? "rotate-180" : "rotate-0"].join(" ")}
/>
</button>
{isUserMenuOpen && (
<div
@@ -91,12 +94,14 @@ export default function NavBar() {
aria-label="사용자 메뉴"
className="absolute right-0 top-full mt-2 bg-white rounded-lg shadow-[0_0_8px_0_rgba(0,0,0,0.25)] p-3 z-50"
>
<button
<Link
role="menuitem"
className="w-full h-10 px-2 rounded-lg text-left text-[#333C47] text-[16px] font-medium leading-normal hover:bg-[rgba(236,240,255,0.5)] focus:bg-[rgba(236,240,255,0.5)] outline-none"
href="/menu/account"
className="block w-full h-10 px-2 rounded-lg text-left text-[#333C47] text-[16px] font-medium leading-normal hover:bg-[rgba(236,240,255,0.5)] focus:bg-[rgba(236,240,255,0.5)] outline-none"
onClick={() => setIsUserMenuOpen(false)}
>
</button>
</Link>
<button
role="menuitem"
className="w-full h-10 px-2 rounded-lg text-left text-[#333C47] text-[16px] font-medium leading-normal hover:bg-[rgba(236,240,255,0.5)] focus:bg-[rgba(236,240,255,0.5)] outline-none"

View File

@@ -0,0 +1,50 @@
'use client';
import MainLogoSvg from '../svgs/mainlogosvg';
export default function Footer() {
return (
<footer className="bg-[#f2f3f7] border-t border-[rgba(0,0,0,0.1)]">
<div className="flex justify-center">
<div className="w-full max-w-[1440px] px-[64px] py-[80px] flex gap-[32px]">
<div className="flex flex-col items-center gap-[7px] w-[72px]">
<MainLogoSvg width={72} height={54} />
<div className="text-[16px] font-extrabold leading-[1.45] tracking-[-0.08px] text-black">
XL LMS
</div>
</div>
<div className="flex-1 flex flex-col justify-end gap-[24px]">
<div className="flex items-center gap-[24px]">
<a href="#" className="text-[16px] font-semibold leading-[1.45] tracking-[-0.08px] text-black no-underline">
</a>
<div className="w-px h-[18px] bg-black/10" aria-hidden />
<a href="#" className="text-[16px] font-semibold leading-[1.45] tracking-[-0.08px] text-black no-underline">
</a>
<div className="w-px h-[18px] bg-black/10" aria-hidden />
<a href="#" className="text-[16px] font-semibold leading-[1.45] tracking-[-0.08px] text-black no-underline">
</a>
</div>
<div className="w-full flex items-end justify-between text-[16px] leading-[0] tracking-[-0.08px] text-black/55">
<div className="flex flex-col gap-[8px]">
<p className="leading-[1.45] text-nowrap">(12345) 123-12(1)</p>
<p className="leading-[1.45] text-nowrap">문의: 1234-1234 ( 09:00 ~ 18:00)</p>
<p className="leading-[1.45] text-nowrap">이메일: qwer1234@go.or.kr</p>
</div>
<p className="leading-[1.45] text-nowrap">Copyright 2025 XL LMS. All rights reserved</p>
</div>
</div>
<img
src="/imgs/talk.png"
alt="talk"
className="self-end ml-auto mr-[40px] mb-[40px]"
/>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,15 @@
'use client';
import { usePathname } from "next/navigation";
import NavBar from "../NavBar";
const HIDE_HEADER_PREFIXES = ["/login", "/register", "/reset-password", "/find-id"];
export default function HeaderVisibility() {
const pathname = usePathname();
const shouldHide = HIDE_HEADER_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/"));
if (shouldHide) return null;
return <NavBar />;
}

View File

@@ -47,5 +47,4 @@
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -1,7 +1,8 @@
import type { Metadata } from "next";
import "./globals.css";
import { pretendard } from "./fonts";
import NavBar from "./NavBar";
import HeaderVisibility from "./components/HeaderVisibility";
import Footer from "./components/Footer";
export const metadata: Metadata = {
title: "XRLMS",
@@ -13,9 +14,12 @@ export default function RootLayout({
}: Readonly<{ children: React.ReactNode; }>) {
return (
<html lang="ko">
<body className={pretendard.className}>
<NavBar />
{children}
<body className={`${pretendard.className} min-h-screen flex flex-col`}>
<HeaderVisibility />
<main className="flex-1 min-h-0">
{children}
</main>
<Footer />
</body>
</html>
);

View File

@@ -0,0 +1,70 @@
'use client';
import ModalCloseSvg from "../svgs/closexsvg";
type Props = {
open: boolean;
onClose: () => void;
onConfirm?: () => void;
};
export default function AccountDeleteModal({ open, onClose, onConfirm }: Props) {
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-[2px]"
role="dialog"
aria-modal="true"
>
<div className="w-[528px] rounded-[12px] border border-[#dee1e6] bg-white shadow-[0_10px_30px_rgba(0,0,0,0.06)]">
{/* header */}
<div className="flex items-center justify-between p-6">
<h2 className="text-[20px] font-bold leading-[1.5] text-[#333c47]"> </h2>
<button
type="button"
aria-label="닫기"
onClick={onClose}
className="inline-flex size-6 items-center justify-center text-[#333c47] hover:opacity-80 cursor-pointer"
>
<ModalCloseSvg />
</button>
</div>
{/* body */}
<div className="px-6">
<div className="rounded-[16px] border border-[#dee1e6] bg-gray-50 p-6">
<p className="mb-3 text-[15px] font-bold leading-[1.5] text-[#4c5561]">
.
</p>
<div className="text-[15px] leading-[1.5] text-[#4c5561]">
<p className="mb-0">- .</p>
<p className="mb-0">- , .</p>
<p>- .</p>
</div>
</div>
</div>
{/* footer */}
<div className="flex items-center justify-center gap-3 p-6">
<button
type="button"
onClick={onClose}
className="h-12 w-[136px] rounded-[10px] bg-[#f1f3f5] px-4 text-[16px] font-semibold leading-[1.5] text-[#4c5561] cursor-pointer"
>
</button>
<button
type="button"
onClick={onConfirm}
className="h-12 w-[136px] rounded-[10px] bg-red-50 px-4 text-[16px] font-semibold leading-[1.5] text-[#f64c4c] cursor-pointer"
>
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,27 +1,74 @@
'use client';
import { useState } from "react";
import { useEffect, useState } from "react";
import ModalCloseSvg from "../svgs/closexsvg";
type Props = {
open: boolean;
onClose: () => void;
onSubmit?: (payload: { email: string; code: string; newPassword: string }) => void;
onSubmit?: (payload: { email: string; code?: string; newPassword: string }) => void;
showVerification?: boolean;
devVerificationState?: 'initial' | 'sent' | 'verified' | 'failed';
};
export default function ChangePasswordModal({ open, onClose, onSubmit }: Props) {
export default function ChangePasswordModal({ open, onClose, onSubmit, showVerification = false, devVerificationState }: Props) {
const [email, setEmail] = useState("xrlms2025@gmail.com");
const [code, setCode] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null); // 인증번호 오류 등
const [requireCode, setRequireCode] = useState<boolean>(showVerification);
const [isCodeSent, setIsCodeSent] = useState<boolean>(showVerification);
const canConfirm = code.trim().length > 0;
const [isVerified, setIsVerified] = useState(false);
const hasError = !!error;
// 외부에서 전달된 개발모드 상태(devVerificationState)에 따라 UI 동기화
useEffect(() => {
if (!devVerificationState) return;
switch (devVerificationState) {
case 'initial':
setRequireCode(false);
setIsCodeSent(false);
setCode("");
setError(null);
setIsVerified(false);
break;
case 'sent':
setRequireCode(true);
setIsCodeSent(true);
setCode("");
setError(null);
setIsVerified(false);
break;
case 'verified':
setRequireCode(true);
setIsCodeSent(true);
setCode("123456");
setError(null);
setIsVerified(true);
break;
case 'failed':
setRequireCode(true);
setIsCodeSent(true);
setCode("");
setError("올바르지 않은 인증번호입니다. 인증번호를 확인해주세요.");
setIsVerified(false);
break;
default:
break;
}
}, [devVerificationState]);
if (!open) return null;
const handleSubmit = () => {
setError(null);
if (!code) {
setError("인증번호를 입력해 주세요.");
return;
if (requireCode) {
if (!code) {
setError("인증번호를 입력해 주세요.");
return;
}
}
if (!newPassword || !confirmPassword) {
setError("새 비밀번호를 입력해 주세요.");
@@ -31,7 +78,7 @@ export default function ChangePasswordModal({ open, onClose, onSubmit }: Props)
setError("새 비밀번호가 일치하지 않습니다.");
return;
}
onSubmit?.({ email, code, newPassword });
onSubmit?.({ email, code: requireCode ? code : undefined, newPassword });
onClose();
};
@@ -49,11 +96,9 @@ export default function ChangePasswordModal({ open, onClose, onSubmit }: Props)
type="button"
aria-label="닫기"
onClick={onClose}
className="inline-flex size-6 items-center justify-center text-[#333c47] hover:opacity-80"
className="inline-flex size-6 items-center justify-center text-[#333c47] hover:opacity-80 cursor-pointer"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="size-[15.2px]">
<path fillRule="evenodd" d="M6.225 4.811a1 1 0 0 1 1.414 0L12 9.172l4.361-4.361a1 1 0 1 1 1.414 1.414L13.414 10.586l4.361 4.361a1 1 0 0 1-1.414 1.414L12 12l-4.361 4.361a1 1 0 1 1-1.414-1.414l4.361-4.361-4.361-4.361a1 1 0 0 1 0-1.414z" clipRule="evenodd" />
</svg>
<ModalCloseSvg />
</button>
</div>
@@ -66,47 +111,75 @@ export default function ChangePasswordModal({ open, onClose, onSubmit }: Props)
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-10 flex-1 rounded-[8px] border border-[#dee1e6] bg-white px-3 text-[16px] leading-[1.5] text-[#333c47] placeholder-[#b1b8c0] outline-none"
className={[
"h-10 flex-1 rounded-[8px] border border-[#dee1e6] px-3 text-[16px] leading-[1.5] text-[#333c47] placeholder-[#b1b8c0] outline-none",
hasError ? "bg-white" : isCodeSent ? "bg-neutral-50" : "bg-white",
].join(" ")}
placeholder="이메일"
/>
<button
type="button"
className="h-10 w-[136px] rounded-[8px] bg-[#f1f3f5] px-3 text-[16px] font-semibold leading-[1.5] text-[#333c47]"
onClick={() => {
setRequireCode(true);
setIsCodeSent(true);
setError(null);
}}
className="h-10 w-[136px] rounded-[8px] bg-[#f1f3f5] px-3 text-[16px] font-semibold leading-[1.5] text-[#333c47] cursor-pointer"
>
{isCodeSent ? "인증번호 재전송" : "인증번호 전송"}
</button>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="w-[100px] text-[15px] font-semibold leading-[1.5] text-[#6c7682]"></div>
<div className="flex items-center gap-3">
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
className="h-10 flex-1 rounded-[8px] border border-[#dee1e6] bg-white px-3 text-[16px] leading-[1.5] text-[#333c47] placeholder-[#b1b8c0] outline-none"
placeholder="인증번호 6자리"
/>
<button
type="button"
className="h-10 w-[136px] rounded-[8px] bg-[#f1f3f5] px-3 text-[16px] font-semibold leading-[1.5] text-[#4c5561]"
>
</button>
{requireCode ? (
<div className="flex flex-col gap-2">
<div className="w-[100px] text-[15px] font-semibold leading-[1.5] text-[#6c7682]"></div>
<div className="flex items-center gap-3">
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
className="h-10 flex-1 rounded-[8px] border border-[#dee1e6] bg-white px-3 text-[16px] leading-[1.5] text-[#333c47] placeholder-[#b1b8c0] outline-none"
placeholder="인증번호를 입력해 주세요."
/>
<button
type="button"
disabled={!canConfirm}
className={[
"h-10 w-[136px] rounded-[8px] px-3 text-[16px] font-semibold leading-[1.5] cursor-pointer disabled:cursor-default",
canConfirm ? "bg-[#f1f3f5] text-[#4c5561]" : "bg-gray-50 text-[#b1b8c0]",
].join(" ")}
>
</button>
</div>
{isCodeSent && !hasError && !isVerified ? (
<div className="px-1">
<p className="text-[13px] font-semibold leading-[1.4] text-[#384fbf]">
.
</p>
<p className="text-[13px] font-semibold leading-[1.4] text-[#384fbf]">
.
</p>
</div>
) : null}
{error ? (
<p className="px-1 text-[13px] font-semibold leading-[1.4] text-[#f64c4c]">
{error}
</p>
) : null}
</div>
{error ? (
<p className="px-1 text-[13px] font-semibold leading-[1.4] text-[#f64c4c]">
{error}
</p>
) : null}
</div>
) : null}
<div className="flex flex-col gap-2">
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682]"> </label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="h-10 rounded-[8px] border border-[#dee1e6] bg-neutral-50 px-3 text-[16px] leading-[1.5] text-[#333c47] placeholder-[#b1b8c0] outline-none"
disabled={!isVerified}
className={[
"h-10 rounded-[8px] border border-[#dee1e6] px-3 text-[16px] leading-[1.5] text-[#333c47] placeholder-[#b1b8c0] outline-none",
isVerified ? "bg-white" : "bg-neutral-50",
].join(" ")}
placeholder="새 비밀번호"
/>
</div>
@@ -118,7 +191,11 @@ export default function ChangePasswordModal({ open, onClose, onSubmit }: Props)
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="h-10 rounded-[8px] border border-[#dee1e6] bg-neutral-50 px-3 text-[16px] leading-[1.5] text-[#333c47] placeholder-[#b1b8c0] outline-none"
disabled={!isVerified}
className={[
"h-10 rounded-[8px] border border-[#dee1e6] px-3 text-[16px] leading-[1.5] text-[#333c47] placeholder-[#b1b8c0] outline-none",
isVerified ? "bg-white" : "bg-neutral-50",
].join(" ")}
placeholder="새 비밀번호 확인"
/>
</div>
@@ -129,14 +206,14 @@ export default function ChangePasswordModal({ open, onClose, onSubmit }: Props)
<button
type="button"
onClick={onClose}
className="h-12 w-[136px] rounded-[10px] bg-[#f1f3f5] px-4 text-[16px] font-semibold leading-[1.5] text-[#4c5561]"
className="h-12 w-[136px] rounded-[10px] bg-[#f1f3f5] px-4 text-[16px] font-semibold leading-[1.5] text-[#4c5561] cursor-pointer"
>
</button>
<button
type="button"
onClick={handleSubmit}
className="h-12 w-[136px] rounded-[10px] bg-[#8598e8] px-4 text-[16px] font-semibold leading-[1.5] text-white"
className="h-12 w-[136px] rounded-[10px] bg-[#8598e8] px-4 text-[16px] font-semibold leading-[1.5] text-white cursor-pointer"
>
</button>

View File

@@ -0,0 +1,56 @@
'use client';
import Link from "next/link";
import ModalCloseSvg from "../svgs/closexsvg";
type Props = {
open: boolean;
onClose: () => void;
};
export default function PasswordChangeDoneModal({ open, onClose }: Props) {
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-[2px]"
role="dialog"
aria-modal="true"
>
<div className="w-[480px] h-[437px] rounded-[12px] border border-[#dee1e6] bg-white shadow-[0_10px_30px_rgba(0,0,0,0.06)] flex flex-col justify-between">
<div className="flex h-[80px] items-center justify-end p-6">
<button
type="button"
aria-label="닫기"
onClick={onClose}
className="inline-flex size-6 items-center justify-center text-[#333c47] hover:opacity-80 cursor-pointer"
>
<ModalCloseSvg />
</button>
</div>
<div className="px-6 pb-0 ">
<div className="w-full mx-auto flex flex-col items-center gap-4">
<h2 className="text-[24px] font-extrabold leading-[1.45] text-[#333c47]">
.
</h2>
<p className="text-[18px] leading-[1.5] text-[#6c7682] text-center">
.
</p>
</div>
</div>
<div className="flex items-center justify-center p-6">
<Link
href="/login"
className="h-12 w-[284px] rounded-[10px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white inline-flex items-center justify-center cursor-pointer"
>
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,151 @@
'use client';
import { useState } from "react";
type VerificationState = 'initial' | 'sent' | 'verified' | 'failed' | 'changed';
type MenuAccountOptionProps = {
verificationState: VerificationState;
setVerificationState: (state: VerificationState) => void;
deleteOpen?: boolean;
setDeleteOpen?: (open: boolean) => void;
};
export default function MenuAccountOption({
verificationState,
setVerificationState,
deleteOpen,
setDeleteOpen,
}: MenuAccountOptionProps) {
const [isOpen, setIsOpen] = useState(false);
const itemClass = (active: boolean) =>
[
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
active ? "bg-blue-600" : "bg-gray-300",
].join(" ");
const knobClass = (active: boolean) =>
[
"inline-block h-5 w-5 transform rounded-full bg-white transition",
active ? "translate-x-5" : "translate-x-1",
].join(" ");
const is = {
initial: verificationState === 'initial',
sent: verificationState === 'sent',
verified: verificationState === 'verified',
failed: verificationState === 'failed',
changed: verificationState === 'changed',
};
return (
<div>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={`fixed bottom-2 right-2 bg-red-400 cursor-pointer rounded-full w-[40px] h-[40px] shadow-xl z-100`}
>
</button>
{isOpen && (
<div className="fixed inset-0 flex items-center justify-center z-50">
<div className="w-[500px] h-[600px] flex bg-white/80 p-10 border rounded-lg relative">
<button
type="button"
aria-label="닫기"
onClick={() => setIsOpen(false)}
className="absolute top-3 right-3 inline-flex items-center justify-center rounded-full w-8 h-8 bg-gray-200 hover:bg-gray-300 text-gray-700"
>
×
</button>
<div className="w-full h-full overflow-auto">
<div className="mb-6">
<p className="text-sm text-gray-700"> : <span className="font-semibold">{verificationState}</span></p>
</div>
<ul className="flex flex-col gap-4">
<li className="flex items-center justify-between">
<p className="mr-4"> </p>
<button
type="button"
aria-label="초기 상태로 설정"
aria-pressed={is.initial}
onClick={() => setVerificationState('initial')}
className={itemClass(is.initial)}
>
<span className={knobClass(is.initial)} />
</button>
</li>
<li className="flex items-center justify-between">
<p className="mr-4"> </p>
<button
type="button"
aria-label="인증번호 전송 상태로 설정"
aria-pressed={is.sent}
onClick={() => setVerificationState('sent')}
className={itemClass(is.sent)}
>
<span className={knobClass(is.sent)} />
</button>
</li>
<li className="flex items-center justify-between">
<p className="mr-4"> </p>
<button
type="button"
aria-label="인증 완료 상태로 설정"
aria-pressed={is.verified}
onClick={() => setVerificationState('verified')}
className={itemClass(is.verified)}
>
<span className={knobClass(is.verified)} />
</button>
</li>
<li className="flex items-center justify-between">
<p className="mr-4"> </p>
<button
type="button"
aria-label="인증 실패 상태로 설정"
aria-pressed={is.failed}
onClick={() => setVerificationState('failed')}
className={itemClass(is.failed)}
>
<span className={knobClass(is.failed)} />
</button>
</li>
</ul>
<div className="mt-6 border-t pt-6">
<li className="flex items-center justify-between">
<p className="mr-4"> </p>
<button
type="button"
aria-label="비밀번호 변경 완료 상태로 설정"
aria-pressed={is.changed}
onClick={() =>
setVerificationState(is.changed ? 'initial' : 'changed')
}
className={itemClass(is.changed)}
>
<span className={knobClass(is.changed)} />
</button>
</li>
<li className="mt-4 flex items-center justify-between">
<p className="mr-4"> </p>
<button
type="button"
aria-label="회원 탈퇴 모달 토글"
aria-pressed={!!deleteOpen}
onClick={() => setDeleteOpen?.(!deleteOpen)}
className={itemClass(!!deleteOpen)}
>
<span className={knobClass(!!deleteOpen)} />
</button>
</li>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,10 +1,23 @@
'use client';
import { useState } from "react";
import { useEffect, useState } from "react";
import ChangePasswordModal from "../ChangePasswordModal";
import PasswordChangeDoneModal from "../PasswordChangeDoneModal";
import AccountDeleteModal from "../AccountDeleteModal";
import MenuAccountOption from "@/app/menu/account/MenuAccountOption";
type VerificationState = 'initial' | 'sent' | 'verified' | 'failed' | 'changed';
export default function AccountPage() {
const [open, setOpen] = useState(false);
const [verificationState, setVerificationState] = useState<VerificationState>('initial');
const [doneOpen, setDoneOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
// 개발 옵션에서 'changed'로 전환하면 완료 모달 표시
useEffect(() => {
setDoneOpen(verificationState === 'changed');
}, [verificationState]);
return (
<main className="flex w-full flex-col">
@@ -40,7 +53,11 @@ export default function AccountPage() {
</div>
</div>
<div className="mt-6">
<button className="text-[15px] font-medium leading-[1.5] text-[#f64c4c] underline">
<button
type="button"
onClick={() => setDeleteOpen(true)}
className="text-[15px] font-medium leading-[1.5] text-[#f64c4c] underline cursor-pointer"
>
</button>
</div>
@@ -52,6 +69,28 @@ export default function AccountPage() {
onSubmit={() => {
// TODO: integrate API
}}
devVerificationState={verificationState}
/>
<MenuAccountOption
verificationState={verificationState}
setVerificationState={setVerificationState}
deleteOpen={deleteOpen}
setDeleteOpen={setDeleteOpen}
/>
<PasswordChangeDoneModal
open={doneOpen}
onClose={() => setDoneOpen(false)}
/>
<AccountDeleteModal
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
onConfirm={() => {
// TODO: 탈퇴 API 연동
setDeleteOpen(false);
}}
/>
</main>
);

View File

@@ -0,0 +1,131 @@
'use client';
import Image from "next/image";
import { useState } from "react";
type Lesson = {
id: string;
title: string;
durationMin: number;
progressPct: number; // 0~100
isCompleted: boolean;
};
type Course = {
id: string;
title: string;
description: string;
thumbnail: string;
status: "전체" | "수강 예정" | "수강중" | "수강 완료";
progressPct: number;
lessons: Lesson[];
};
function ProgressBar({ value }: { value: number }) {
const pct = Math.max(0, Math.min(100, value));
return (
<div className="relative h-2 w-full overflow-hidden rounded-full bg-[#ecf0ff]">
<div
className="h-full rounded-full bg-[#384fbf] transition-[width] duration-300 ease-out"
style={{ width: `${pct}%` }}
/>
</div>
);
}
export default function CourseCard({ course }: { course: Course }) {
const [open, setOpen] = useState(false);
return (
<article className="rounded-xl border border-[#ecf0ff] bg-white shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<header className="flex items-center gap-4 p-4">
<div className="relative h-[76px] w-[120px] overflow-hidden rounded-md bg-[#f1f3f5]">
<Image src={course.thumbnail} alt="" fill sizes="120px" className="object-cover" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="rounded bg-[#e5f5ec] px-2 py-0.5 text-[12px] font-semibold leading-[1.4] text-[#0c9d61]">
{course.status}
</span>
<h2 className="truncate text-[18px] font-bold leading-[1.5] text-[#1b2027]">
{course.title}
</h2>
</div>
<p className="mt-1 line-clamp-2 text-[14px] leading-[1.5] text-[#4c5561]">{course.description}</p>
<div className="mt-2 flex items-center gap-3">
<ProgressBar value={course.progressPct} />
<span className="w-[72px] text-right text-[13px] font-medium leading-[1.4] text-[#6c7682]">
{course.progressPct}%
</span>
</div>
</div>
<div className="flex items-center gap-2 self-start">
<button
type="button"
className="rounded-md border border-[#dee1e6] px-3 py-2 text-[13px] font-semibold leading-[1.4] text-[#333c47] hover:bg-[#f9fafb]"
>
</button>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="flex h-8 w-8 items-center justify-center rounded-full border border-[#dee1e6] text-[#6c7682] hover:bg-[#f9fafb]"
aria-label={open ? "접기" : "펼치기"}
>
<span
className={["inline-block transition-transform", open ? "rotate-180" : "rotate-0"].join(" ")}
>
</span>
</button>
</div>
</header>
{open ? (
<div className="px-4 pb-4">
<ul className="flex flex-col gap-2">
{course.lessons.map((lesson, idx) => (
<li key={lesson.id} className="rounded-lg border border-[#ecf0ff]">
<div className="flex items-center justify-between gap-4 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
<span className="w-[20px] text-[13px] font-semibold leading-[1.4] text-[#8c95a1]">
{idx + 1}.
</span>
<p className="truncate text-[14px] font-medium leading-[1.5] text-[#333c47]">
{lesson.title}
</p>
</div>
<div className="mt-2 flex items-center gap-3">
<ProgressBar value={lesson.progressPct} />
<span className="w-[80px] text-right text-[13px] leading-[1.4] text-[#8c95a1]">
{lesson.progressPct}% · {lesson.durationMin}
</span>
</div>
</div>
<div className="flex items-center gap-2">
{lesson.isCompleted ? (
<span className="rounded bg-[#e5f5ec] px-2 py-1 text-[12px] font-semibold leading-[1.4] text-[#0c9d61]">
</span>
) : (
<button
type="button"
className="rounded-md border border-[#dee1e6] px-3 py-2 text-[13px] font-semibold leading-[1.4] text-[#333c47] hover:bg-[#f9fafb]"
>
</button>
)}
</div>
</div>
</li>
))}
</ul>
</div>
) : null}
</article>
);
}

View File

@@ -0,0 +1,61 @@
'use client';
import Image from "next/image";
export type CourseGridItemProps = {
id: string;
thumbnail: string;
title: string;
isNew?: boolean;
category?: string; // ex) 안전, 공정 등
meta?: string; // ex) "VOD · 총 6강 · 4시간 20분"
};
export default function CourseGridItem({
id,
thumbnail,
title,
isNew,
category,
meta,
}: CourseGridItemProps) {
return (
<li
key={id}
className="group flex w-full flex-col gap-2"
>
<div className="relative aspect-[16/10] w-full overflow-hidden rounded-lg border border-[#ecf0ff] bg-[#f9fafb]">
<Image
src={thumbnail}
alt=""
fill
sizes="(max-width: 768px) 50vw, (max-width: 1200px) 25vw, 240px"
className="object-cover transition-transform duration-300 group-hover:scale-[1.02]"
priority={false}
/>
</div>
<div className="flex flex-col">
<div className="flex items-center gap-2">
{category ? (
<span className="rounded bg-[#e5f5ec] px-1.5 py-0.5 text-[12px] font-semibold leading-[1.4] text-[#0c9d61]">
{category}
</span>
) : null}
{isNew ? (
<span className="rounded bg-[#ecf0ff] px-1.5 py-0.5 text-[12px] font-semibold leading-[1.4] text-[#1f2b91]">
NEW
</span>
) : null}
</div>
<h3 className="mt-1 line-clamp-1 text-[14px] font-semibold leading-[1.5] text-[#333c47]">
{title}
</h3>
{meta ? (
<p className="mt-0.5 text-[12px] leading-[1.4] text-[#8c95a1]">{meta}</p>
) : null}
</div>
</li>
);
}

View File

@@ -1,14 +1,179 @@
'use client';
import { useMemo, useState } from "react";
import CourseCard from "./CourseCard";
type CourseStatus = "전체" | "수강 예정" | "수강중" | "수강 완료";
type Lesson = {
id: string;
title: string;
durationMin: number;
progressPct: number; // 0~100
isCompleted: boolean;
};
type Course = {
id: string;
title: string;
description: string;
thumbnail: string;
status: CourseStatus;
progressPct: number;
lessons: Lesson[];
};
const MOCK_COURSES: Course[] = [
{
id: "c1",
title: "원자재 출입 전 계동",
description:
"원자재 이송/보관 기준을 기반으로 가이드하며, 일련의 단계 별 강의에서 세부적인 작업 지침을 다룹니다.",
thumbnail: "/imgs/talk.png",
status: "수강중",
progressPct: 80,
lessons: [
{ id: "c1l1", title: "1. 운반과 기준 및 방출 절차", durationMin: 12, progressPct: 100, isCompleted: true },
{ id: "c1l2", title: "2. 라벨링 원칙과 현장 케어", durationMin: 9, progressPct: 100, isCompleted: true },
{ id: "c1l3", title: "3. 배치/현황 기록 및 문서 관리", durationMin: 15, progressPct: 60, isCompleted: false },
{ id: "c1l4", title: "4. 보관 적재 기준 점검", durationMin: 8, progressPct: 0, isCompleted: false },
{ id: "c1l5", title: "5. 입고 검사 방법 (AQL) 및 유의점", durationMin: 11, progressPct: 0, isCompleted: false },
{ id: "c1l6", title: "6. 장비 사용, 손질 및 일지 필기", durationMin: 13, progressPct: 0, isCompleted: false },
],
},
{
id: "c2",
title: "학점과 평가",
description:
"학점과 평가 항목 기준을 가이드하며, 일과 관련된 기본 평가 체계와 피드백 처리 방법에 대해 배웁니다.",
thumbnail: "/imgs/talk.png",
status: "수강 완료",
progressPct: 100,
lessons: [
{ id: "c2l1", title: "평가 기준 이해", durationMin: 10, progressPct: 100, isCompleted: true },
],
},
{
id: "c3",
title: "부서간 연협",
description:
"부서간 협업 절차와 기록 기준을 가이드하며, 일련 단계 별 협업에서 생길 수 있는 리스크 관리법을 다룹니다.",
thumbnail: "/imgs/talk.png",
status: "수강중",
progressPct: 60,
lessons: [
{ id: "c3l1", title: "의사소통 원칙", durationMin: 9, progressPct: 100, isCompleted: true },
{ id: "c3l2", title: "문서 공유와 승인", durationMin: 14, progressPct: 30, isCompleted: false },
],
},
{
id: "c4",
title: "작업별 방사선의 이해",
description:
"작업별 방사선 안전 기준을 가이드하며, 일과 관련된 위험과 보호 장비 선택법을 배웁니다.",
thumbnail: "/imgs/talk.png",
status: "수강 예정",
progressPct: 0,
lessons: [
{ id: "c4l1", title: "기초 이론", durationMin: 12, progressPct: 0, isCompleted: false },
],
},
];
const TABS: CourseStatus[] = ["전체", "수강 예정", "수강중", "수강 완료"];
export default function CoursesPage() {
return (
<main className="flex w-full flex-col">
<div className="flex h-[100px] items-center px-8">
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]"> </h1>
</div>
<div className="px-8 pb-20">
<p className="text-[16px] leading-[1.5] text-[#4c5561]"> .</p>
</div>
</main>
);
const [activeTab, setActiveTab] = useState<CourseStatus>("전체");
const countsByStatus = useMemo(() => {
return {
전체: MOCK_COURSES.length,
"수강 예정": MOCK_COURSES.filter((c) => c.status === "수강 예정").length,
수강중: MOCK_COURSES.filter((c) => c.status === "수강중").length,
"수강 완료": MOCK_COURSES.filter((c) => c.status === "수강 완료").length,
};
}, []);
const filtered = useMemo(() => {
if (activeTab === "전체") return MOCK_COURSES;
return MOCK_COURSES.filter((c) => c.status === activeTab);
}, [activeTab]);
return (
<main className="flex w-full flex-col">
<div className="flex h-[100px] items-center px-8">
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]"> </h1>
</div>
<div className="px-8">
<div className="border-b border-[#ecf0ff]">
<ul className="flex gap-6">
{TABS.map((tab) => {
const isActive = activeTab === tab;
return (
<li key={tab}>
<button
type="button"
onClick={() => setActiveTab(tab)}
className={[
"relative -mb-px h-12 px-1 text-[14px] leading-[1.5]",
isActive ? "font-semibold text-[#1f2b91]" : "font-medium text-[#6c7682]",
].join(" ")}
>
{tab}{" "}
<span className="ml-1 text-[#8c95a1]">
{countsByStatus[tab as keyof typeof countsByStatus] ?? 0}
</span>
{isActive ? (
<span className="absolute inset-x-0 -bottom-[1px] h-[2px] bg-[#1f2b91]" />
) : null}
</button>
</li>
);
})}
</ul>
</div>
</div>
<div className="px-8 pb-16 pt-6">
<div className="flex flex-col gap-4">
{filtered.map((course) => (
<CourseCard key={course.id} course={course} />
))}
</div>
{/* pagination */}
<div className="mt-10 flex items-center justify-center gap-2">
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full text-[#8c95a1] hover:bg-[#f1f3f5]"
aria-label="이전 페이지"
>
&#x2039;
</button>
{[1, 2, 3].map((p) => (
<button
key={p}
type="button"
className={[
"flex h-8 w-8 items-center justify-center rounded-full text-[13px] leading-[1.4]",
p === 2 ? "bg-[#1f2b91] text-white" : "text-[#4c5561] hover:bg-[#f1f3f5]",
].join(" ")}
>
{p}
</button>
))}
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full text-[#8c95a1] hover:bg-[#f1f3f5]"
aria-label="다음 페이지"
>
&#x203A;
</button>
</div>
</div>
</main>
);
}

View File

@@ -3,7 +3,7 @@ import MenuSidebar from "./MenuSidebar";
export default function MenuLayout({ children }: { children: ReactNode }) {
return (
<div className="mx-auto flex w-full max-w-[1440px]">
<div className="mx-auto flex w-full max-w-[1440px] min-h-full">
<aside className="w-[320px] border-r border-[#dee1e6] px-4 py-6">
<MenuSidebar />
</aside>

View File

@@ -1,12 +1,118 @@
type ResultRow = {
programTitle: string;
courseTitle: string;
completedAt: string;
score: string;
instructor: string;
feedbackLink?: string;
certificateLink?: string;
};
const mockResults: ResultRow[] = [
{
programTitle: "XR 안전 기본 과정",
courseTitle: "{강좌명}",
completedAt: "2025-09-10",
score: "-",
instructor: "이공필",
},
{
programTitle: "건설 현장 안전 실무",
courseTitle: "{강좌명}",
completedAt: "2025-09-10",
score: "70 / 100",
instructor: "이공필",
feedbackLink: "#",
certificateLink: "#",
},
{
programTitle: "전기 설비 위험성 평가",
courseTitle: "{강좌명}",
completedAt: "2025-09-10",
score: "70 / 100",
instructor: "이공필",
feedbackLink: "#",
certificateLink: "#",
},
];
export default function ResultsPage() {
return (
<main className="flex w-full flex-col">
<div className="flex h-[100px] items-center px-8">
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]"> </h1>
</div>
<div className="px-8 pb-20">
<p className="text-[16px] leading-[1.5] text-[#4c5561]"> .</p>
</div>
<section className="px-8 pb-20">
<div className="rounded-[8px]">
<div className="w-full overflow-x-auto rounded-[8px] border border-[#dee1e6]">
<table className="min-w-full border-collapse">
<colgroup>
<col />
<col />
<col style={{ width: 140 }} />
<col style={{ width: 140 }} />
<col style={{ width: 140 }} />
<col style={{ width: 76 }} />
<col style={{ width: 76 }} />
</colgroup>
<thead>
<tr className="h-12 bg-gray-50 text-left">
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"> </th>
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></th>
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"> </th>
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"> ()</th>
<th className="border-r border-[#dee1e6] px-4 text-[14px] font-semibold leading-[1.5] text-[#4c5561]"> </th>
<th className="border-r border-[#dee1e6] px-4 text-center text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></th>
<th className="px-4 text-center text-[14px] font-semibold leading-[1.5] text-[#4c5561]"></th>
</tr>
</thead>
<tbody>
{mockResults.map((row, idx) => (
<tr
key={`${row.programTitle}-${idx}`}
className={idx === 1 ? "h-12 bg-[rgba(236,240,255,0.5)]" : "h-12"}
>
<td className="border-t border-r border-[#dee1e6] px-4 text-[15px] leading-[1.5] text-[#1b2027]">
{row.programTitle}
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-[15px] leading-[1.5] text-[#1b2027]">
{row.courseTitle}
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-[15px] leading-[1.5] text-[#1b2027]">
{row.completedAt}
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-[15px] leading-[1.5] text-[#1b2027]">
{row.score}
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-[15px] leading-[1.5] text-[#1b2027]">
{row.instructor}
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-center text-[15px] leading-[1.5] text-[#1b2027]">
{row.feedbackLink ? (
<a href={row.feedbackLink} className="text-[12px] text-blue-500 underline underline-offset-[3px]">
</a>
) : (
"-"
)}
</td>
<td className="border-t border-[#dee1e6] px-4 text-center text-[15px] leading-[1.5] text-[#1b2027]">
{row.certificateLink ? (
<a href={row.certificateLink} className="text-[12px] text-blue-500 underline underline-offset-[3px]">
</a>
) : (
"-"
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
</main>
);
}

View File

@@ -3,10 +3,12 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import MainLogoSvg from './svgs/mainlogosvg';
import ChevronDownSvg from './svgs/chevrondownsvg';
export default function Home() {
const containerRef = useRef<HTMLDivElement | null>(null);
const [currentIndex, setCurrentIndex] = useState(0);
const [isNameActive, setIsNameActive] = useState(false);
// 코스, 공지사항 더미 데이터
const courseCards = useMemo(
@@ -16,71 +18,61 @@ export default function Home() {
id: 'c1',
title: '원자력 운영 기초',
meta: 'VOD • 초급 • 4시간 20분',
image:
'https://images.unsplash.com/photo-1555949963-ff9fe0c870eb?q=80&w=1200&auto=format&fit=crop',
image: 'https://picsum.photos/seed/xrlms-c1/1200/800',
},
{
id: 'c2',
title: '반도체',
meta: 'VOD • 중급 • 3시간 10분',
image:
'https://images.unsplash.com/photo-1581092921461-eab62e97a780?q=80&w=1200&auto=format&fit=crop',
image: 'https://picsum.photos/seed/xrlms-c2/1200/800',
},
{
id: 'c3',
title: '방사선 안전',
meta: 'VOD • 중급 • 4시간 20분',
image:
'https://images.unsplash.com/photo-1581090464777-f3220bbe1b8b?q=80&w=1200&auto=format&fit=crop',
image: 'https://picsum.photos/seed/xrlms-c3/1200/800',
},
{
id: 'c4',
title: '방사선 폐기물',
meta: 'VOD • 중급 • 4시간 20분',
image:
'https://images.unsplash.com/photo-1581091220351-5a6a4e6f22c1?q=80&w=1200&auto=format&fit=crop',
image: 'https://picsum.photos/seed/xrlms-c4/1200/800',
},
{
id: 'c5',
title: '원자력 운전 개론',
meta: 'VOD • 초급 • 3시간 00분',
image:
'https://images.unsplash.com/photo-1581090463520-5d09f3c456d2?q=80&w=1200&auto=format&fit=crop',
image: 'https://picsum.photos/seed/xrlms-c5/1200/800',
},
{
id: 'c6',
title: '안전 표지와 표준',
meta: 'VOD • 초급 • 2시간 40분',
image:
'https://images.unsplash.com/photo-1470167290877-7d5d3446de4c?q=80&w=1200&auto=format&fit=crop',
image: 'https://picsum.photos/seed/xrlms-c6/1200/800',
},
{
id: 'c7',
title: '발전소 운영',
meta: 'VOD • 중급 • 4시간 20분',
image:
'https://images.unsplash.com/photo-1581092160562-40aa08e78837?q=80&w=1200&auto=format&fit=crop',
image: 'https://picsum.photos/seed/xrlms-c7/1200/800',
},
{
id: 'c8',
title: '방사선 안전 실습',
meta: 'VOD • 중급 • 3시간 30분',
image:
'https://images.unsplash.com/photo-1581093458791-9d181f5842fd?q=80&w=1200&auto=format&fit=crop',
image: 'https://picsum.photos/seed/xrlms-c8/1200/800',
},
{
id: 'c9',
title: '실험실 안전',
meta: 'VOD • 초급 • 2시간 10분',
image:
'https://images.unsplash.com/photo-1559757175-08c6d5b3f4b4?q=80&w=1200&auto=format&fit=crop',
image: 'https://picsum.photos/seed/xrlms-c9/1200/800',
},
{
id: 'c10',
title: '기초 장비 운용',
meta: 'VOD • 초급 • 2시간 50분',
image:
'https://images.unsplash.com/photo-1581092338398-16e5b28a2b13?q=80&w=1200&auto=format&fit=crop',
image: 'https://picsum.photos/seed/xrlms-c10/1200/800',
},
] as Array<{ id: string; title: string; meta: string; image: string }>,
[]
@@ -104,22 +96,19 @@ export default function Home() {
id: 1,
title: '시스템 점검 안내',
description: '11월 10일 새벽 2시~4시 시스템 점검이 진행됩니다.',
imageSrc:
'https://images.unsplash.com/photo-1581091226825-a6a2a5aee158?q=80&w=1600&auto=format&fit=crop',
imageSrc: 'https://picsum.photos/seed/xrlms-slide1/1600/900',
},
{
id: 2,
title: '신규 과정 오픈',
description: '최신 커리큘럼으로 업스킬링하세요.',
imageSrc:
'https://images.unsplash.com/photo-1550602921-a0d9a4d5b1a9?q=80&w=1600&auto=format&fit=crop',
imageSrc: 'https://picsum.photos/seed/xrlms-slide2/1600/900',
},
{
id: 3,
title: '수강 이벤트',
description: '이번 달 수강 혜택을 확인해보세요.',
imageSrc:
'https://images.unsplash.com/photo-1545235617-9465d2a55698?q=80&w=1600&auto=format&fit=crop',
imageSrc: 'https://picsum.photos/seed/xrlms-slide3/1600/900',
},
],
[]
@@ -152,29 +141,41 @@ export default function Home() {
const handlePrev = () => scrollToIndex(currentIndex - 1);
const handleNext = () => scrollToIndex(currentIndex + 1);
const handleNameClick = () => {
setIsNameActive((prev) => !prev);
};
return (
<div className="w-full min-h-screen flex flex-col bg-white">
<main className="flex-1">
{/* 메인 컨테이너 */}
<div className="w-full flex justify-center">
<div className="w-full max-w-[1180px] px-3 py-6">
<div className="w-full max-w-[1376px] px-3 py-6">
{/* 배너 + 사이드 */}
<div className="flex gap-6">
<div className="flex gap-8">
{/* 배너 */}
<section className="flex-1" aria-label="홈 상단 배너">
<section className="flex-none w-[944px]" aria-label="홈 상단 배너">
<div className="relative">
<div
ref={containerRef}
className="flex overflow-x-auto snap-x snap-mandatory scroll-smooth rounded-xl bg-[#F1F3F5]"
className="flex overflow-x-hidden overscroll-none overflow-y-hidden overscroll-y-none snap-x snap-mandatory scroll-smooth rounded-[12px] bg-[#F1F3F5]"
>
{slides.map((slide) => (
<div key={slide.id} className="flex-none w-full h-[360px] relative snap-start overflow-hidden">
<img alt={slide.title} src={slide.imageSrc} className="w-full h-full object-cover block" />
<div className="absolute left-0 right-0 bottom-0 h-1/2 bg-linear-to-b from-transparent via-black/55 to-black/75" />
<div className="absolute left-6 bottom-6 text-white">
<div className="font-bold text-[20px] leading-normal mb-1.5">{slide.title}</div>
<div className="font-medium text-[14px] leading-normal opacity-95">
<div key={slide.id} className="flex-none w-full h-[510px] relative snap-start overflow-hidden">
<img
alt={slide.title}
src={slide.imageSrc}
className="w-full h-full object-cover block"
onError={(e) => {
const t = e.currentTarget as HTMLImageElement;
if (!t.src.includes('picsum.photos')) t.src = 'https://picsum.photos/seed/xrlms-fallback-slide/1600/900';
}}
/>
<div className="absolute left-0 right-0 bottom-0 h-[198px] bg-linear-to-b from-transparent to-black/50" />
<div className="absolute left-12 bottom-8 text-white">
<div className="font-bold text-[32px] tracking-[-0.32px] leading-normal mb-1.5">{slide.title}</div>
<div className="font-bold text-[24px] tracking-[-0.24px] leading-normal opacity-95">
{slide.description}
</div>
</div>
@@ -188,27 +189,28 @@ export default function Home() {
onClick={handlePrev}
aria-label="이전 배너"
disabled={currentIndex <= 0}
className="absolute left-2 top-1/2 -translate-y-1/2 w-9 h-9 rounded-full bg-white/90 text-[#1B2027] border border-[#DEE1E6] flex items-center justify-center cursor-pointer shadow disabled:opacity-60"
className="absolute left-4 top-1/2 -translate-y-1/2 size-12 rounded-full bg-white/20 text-white flex items-center justify-center cursor-pointer disabled:opacity-60"
>
<svg width="24" height="24" viewBox="0 0 24 24" aria-hidden>
<path d="M15 6l-6 6 6 6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button
type="button"
onClick={handleNext}
aria-label="다음 배너"
disabled={currentIndex >= slides.length - 1}
className="absolute right-2 top-1/2 -translate-y-1/2 w-9 h-9 rounded-full bg-white/90 text-[#1B2027] border border-[#DEE1E6] flex items-center justify-center cursor-pointer shadow disabled:opacity-60"
className="absolute right-4 top-1/2 -translate-y-1/2 size-12 rounded-full bg-white/20 text-white flex items-center justify-center cursor-pointer disabled:opacity-60"
>
<svg width="24" height="24" viewBox="0 0 24 24" aria-hidden>
<path d="M9 6l6 6-6 6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{/* 인디케이터 */}
<div className="absolute left-1/2 bottom-3 -translate-x-1/2 flex gap-1.5" aria-hidden>
<div className="absolute left-1/2 bottom-3 -translate-x-1/2 flex items-center gap-2" aria-hidden>
{slides.map((_, idx) => (
<span
key={idx}
className={'w-1.5 h-1.5 rounded-full ' + (idx === currentIndex ? 'bg-white' : 'bg-white/50')}
/>
<span key={idx} className={idx === currentIndex ? 'h-2 w-8 rounded-full bg-white' : 'size-2 rounded-full bg-white/50'} />
))}
</div>
</div>
@@ -217,12 +219,24 @@ export default function Home() {
{/* 사이드 패널 (피그마 디자인 적용) */}
<aside className="flex-none w-[400px]">
<div className="bg-[#F1F3F5] rounded-[12px] overflow-hidden h-[510px]">
{/* 상단 환영 및 통계 */}
{/* 상단 환영 및 통계 (피그마 사이즈/간격 적용) */}
<div className="px-8 py-8">
<div className="mb-6">
<div className="text-[18px] leading-normal">
<span className="font-bold text-[#333C47]"></span>
<span className="font-normal text-[#333C47]"> .</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleNameClick}
aria-expanded={isNameActive}
className="m-0 p-0 bg-transparent border-0 text-[18px] font-bold leading-normal text-[#333C47] cursor-pointer inline-flex items-center gap-1"
>
<ChevronDownSvg
width={20}
height={20}
className={'transition-transform duration-200 ' + (isNameActive ? 'rotate-180' : 'rotate-0')}
/>
</button>
<span className="text-[18px] font-bold leading-normal text-[#333C47]">.</span>
</div>
</div>
<div className="flex items-center justify-center gap-6">
@@ -230,7 +244,7 @@ export default function Home() {
{ label: '수강중', value: 0 },
{ label: '수강 완료', value: 0 },
{ label: '문제 제출', value: 0 },
{ label: '수료증 발급', value: 0 },
{ label: '수료증', value: 0 },
].map((s) => (
<div key={s.label} className="w-[64px] flex flex-col items-center justify-center gap-2">
<div className="size-16 rounded-full bg-white flex items-center justify-center">
@@ -241,10 +255,10 @@ export default function Home() {
))}
</div>
</div>
{/* 구분선 */}
{/* 구분선 (좌우 32px 여백, 1px 라인) */}
<div className="h-px bg-[#DEE1E6] mx-8" />
{/* 최근 수강 내역 */}
<div className="px-8 pt-3 pb-6">
{/* 최근 수강 내역 헤더 + 빈 상태 영역 (피그마 텍스트/간격 일치) */}
<div className="px-8 pt-[12px] pb-[24px]">
<div className="h-[60px] w-full flex items-center justify-between">
<div className="text-[18px] font-bold text-[#1B2027]"> </div>
<a href="#" className="flex items-center gap-0.5 text-[14px] font-medium text-[#6C7682] no-underline">
@@ -254,19 +268,8 @@ export default function Home() {
</svg>
</a>
</div>
<div className="flex flex-col gap-2">
{[
{ cat: '원자로 운전 및 계통', title: '6. 원자로 시동, 운전 및 정지 절차' },
{ cat: '원자로 운전 및 계통', title: '6. 원자로 시동, 운전 및 정지 절차' },
{ cat: '원자로 운전 및 계통', title: '6. 원자로 시동, 운전 및 정지 절차' },
].map((r, i) => (
<div key={i} className="w-full rounded-[8px] bg-white px-5 py-3">
<div className="text-[14px] font-semibold leading-normal">
<p className="text-[#6C7682]">{r.cat}</p>
<p className="text-[#333C47] whitespace-pre">{r.title}</p>
</div>
</div>
))}
<div className="box-border w-full flex items-center justify-center px-[45px] py-[96px]">
<p className="m-0 text-[16px] font-medium text-[#6C7682] text-center"> .</p>
</div>
</div>
</div>
@@ -275,29 +278,60 @@ export default function Home() {
{/* 교육 과정 */}
<section className="mt-8">
<div className="flex items-center justify-between mb-3">
<div className="flex items-baseline gap-2">
<h2 className="m-0 text-[18px] font-bold text-[#333C47]">
</h2>
<span className="text-[#8C95A1] text-[13px]"> 28</span>
<div className="flex items-center justify-between h-[100px] px-0">
<div className="flex items-center gap-3">
<h2 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]"> </h2>
<div className="text-[15px] font-medium leading-normal text-[#333C47]">
<span className="text-[#384FBF]">28</span>
</div>
</div>
<a href="#" className="text-[#8C95A1] text-[13px] no-underline">
<a
href="#"
className="flex items-center gap-0.5 text-[14px] font-medium leading-normal text-[#6C7682] no-underline"
>
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden>
<path
d="M8 5l8 7-8 7"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
</div>
<div className="grid grid-cols-5 gap-4">
<div className="grid grid-cols-5 gap-8">
{courseCards.map((c) => (
<article key={c.id} className="bg-white border border-[#DEE1E6] rounded-[10px] overflow-hidden">
<div className="w-full h-[120px] overflow-hidden">
<img alt={c.title} src={c.image} className="w-full h-full object-cover" />
<article key={c.id} className="flex flex-col gap-4 h-[260px]">
<div className="h-[166.4px] overflow-hidden rounded-[8px]">
<img
alt={c.title}
src={c.image}
className="w-full h-full object-cover block"
onError={(e) => {
const t = e.currentTarget as HTMLImageElement;
if (!t.src.includes('picsum.photos')) t.src = 'https://picsum.photos/seed/xrlms-fallback-card/1200/800';
}}
/>
</div>
<div className="p-2.5">
<div className="text-[#333C47] font-semibold text-[14px] leading-[1.35] mb-1.5" title={c.title}>
<div className="flex flex-col gap-1">
{c.id === 'c1' && (
<span className="inline-flex h-[20px] items-center justify-center px-1 bg-[#E5F5EC] rounded-[4px] text-[13px] font-semibold leading-[1.4] text-[#0C9D61]">
</span>
)}
<h5 className="m-0 text-[#333C47] font-semibold text-[18px] leading-normal truncate" title={c.title}>
{c.title}
</h5>
<div className="flex items-center gap-1">
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden className="text-[#8C95A1]">
<path d="M8 5v14l11-7z" fill="currentColor" />
</svg>
<p className="m-0 text-[#8C95A1] text-[13px] font-medium leading-[1.4]">{c.meta}</p>
</div>
<div className="text-[#8C95A1] text-[12px]">{c.meta}</div>
</div>
</article>
))}
@@ -306,43 +340,56 @@ export default function Home() {
{/* 공지사항 */}
<section className="mt-9">
<div className="flex items-center justify-between mb-3">
<div className="flex items-baseline gap-2">
<h2 className="m-0 text-[16px] font-bold text-[#333C47]">
</h2>
<span className="text-[#8C95A1] text-[13px]"> 102</span>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<h2 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]"></h2>
<div className="text-[15px] font-medium leading-normal text-[#333C47]">
<span className="text-[#384FBF]">{noticeRows.length}</span>
</div>
</div>
<a href="#" className="text-[#8C95A1] text-[13px] no-underline">
<a
href="#"
className="flex items-center gap-0.5 text-[14px] font-medium leading-normal text-[#6C7682] no-underline"
>
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden>
<path
d="M8 5l8 7-8 7"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
</div>
<div className="bg-white border border-[#DEE1E6] rounded-[10px] overflow-hidden">
<div className="grid grid-cols-[80px_1fr_140px_100px_90px] gap-0 bg-[#F9FAFB] text-[#6C7682] text-[13px] border-b border-[#DEE1E6]">
{['번호', '제목', '작성일', '조회수', '작성자'].map((h) => (
<div key={h} className="py-2.5 px-3">
{h}
</div>
))}
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
<div className="grid grid-cols-[57px_1fr_140px_140px_140px] bg-[#F9FAFB] h-[48px] text-[14px] font-semibold text-[#4C5561] border-b border-[#DEE1E6]">
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]"></div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]"></div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]"></div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]"></div>
<div className="flex items-center px-4"></div>
</div>
<div>
{noticeRows.map((r, idx) => (
{noticeRows.map((r) => (
<div
key={r.id}
className={
'grid grid-cols-[80px_1fr_140px_100px_90px] gap-0 text-[14px] text-[#4C5561] ' +
(idx === noticeRows.length - 1 ? '' : 'border-b border-[#F1F3F5]')
}
className="grid grid-cols-[57px_1fr_140px_140px_140px] h-[48px] text-[15px] font-medium text-[#1B2027] border-t border-[#DEE1E6]"
>
<div className="py-3 px-3">{r.id}</div>
<div className="py-3 px-3 whitespace-nowrap overflow-hidden text-ellipsis" title={r.title}>
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">{r.id}</div>
<div
className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis"
title={r.title}
>
{r.title}
</div>
<div className="py-3 px-3">{r.date}</div>
<div className="py-3 px-3">{r.views}</div>
<div className="py-3 px-3">{r.writer}</div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]">{r.date}</div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]">{r.views.toLocaleString()}</div>
<div className="flex items-center px-4">{r.writer}</div>
</div>
))}
</div>
@@ -352,28 +399,7 @@ export default function Home() {
</div>
</main>
{/* 푸터 */}
<footer className="mt-10 bg-[#F1F3F5] border-t border-[#DEE1E6]">
<div className="max-w-[1180px] mx-auto px-3 pt-6 pb-10 text-[#4C5561]">
<a href="/" aria-label="XR LMS 홈" className="flex items-center gap-3 no-underline">
<MainLogoSvg width={46.703} height={36} />
<strong className="text-[#333C47]">XL LMS</strong>
</a>
<div className="flex flex-wrap gap-4 mt-3 text-[13px]">
<a href="#" className="text-[#4C5561] no-underline"> </a>
<a href="#" className="text-[#4C5561] no-underline"></a>
<a href="#" className="text-[#4C5561] no-underline"></a>
</div>
<div className="mt-3 text-[12px] text-[#8C95A1]">
(12345) 123-12() | 전화: 1234-1234 ( 09:00 ~ 18:00) | 이메일: xper1234@xpl.co.kr
</div>
<div className="mt-2 text-[12px] text-[#8C95A1]">
Copyright © 2025 XL LMS. All rights reserved
</div>
</div>
</footer>
{/* 전역 Footer는 layout.tsx에서 렌더링됩니다. */}
</div>
);
}

153
src/app/resources/page.tsx Normal file
View File

@@ -0,0 +1,153 @@
'use client';
type ResourceRow = {
id: number;
title: string;
date: string;
views: number;
writer: string;
hasAttachment?: boolean;
};
const rows: ResourceRow[] = [
{
id: 6,
title: '방사선과 물질의 상호작용 관련 학습 자료',
date: '2025-06-28',
views: 1230,
writer: '강민재',
hasAttachment: true,
},
{
id: 5,
title: '감마선과 베타선의 특성 및 차이 분석 자료',
date: '2025-06-28',
views: 594,
writer: '강민재',
},
{
id: 4,
title: '방사선량 단위(Sv, Gy) 비교 및 계산 예제',
date: '2025-06-28',
views: 1230,
writer: '강민재',
},
{
id: 3,
title: '의료 영상 촬영 시 방사선 안전 수칙 가이드',
date: '2025-06-28',
views: 1230,
writer: '강민재',
},
{
id: 2,
title: 'X선 발생 원리 및 특성에 대한 이해 자료',
date: '2025-06-28',
views: 1230,
writer: '강민재',
},
{
id: 1,
title: '방사선의 기초 개념과 물질과의 상호작용 정리 자료',
date: '2025-06-28',
views: 1230,
writer: '강민재',
},
];
export default function ResourcesPage() {
return (
<div className="w-full bg-white">
<div className="flex justify-center">
<div className="w-full max-w-[1440px]">
{/* 헤더 영역 */}
<div className="h-[100px] flex items-center px-8">
<h1 className="m-0 text-[24px] font-bold leading-[1.5] text-[#1B2027]">
</h1>
</div>
{/* 본문 영역 */}
<section className="px-8 pb-8">
{/* 총 건수 */}
<div className="h-10 flex items-center">
<p className="m-0 text-[15px] font-medium leading-[1.5] text-[#333C47]">
<span className="text-[#384FBF]">{rows.length}</span>
</p>
</div>
{/* 표 */}
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
{/* 헤더 */}
<div className="grid grid-cols-[57px_1fr_140px_140px_140px] bg-[#F9FAFB] h-[48px] text-[14px] font-semibold text-[#4C5561] border-b border-[#DEE1E6]">
<div className="flex items-center justify-center px-4 border-r border-[#DEE1E6]">
</div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
</div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
</div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
</div>
<div className="flex items-center px-4"></div>
</div>
{/* 바디 */}
<div>
{rows.map((r) => (
<div
key={r.id}
className={[
'grid grid-cols-[57px_1fr_140px_140px_140px] h-[48px] text-[15px] font-medium text-[#1B2027] border-t border-[#DEE1E6]',
r.id === rows[0].id ? 'bg-[rgba(236,240,255,0.5)]' : '',
].join(' ')}
>
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">
{r.id}
</div>
<div
className="flex items-center gap-1.5 px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis"
title={r.title}
>
{r.title}
{r.hasAttachment && (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
aria-hidden
className="shrink-0 text-[#8C95A1]"
>
<path
d="M21 8l-9.5 9.5a4 4 0 01-5.657-5.657L14 4.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
{r.date}
</div>
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
{r.views.toLocaleString()}
</div>
<div className="flex items-center px-4">{r.writer}</div>
</div>
))}
</div>
</div>
</section>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
export default function ChevronDownSvg(
{
width = 24,
height = 24,
className = '',
}: { width?: number | string; height?: number | string; className?: string }
): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
viewBox="0 0 24 24"
fill="none"
className={className}
aria-hidden
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M18.7071 8.29277C19.0976 8.6833 19.0976 9.31646 18.7071 9.70698L12.7071 15.707C12.3166 16.0975 11.6834 16.0975 11.2929 15.707L5.29289 9.70699C4.90237 9.31646 4.90237 8.6833 5.29289 8.29277C5.68342 7.90225 6.31658 7.90225 6.70711 8.29277L12 13.5857L17.2929 8.29277C17.6834 7.90225 18.3166 7.90225 18.7071 8.29277Z"
fill="white"
/>
</svg>
);
}

View File

@@ -0,0 +1,16 @@
"use client";
export default function ModalCloseSvg() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.71769 4.7172C5.14076 4.29414 5.82669 4.29414 6.24976 4.7172L11.9837 10.4512L17.7177 4.7172C18.1408 4.29414 18.8267 4.29414 19.2498 4.7172C19.6728 5.14027 19.6728 5.8262 19.2498 6.24927L13.5158 11.9832L19.2498 17.7172C19.6728 18.1403 19.6728 18.8262 19.2498 19.2493C18.8267 19.6723 18.1408 19.6723 17.7177 19.2493L11.9837 13.5153L6.24976 19.2493C5.82669 19.6723 5.14076 19.6723 4.71769 19.2493C4.29462 18.8262 4.29462 18.1403 4.71769 17.7172L10.4517 11.9832L4.71769 6.24927C4.29462 5.8262 4.29462 5.14027 4.71769 4.7172Z"
fill="#333C47"
/>
</svg>
);
}