Compare commits
2 Commits
0452ca2c28
...
4dc8304a1d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dc8304a1d | ||
|
|
4f7b98dffb |
@@ -2,6 +2,14 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "picsum.photos",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
BIN
public/imgs/talk.png
Normal file
BIN
public/imgs/talk.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
@@ -4,9 +4,10 @@ import Link from "next/link";
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import MainLogoSvg from "./svgs/mainlogosvg";
|
import MainLogoSvg from "./svgs/mainlogosvg";
|
||||||
|
import ChevronDownSvg from "./svgs/chevrondownsvg";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ label: "교육 과정 목록", href: "/menu" },
|
{ label: "교육 과정 목록", href: "/course-list" },
|
||||||
{ label: "학습 자료실", href: "/resources" },
|
{ label: "학습 자료실", href: "/resources" },
|
||||||
{ label: "공지사항", href: "/notices" },
|
{ label: "공지사항", href: "/notices" },
|
||||||
];
|
];
|
||||||
@@ -51,14 +52,12 @@ export default function NavBar() {
|
|||||||
</Link>
|
</Link>
|
||||||
<nav className="flex h-full items-center">
|
<nav className="flex h-full items-center">
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
const isActive = pathname === item.href;
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={[
|
className={[
|
||||||
"px-4 py-2 text-[16px] font-semibold",
|
"px-4 py-2 text-[16px] font-semibold text-white",
|
||||||
isActive ? "text-white" : "text-white/80 hover:text-white",
|
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -68,7 +67,7 @@ export default function NavBar() {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex items-center gap-2">
|
<div className="relative flex items-center gap-2">
|
||||||
<Link href="/my-courses" className="px-4 py-2 text-[16px] font-semibold text-white hover:text-white">
|
<Link href="/menu/courses" className="px-4 py-2 text-[16px] font-semibold text-white">
|
||||||
내 강좌실
|
내 강좌실
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
@@ -77,12 +76,14 @@ export default function NavBar() {
|
|||||||
onClick={() => setIsUserMenuOpen((v) => !v)}
|
onClick={() => setIsUserMenuOpen((v) => !v)}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={isUserMenuOpen}
|
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">
|
<ChevronDownSvg
|
||||||
<path d="M8 5l8 7-8 7" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
width={16}
|
||||||
</svg>
|
height={16}
|
||||||
|
className={["transition-transform", isUserMenuOpen ? "rotate-180" : "rotate-0"].join(" ")}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
{isUserMenuOpen && (
|
{isUserMenuOpen && (
|
||||||
<div
|
<div
|
||||||
@@ -91,12 +92,14 @@ export default function NavBar() {
|
|||||||
aria-label="사용자 메뉴"
|
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"
|
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"
|
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
|
<button
|
||||||
role="menuitem"
|
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"
|
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"
|
||||||
|
|||||||
50
src/app/components/Footer.tsx
Normal file
50
src/app/components/Footer.tsx
Normal 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-[1px] py-[40px] 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
15
src/app/components/HeaderVisibility.tsx
Normal file
15
src/app/components/HeaderVisibility.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
62
src/app/course-list/page.tsx
Normal file
62
src/app/course-list/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
export default function CourseListPage() {
|
||||||
|
const courses = [
|
||||||
|
{
|
||||||
|
id: "p1",
|
||||||
|
title: "원자로 운전 및 계통",
|
||||||
|
description:
|
||||||
|
"원자로 운전 원리와 주요 계통의 구조 및 기능을 이해하고, 실제 운전 상황을 가상 환경에서 체험합니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p2",
|
||||||
|
title: "확률론",
|
||||||
|
description:
|
||||||
|
"확률과 통계의 핵심 개념을 직관적으로 이해하고 문제 해결력을 기릅니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p3",
|
||||||
|
title: "부서간 협업",
|
||||||
|
description:
|
||||||
|
"협업 절차, 기록 기준, 리스크 관리까지 실제 사례 기반으로 배우는 협업 가이드.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p4",
|
||||||
|
title: "방사선의 이해",
|
||||||
|
description:
|
||||||
|
"방사선 안전 기준과 보호 장비 선택법을 배우는 실무형 과정.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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-white">교육 과정 목록</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="px-8 pb-16">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{courses.map((c) => (
|
||||||
|
<article
|
||||||
|
key={c.id}
|
||||||
|
className="rounded-xl border border-[#ecf0ff] bg-white p-4 shadow-[0_2px_8px_rgba(0,0,0,0.02)]"
|
||||||
|
>
|
||||||
|
<h2 className="truncate text-[18px] font-bold leading-[1.5] text-[#1b2027]">{c.title}</h2>
|
||||||
|
<p className="mt-1 line-clamp-3 text-[14px] leading-[1.5] text-[#4c5561]">{c.description}</p>
|
||||||
|
<div className="mt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-9 rounded-md border border-[#dee1e6] px-3 text-[14px] font-medium leading-[1.5] text-[#4c5561] hover:bg-[#f9fafb]"
|
||||||
|
>
|
||||||
|
자세히 보기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -47,5 +47,4 @@
|
|||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { pretendard } from "./fonts";
|
import { pretendard } from "./fonts";
|
||||||
import NavBar from "./NavBar";
|
import HeaderVisibility from "./components/HeaderVisibility";
|
||||||
|
import Footer from "./components/Footer";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "XRLMS",
|
title: "XRLMS",
|
||||||
@@ -13,9 +14,12 @@ export default function RootLayout({
|
|||||||
}: Readonly<{ children: React.ReactNode; }>) {
|
}: Readonly<{ children: React.ReactNode; }>) {
|
||||||
return (
|
return (
|
||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
<body className={pretendard.className}>
|
<body className={`${pretendard.className} min-h-screen flex flex-col`}>
|
||||||
<NavBar />
|
<HeaderVisibility />
|
||||||
|
<main className="flex-1 min-h-0">
|
||||||
{children}
|
{children}
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
70
src/app/menu/AccountDeleteModal.tsx
Normal file
70
src/app/menu/AccountDeleteModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,28 +1,75 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import ModalCloseSvg from "../svgs/closexsvg";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
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 [email, setEmail] = useState("xrlms2025@gmail.com");
|
||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null); // 인증번호 오류 등
|
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;
|
if (!open) return null;
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
if (requireCode) {
|
||||||
if (!code) {
|
if (!code) {
|
||||||
setError("인증번호를 입력해 주세요.");
|
setError("인증번호를 입력해 주세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (!newPassword || !confirmPassword) {
|
if (!newPassword || !confirmPassword) {
|
||||||
setError("새 비밀번호를 입력해 주세요.");
|
setError("새 비밀번호를 입력해 주세요.");
|
||||||
return;
|
return;
|
||||||
@@ -31,7 +78,7 @@ export default function ChangePasswordModal({ open, onClose, onSubmit }: Props)
|
|||||||
setError("새 비밀번호가 일치하지 않습니다.");
|
setError("새 비밀번호가 일치하지 않습니다.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSubmit?.({ email, code, newPassword });
|
onSubmit?.({ email, code: requireCode ? code : undefined, newPassword });
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,11 +96,9 @@ export default function ChangePasswordModal({ open, onClose, onSubmit }: Props)
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label="닫기"
|
aria-label="닫기"
|
||||||
onClick={onClose}
|
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]">
|
<ModalCloseSvg />
|
||||||
<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>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -66,17 +111,26 @@ export default function ChangePasswordModal({ open, onClose, onSubmit }: Props)
|
|||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
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="이메일"
|
placeholder="이메일"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{requireCode ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="w-[100px] text-[15px] font-semibold leading-[1.5] text-[#6c7682]">인증번호</div>
|
<div className="w-[100px] text-[15px] font-semibold leading-[1.5] text-[#6c7682]">인증번호</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -85,28 +139,47 @@ export default function ChangePasswordModal({ open, onClose, onSubmit }: Props)
|
|||||||
value={code}
|
value={code}
|
||||||
onChange={(e) => setCode(e.target.value)}
|
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"
|
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자리"
|
placeholder="인증번호를 입력해 주세요."
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="h-10 w-[136px] rounded-[8px] bg-[#f1f3f5] px-3 text-[16px] font-semibold leading-[1.5] text-[#4c5561]"
|
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>
|
</button>
|
||||||
</div>
|
</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 ? (
|
{error ? (
|
||||||
<p className="px-1 text-[13px] font-semibold leading-[1.4] text-[#f64c4c]">
|
<p className="px-1 text-[13px] font-semibold leading-[1.4] text-[#f64c4c]">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682]">새 비밀번호</label>
|
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682]">새 비밀번호</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
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="새 비밀번호"
|
placeholder="새 비밀번호"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,7 +191,11 @@ export default function ChangePasswordModal({ open, onClose, onSubmit }: Props)
|
|||||||
type="password"
|
type="password"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
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="새 비밀번호 확인"
|
placeholder="새 비밀번호 확인"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,14 +206,14 @@ export default function ChangePasswordModal({ open, onClose, onSubmit }: Props)
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSubmit}
|
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>
|
</button>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const learningItems: NavItem[] = [
|
|||||||
|
|
||||||
const accountItems: NavItem[] = [
|
const accountItems: NavItem[] = [
|
||||||
{ label: "내 정보 수정", href: "/menu/account" },
|
{ label: "내 정보 수정", href: "/menu/account" },
|
||||||
{ label: "로그아웃", href: "/logout" },
|
{ label: "로그아웃", href: "/login" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function MenuSidebar() {
|
export default function MenuSidebar() {
|
||||||
|
|||||||
56
src/app/menu/PasswordChangeDoneModal.tsx
Normal file
56
src/app/menu/PasswordChangeDoneModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
151
src/app/menu/account/MenuAccountOption.tsx
Normal file
151
src/app/menu/account/MenuAccountOption.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,10 +1,23 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ChangePasswordModal from "../ChangePasswordModal";
|
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() {
|
export default function AccountPage() {
|
||||||
const [open, setOpen] = useState(false);
|
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 (
|
return (
|
||||||
<main className="flex w-full flex-col">
|
<main className="flex w-full flex-col">
|
||||||
@@ -40,7 +53,11 @@ export default function AccountPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,6 +69,28 @@ export default function AccountPage() {
|
|||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
// TODO: integrate API
|
// 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>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
171
src/app/menu/courses/CourseCard.tsx
Normal file
171
src/app/menu/courses/CourseCard.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useState } from "react";
|
||||||
|
import ChevronDownSvg from "../../svgs/chevrondownsvg";
|
||||||
|
|
||||||
|
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-1.5 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, defaultOpen = false }: { course: Course; defaultOpen?: boolean }) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
|
||||||
|
const totalMinutes = course.lessons.reduce((sum, l) => sum + (l.durationMin || 0), 0);
|
||||||
|
const totalHours = Math.floor(totalMinutes / 60);
|
||||||
|
const restMinutes = totalMinutes % 60;
|
||||||
|
const firstIncomplete = course.lessons.find((l) => !l.isCompleted)?.id;
|
||||||
|
const cardClassName = [
|
||||||
|
"rounded-xl border bg-white shadow-[0_2px_8px_rgba(0,0,0,0.02)]",
|
||||||
|
open ? "border-[#384fbf]" : "border-[#ecf0ff]",
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
const formatDuration = (m: number) => {
|
||||||
|
const minutes = String(m).padStart(2, "0");
|
||||||
|
const seconds = String((m * 7) % 60).padStart(2, "0");
|
||||||
|
return `${minutes}:${seconds}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={cardClassName}>
|
||||||
|
<header className="flex items-center gap-4 p-4">
|
||||||
|
<div className="relative h-[76px] w-[120px] overflow-hidden rounded-md bg-[#f1f3f5]">
|
||||||
|
<Image
|
||||||
|
src={`https://picsum.photos/seed/${encodeURIComponent(course.id)}/240/152`}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
sizes="120px"
|
||||||
|
unoptimized
|
||||||
|
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 justify-between gap-3">
|
||||||
|
<p className="text-[13px] leading-[1.4] text-[#8c95a1]">
|
||||||
|
VOD · 총 {course.lessons.length}강 · {totalHours}시간 {restMinutes}분
|
||||||
|
</p>
|
||||||
|
<p className="text-[13px] font-semibold leading-[1.4] text-[#384fbf]">
|
||||||
|
진행도 {course.progressPct}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<ProgressBar value={course.progressPct} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 self-start">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex h-8 w-8 items-center justify-center text-[#6c7682] cursor-pointer"
|
||||||
|
aria-label={open ? "접기" : "펼치기"}
|
||||||
|
>
|
||||||
|
<ChevronDownSvg
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
className={["transition-transform", open ? "rotate-180" : "rotate-0"].join(" ")}
|
||||||
|
/>
|
||||||
|
</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] bg-white">
|
||||||
|
<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">
|
||||||
|
<span className="text-[13px] leading-[1.4] text-[#8c95a1]">
|
||||||
|
{formatDuration(lesson.durationMin)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={[
|
||||||
|
"rounded-md px-3 py-2 text-[14px] font-medium leading-[1.5]",
|
||||||
|
lesson.isCompleted
|
||||||
|
? "text-[#384fbf] border border-transparent bg-white"
|
||||||
|
: "text-[#4c5561] border border-[#8c95a1] bg-white",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{lesson.isCompleted ? "학습 제출 완료" : "학습 제출 하기"}
|
||||||
|
</button>
|
||||||
|
{lesson.isCompleted ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md border border-[#dee1e6] px-3 py-2 text-[14px] font-medium leading-[1.5] text-[#4c5561] hover:bg-[#f9fafb]"
|
||||||
|
>
|
||||||
|
복습하기
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={[
|
||||||
|
"rounded-md px-3 py-2 text-[14px] font-medium leading-[1.5]",
|
||||||
|
lesson.id === firstIncomplete
|
||||||
|
? "bg-[#ecf0ff] text-[#384fbf]"
|
||||||
|
: "border border-[#dee1e6] text-[#4c5561] hover:bg-[#f9fafb]",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{lesson.id === firstIncomplete ? "이어서 수강하기" : "수강하기"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
61
src/app/menu/courses/CourseGridItem.tsx
Normal file
61
src/app/menu/courses/CourseGridItem.tsx
Normal 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="flex w-full cursor-pointer 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"
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
138
src/app/menu/courses/[courseId]/page.tsx
Normal file
138
src/app/menu/courses/[courseId]/page.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
type Lesson = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
duration: string; // "12:46" 형식
|
||||||
|
state: "제출완료" | "제출대기";
|
||||||
|
action: "복습하기" | "이어서 수강하기" | "수강하기";
|
||||||
|
};
|
||||||
|
|
||||||
|
type CourseDetail = {
|
||||||
|
id: string;
|
||||||
|
status: "수강 중" | "수강 예정" | "수강 완료";
|
||||||
|
title: string;
|
||||||
|
goal: string;
|
||||||
|
method: string;
|
||||||
|
summary: string; // VOD · 총 n강 · n시간 n분
|
||||||
|
submitSummary: string; // 학습 제출 n/n
|
||||||
|
thumbnail: string;
|
||||||
|
lessons: Lesson[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOCK_DETAIL: CourseDetail = {
|
||||||
|
id: "c1",
|
||||||
|
status: "수강 중",
|
||||||
|
title: "원자로 운전 및 계통",
|
||||||
|
goal:
|
||||||
|
"원자로 운전 원리와 주요 계통의 구조 및 기능을 이해하고, 실제 운전 상황을 가상 환경에서 체험하여 문제 해결 능력을 기른다.",
|
||||||
|
method:
|
||||||
|
"강좌 동영상을 통해 이론을 학습한 후, XR 실습을 통해 원자로 운전 및 계통 제어 과정을 체험한다. 이후 문제 풀이를 통해 학습 내용을 점검하며 평가를 받아 학습 성취도를 확인한다.",
|
||||||
|
summary: "VOD · 총 6강 · 4시간 20분",
|
||||||
|
submitSummary: "학습 제출 3/6",
|
||||||
|
thumbnail: "https://picsum.photos/seed/course-detail/584/318",
|
||||||
|
lessons: [
|
||||||
|
{ id: "l1", title: "1. 원자로 기초 및 핵분열 원리", duration: "12:46", state: "제출완료", action: "복습하기" },
|
||||||
|
{ id: "l2", title: "2. 제어봉 및 원자로 출력 제어", duration: "18:23", state: "제출완료", action: "복습하기" },
|
||||||
|
{ id: "l3", title: "3. 터빈-발전기 계통 및 전력 생산", duration: "13:47", state: "제출완료", action: "복습하기" },
|
||||||
|
{ id: "l4", title: "4. 보조 계통 및 안전 계통", duration: "13:47", state: "제출대기", action: "복습하기" },
|
||||||
|
{ id: "l5", title: "5. 원자로 냉각재 계통 (RCS) 및 열수력", duration: "08:11", state: "제출대기", action: "이어서 수강하기" },
|
||||||
|
{ id: "l6", title: "6. 원자로 시동, 운전 및 정지 절차", duration: "13:47", state: "제출대기", action: "수강하기" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CourseDetailPage() {
|
||||||
|
const c = MOCK_DETAIL;
|
||||||
|
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>
|
||||||
|
|
||||||
|
<section className="px-8 pb-20">
|
||||||
|
<div className="rounded-[8px] bg-white px-8 pb-20 pt-6">
|
||||||
|
{/* 상단 소개 카드 */}
|
||||||
|
<div className="flex gap-6 rounded-[8px] bg-[#f8f9fa] p-6">
|
||||||
|
<div className="relative h-[159px] w-[292px] overflow-hidden rounded">
|
||||||
|
<Image src={c.thumbnail} alt="" fill sizes="292px" className="object-cover" unoptimized />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex h-[27px] items-center gap-2">
|
||||||
|
<span className="h-[20px] rounded-[4px] bg-[#e5f5ec] px-1.5 text-[13px] font-semibold leading-[1.4] text-[#0c9d61]">
|
||||||
|
{c.status}
|
||||||
|
</span>
|
||||||
|
<h2 className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">{c.title}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-1">
|
||||||
|
<p className="text-[15px] leading-[1.5] text-[#333c47]">
|
||||||
|
<span className="font-medium">학습 목표:</span> {c.goal}
|
||||||
|
</p>
|
||||||
|
<p className="text-[15px] leading-[1.5] text-[#333c47]">
|
||||||
|
<span className="font-medium">학습 방법:</span> {c.method}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center gap-5 text-[13px] leading-[1.4] text-[#8c95a1]">
|
||||||
|
<span>{c.summary}</span>
|
||||||
|
<span>{c.submitSummary}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 차시 리스트 */}
|
||||||
|
<div className="mt-6 space-y-2">
|
||||||
|
{c.lessons.map((l) => {
|
||||||
|
const isSubmitted = l.state === "제출완료";
|
||||||
|
const submitBtnStyle =
|
||||||
|
l.state === "제출완료"
|
||||||
|
? "border border-transparent text-[#384fbf]"
|
||||||
|
: "border " + (l.action === "이어서 수강하기" || l.action === "수강하기" ? "border-[#b1b8c0]" : "border-[#8c95a1]");
|
||||||
|
const rightBtnStyle =
|
||||||
|
l.action === "이어서 수강하기"
|
||||||
|
? "bg-[#ecf0ff] text-[#384fbf]"
|
||||||
|
: l.action === "수강하기"
|
||||||
|
? "bg-[#ecf0ff] text-[#384fbf]"
|
||||||
|
: "bg-[#f1f3f5] text-[#4c5561]";
|
||||||
|
return (
|
||||||
|
<div key={l.id} className="rounded-[8px] border border-[#dee1e6] bg-white">
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-[8px] px-6 py-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[16px] font-semibold leading-[1.5] text-[#333c47]">{l.title}</p>
|
||||||
|
<div className="mt-1 flex items-center gap-3">
|
||||||
|
<p className="w-[40px] text-[13px] leading-[1.4] text-[#8c95a1]">{l.duration}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={[
|
||||||
|
"h-8 rounded-[6px] px-4 text-[14px] font-medium leading-[1.5]",
|
||||||
|
"bg-white",
|
||||||
|
submitBtnStyle,
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{isSubmitted ? "학습 제출 완료" : "학습 제출 하기"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={[
|
||||||
|
"h-8 rounded-[6px] px-4 text-[14px] font-medium leading-[1.5]",
|
||||||
|
rightBtnStyle,
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{l.action}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,14 +1,176 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import CourseCard from "./CourseCard";
|
||||||
|
|
||||||
|
type CourseStatus = "전체" | "수강 예정" | "수강중" | "수강 완료";
|
||||||
|
|
||||||
|
type Lesson = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
durationMin: number;
|
||||||
|
progressPct: number;
|
||||||
|
isCompleted: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Course = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
thumbnail: string;
|
||||||
|
status: CourseStatus;
|
||||||
|
progressPct: number;
|
||||||
|
lessons: Lesson[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const 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: 18, progressPct: 100, isCompleted: true },
|
||||||
|
{ id: "c1l3", title: "3. 터빈-발전기 계통 및 전력 생산", durationMin: 13, progressPct: 60, isCompleted: false },
|
||||||
|
{ id: "c1l4", title: "4. 보조 계통 및 안전 계통", durationMin: 13, progressPct: 0, isCompleted: false },
|
||||||
|
{ id: "c1l5", title: "5. 원자로 냉각재 계통 (RCS) 및 열수력", durationMin: 8, 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() {
|
export default function CoursesPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState<CourseStatus>("전체");
|
||||||
|
|
||||||
|
const countsByStatus = useMemo(() => {
|
||||||
|
return {
|
||||||
|
전체: COURSES.length,
|
||||||
|
"수강 예정": COURSES.filter((c) => c.status === "수강 예정").length,
|
||||||
|
수강중: COURSES.filter((c) => c.status === "수강중").length,
|
||||||
|
"수강 완료": COURSES.filter((c) => c.status === "수강 완료").length,
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (activeTab === "전체") return COURSES;
|
||||||
|
return COURSES.filter((c) => c.status === activeTab);
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex w-full flex-col">
|
<main className="flex w-full flex-col">
|
||||||
<div className="flex h-[100px] items-center px-8">
|
<div className="flex h-[100px] items-center px-8">
|
||||||
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">내 강좌실</h1>
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">내 강좌실</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-8 pb-20">
|
|
||||||
<p className="text-[16px] leading-[1.5] text-[#4c5561]">콘텐츠 준비 중입니다.</p>
|
<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="이전 페이지"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</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="다음 페이지"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import MenuSidebar from "./MenuSidebar";
|
|||||||
|
|
||||||
export default function MenuLayout({ children }: { children: ReactNode }) {
|
export default function MenuLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
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">
|
<aside className="w-[320px] border-r border-[#dee1e6] px-4 py-6">
|
||||||
<MenuSidebar />
|
<MenuSidebar />
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -1,12 +1,86 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import CourseGridItem, { CourseGridItemProps } from "./courses/CourseGridItem";
|
||||||
|
|
||||||
|
type CatalogCourse = CourseGridItemProps & {
|
||||||
|
category: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 안정적인 랜덤 이미지를 위해 시드 기반 썸네일 풀 구성
|
||||||
|
const THUMBS = Array.from({ length: 16 }).map((_, i) => `https://picsum.photos/seed/xrlms-${i}/640/400`);
|
||||||
|
|
||||||
|
const CATALOG: CatalogCourse[] = Array.from({ length: 20 }).map((_, i) => ({
|
||||||
|
id: `cat-${i + 1}`,
|
||||||
|
title:
|
||||||
|
i % 5 === 0
|
||||||
|
? "방사선 안전"
|
||||||
|
: i % 5 === 1
|
||||||
|
? "원자재 운전 및 계동"
|
||||||
|
: i % 5 === 2
|
||||||
|
? "학점과 평가"
|
||||||
|
: i % 5 === 3
|
||||||
|
? "방사선 불가물"
|
||||||
|
: "현장 운전 및 계동",
|
||||||
|
category: i % 6 === 0 ? "추천" : undefined,
|
||||||
|
meta: "VOD · 총 6강 · 4시간 20분",
|
||||||
|
thumbnail: THUMBS[i % THUMBS.length],
|
||||||
|
isNew: i % 9 === 0,
|
||||||
|
}));
|
||||||
|
|
||||||
export default function MenuPage() {
|
export default function MenuPage() {
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto w-full max-w-[1440px] px-8 py-8">
|
<main className="w-full">
|
||||||
|
<div className="flex h-[88px] items-center px-8">
|
||||||
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">교육 과정 목록</h1>
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">교육 과정 목록</h1>
|
||||||
<p className="mt-6 text-[16px] leading-[1.5] text-[#4c5561]">
|
</div>
|
||||||
메뉴 페이지 준비 중입니다.
|
|
||||||
</p>
|
<div className="px-8 pb-16 pt-2">
|
||||||
|
<p className="mb-4 text-[13px] leading-[1.4] text-[#8c95a1]">총 {CATALOG.length}건</p>
|
||||||
|
<ul className="grid grid-cols-2 gap-x-5 gap-y-6 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
|
{CATALOG.map((course) => (
|
||||||
|
<CourseGridItem
|
||||||
|
key={course.id}
|
||||||
|
id={course.id}
|
||||||
|
title={course.title}
|
||||||
|
category={course.category}
|
||||||
|
meta={course.meta}
|
||||||
|
thumbnail={course.thumbnail}
|
||||||
|
isNew={course.isNew}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* 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="이전 페이지"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</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 === 1 ? "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="다음 페이지"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
23
src/app/menu/results/CertificateModal.tsx
Normal file
23
src/app/menu/results/CertificateModal.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import FigmaCertificateContent from "./FigmaCertificateContent";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CertificateModal({ open, onClose }: Props) {
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/40" onClick={onClose} aria-hidden="true" />
|
||||||
|
<div className="relative z-10 shadow-xl">
|
||||||
|
<FigmaCertificateContent onClose={onClose} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
44
src/app/menu/results/FeedbackModal.tsx
Normal file
44
src/app/menu/results/FeedbackModal.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import FigmaFeedbackContent from "./FigmaFeedbackContent";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
learnerName?: string;
|
||||||
|
instructorName?: string;
|
||||||
|
scoreText?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FeedbackModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
learnerName,
|
||||||
|
instructorName,
|
||||||
|
scoreText,
|
||||||
|
}: Props) {
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
aria-hidden={!open}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="relative z-10 shadow-xl">
|
||||||
|
<FigmaFeedbackContent
|
||||||
|
onClose={onClose}
|
||||||
|
learnerName={learnerName}
|
||||||
|
instructorName={instructorName}
|
||||||
|
scoreText={scoreText}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
346
src/app/menu/results/FigmaCertificateContent.tsx
Normal file
346
src/app/menu/results/FigmaCertificateContent.tsx
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const imgImage1 = "http://localhost:3845/assets/89fda8e949171025b1232bae70fc9d442e4e70c8.png";
|
||||||
|
const imgContainer = "http://localhost:3845/assets/d04df6bb7fe1bd29946d04be9442029bca1503b0.png";
|
||||||
|
const img = "http://localhost:3845/assets/7adf9a5e43b6c9e5f9bee6adfee64e85eabac44a.svg";
|
||||||
|
const img1 = "http://localhost:3845/assets/9e3b52939dbaa99088659a82db437772ef1ad40e.svg";
|
||||||
|
|
||||||
|
export default function FigmaCertificateContent({ onClose }: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-white border border-[#dee1e6] border-solid box-border content-stretch flex flex-col items-center relative rounded-[12px] w-[720px] max-w-[95vw]"
|
||||||
|
data-name="Frame"
|
||||||
|
data-node-id="388:21934"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="box-border content-stretch flex gap-[10px] items-center overflow-clip p-[24px] relative shrink-0 w-full"
|
||||||
|
data-name="header"
|
||||||
|
data-node-id="388:21935"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="basis-0 flex flex-col font-['Pretendard:Bold',sans-serif] grow h-[32px] justify-center leading-[0] min-h-px min-w-px not-italic relative shrink-0 text-[#333c47] text-[20px]"
|
||||||
|
data-node-id="388:21937"
|
||||||
|
>
|
||||||
|
<p className="leading-[1.5]">수료증</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="닫기"
|
||||||
|
onClick={onClose}
|
||||||
|
className="overflow-clip relative shrink-0 size-[24px]"
|
||||||
|
data-name="x-close-lg"
|
||||||
|
data-node-id="388:21938"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute left-[calc(50%-0.02px)] size-[15.167px] top-[calc(50%-0.02px)] translate-x-[-50%] translate-y-[-50%]"
|
||||||
|
data-name="Icon (Stroke)"
|
||||||
|
data-node-id="I388:21938;1422:243"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{ "--fill-0": "rgba(51, 60, 71, 1)" } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<img alt="" className="block max-w-none size-full" src={img} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="bg-white border-[#1f2b91] border-[6px] border-solid h-[714px] relative rounded-[12px] shrink-0 w-[506px]"
|
||||||
|
data-name="Container"
|
||||||
|
data-node-id="388:22388"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute content-stretch flex flex-col gap-[8px] items-center justify-center left-[46px] not-italic text-center text-nowrap top-[92px] w-[418px] whitespace-pre"
|
||||||
|
data-node-id="388:22389"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="font-['Pretendard:Regular',sans-serif] leading-[1.4] relative shrink-0 text-[#6c7682] text-[13px]"
|
||||||
|
data-node-id="388:22390"
|
||||||
|
>
|
||||||
|
제 2025-N0055L3
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] relative shrink-0 text-[#333c47] text-[40px]"
|
||||||
|
data-node-id="388:22391"
|
||||||
|
>{`수 료 증`}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="absolute content-stretch flex flex-col gap-[4px] items-start left-[46px] top-[216px] w-[418px]"
|
||||||
|
data-node-id="388:22392"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="content-stretch flex h-[24px] items-start relative shrink-0 w-full"
|
||||||
|
data-name="Container"
|
||||||
|
data-node-id="388:22393"
|
||||||
|
>
|
||||||
|
<p className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#333c47] text-[15px] w-[100px] whitespace-pre-wrap">
|
||||||
|
{`소 속 :`}
|
||||||
|
</p>
|
||||||
|
<div className="h-[24px] relative shrink-0 w-[170.297px]" data-name="Paragraph">
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-[24px] relative w-[170.297px]">
|
||||||
|
<p className="absolute font-['Pretendard:Medium',sans-serif] leading-[1.5] left-0 not-italic text-[#333c47] text-[15px] text-nowrap top-[-1px] whitespace-pre">
|
||||||
|
XR LMS
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="content-stretch flex h-[24px] items-start relative shrink-0 w-full" data-name="Container">
|
||||||
|
<p className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#333c47] text-[15px] w-[100px] whitespace-pre-wrap">
|
||||||
|
{`성 명 :`}
|
||||||
|
</p>
|
||||||
|
<div className="h-[24px] relative shrink-0 w-[41.531px]" data-name="Paragraph">
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-[24px] relative w-[41.531px]">
|
||||||
|
<p className="absolute font-['Pretendard:Medium',sans-serif] leading-[1.5] left-0 not-italic text-[#333c47] text-[15px] text-nowrap top-[-1px] whitespace-pre">
|
||||||
|
김하늘
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="content-stretch flex h-[24px] items-start relative shrink-0 w-full" data-name="Container">
|
||||||
|
<p className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#333c47] text-[15px] w-[100px]">
|
||||||
|
생 년 월 일 :
|
||||||
|
</p>
|
||||||
|
<div className="h-[24px] relative shrink-0 w-[78.281px]" data-name="Paragraph">
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-[24px] relative w-[78.281px]">
|
||||||
|
<p className="absolute font-['Pretendard:Medium',sans-serif] leading-[1.5] left-0 not-italic text-[#333c47] text-[15px] text-nowrap top-[-1px] whitespace-pre">
|
||||||
|
1994-10-17
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="content-stretch flex h-[24px] items-start relative shrink-0 w-full" data-name="Container">
|
||||||
|
<p className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#333c47] text-[15px] w-[100px]">
|
||||||
|
교 육 과 정 :
|
||||||
|
</p>
|
||||||
|
<div className="basis-0 grow h-[24px] min-h-px min-w-px relative shrink-0" data-name="Paragraph">
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-[24px] relative w-full">
|
||||||
|
<p className="absolute font-['Pretendard:Medium',sans-serif] leading-[1.5] left-0 not-italic text-[#333c47] text-[15px] text-nowrap top-[-1px] whitespace-pre">
|
||||||
|
(2025년) 방사선작업종사자 직장교육(신규) 9월
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="content-stretch flex h-[24px] items-start relative shrink-0 w-full" data-name="Container">
|
||||||
|
<p className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#333c47] text-[15px] w-[100px]">
|
||||||
|
교 육 기 간 :
|
||||||
|
</p>
|
||||||
|
<div className="h-[24px] relative shrink-0 w-[229.328px]" data-name="Paragraph">
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-[24px] relative w-[229.328px]">
|
||||||
|
<p className="absolute font-['Pretendard:Medium',sans-serif] leading-[1.5] left-0 not-italic text-[#333c47] text-[15px] text-nowrap top-[-1px] whitespace-pre">
|
||||||
|
2025-09-01 ~ 2025-09-30, 4시간
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="content-stretch flex h-[24px] items-start relative shrink-0 w-full" data-name="Container">
|
||||||
|
<p className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#333c47] text-[15px] w-[100px]">
|
||||||
|
수 료 일 자 :
|
||||||
|
</p>
|
||||||
|
<div className="h-[24px] relative shrink-0 w-[84.219px]" data-name="Paragraph">
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-[24px] relative w-[84.219px]">
|
||||||
|
<p className="absolute font-['Pretendard:Medium',sans-serif] leading-[1.5] left-0 not-italic text-[#333c47] text-[15px] text-nowrap top-[-1px] whitespace-pre">
|
||||||
|
2025-09-26
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="absolute content-stretch flex flex-col gap-[24px] items-center left-[94.5px] top-[428px]"
|
||||||
|
data-node-id="388:22423"
|
||||||
|
>
|
||||||
|
<div className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#333c47] text-[16px] text-center text-nowrap whitespace-pre">
|
||||||
|
<p className="mb-0">위 사람은 우리 협회가 진행한</p>
|
||||||
|
<p>
|
||||||
|
『(2025년) 방사선작업종사자 직장교육(신규)_9월』
|
||||||
|
<br aria-hidden="true" />
|
||||||
|
과정을 수료하였으므로 이 수료증을 수여함.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#4c5561] text-[16px] text-center text-nowrap whitespace-pre">
|
||||||
|
2025년 10월 21일
|
||||||
|
</p>
|
||||||
|
<div className="content-stretch flex gap-[8px] items-center relative shrink-0" data-name="logo">
|
||||||
|
<div className="h-[36px] relative shrink-0 w-[46.703px]" data-name="image 1">
|
||||||
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
|
<img alt="" className="absolute h-[390.71%] left-[-100%] max-w-none top-[-132.02%] w-[301.18%]" src={imgImage1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col font-['Pretendard:ExtraBold',sans-serif] justify-center leading-[0] not-italic relative shrink-0 text-[#333c47] text-[24px] text-nowrap">
|
||||||
|
<p className="leading-[1.45] whitespace-pre">XR LMS</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute contents left-[26px] top-[662px]">
|
||||||
|
<div className="absolute flex h-[28px] items-center justify-center left-[26px] top-[662px] w-[40px]">
|
||||||
|
<div className="flex-none scale-y-[-100%]">
|
||||||
|
<div className="content-stretch flex flex-col gap-[4px] h-[28px] items-start relative w-[40px]">
|
||||||
|
<div className="h-[12px] relative shrink-0 w-[40px]">
|
||||||
|
<div className="content-stretch flex gap-[4px] h-[12px] items-start relative w-[40px]">
|
||||||
|
<div
|
||||||
|
className="relative shrink-0 size-[12px]"
|
||||||
|
style={{ backgroundImage: "linear-gradient(rgb(31, 43, 145) 0%, rgb(56, 79, 191) 100%), linear-gradient(90deg, rgb(56, 79, 191) 0%, rgb(56, 79, 191) 100%)" }}
|
||||||
|
>
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border size-[12px]" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="relative shrink-0 size-[12px]"
|
||||||
|
style={{ backgroundImage: "linear-gradient(rgb(31, 43, 145) 0%, rgb(56, 79, 191) 100%), linear-gradient(90deg, rgb(56, 79, 191) 0%, rgb(56, 79, 191) 100%)" }}
|
||||||
|
>
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border size-[12px]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="basis-0 grow min-h-px min-w-px relative shrink-0 w-[12px]"
|
||||||
|
style={{ backgroundImage: "linear-gradient(rgb(31, 43, 145) 0%, rgb(56, 79, 191) 100%), linear-gradient(90deg, rgb(56, 79, 191) 0%, rgb(56, 79, 191) 100%)" }}
|
||||||
|
>
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-full w-[12px]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute flex h-[28px] items-center justify-center left-[444px] top-[662px] w-[40px]">
|
||||||
|
<div className="flex-none scale-y-[-100%]">
|
||||||
|
<div className="content-stretch flex flex-col gap-[4px] h-[28px] items-end relative w-[40px]">
|
||||||
|
<div className="h-[12px] relative shrink-0 w-[28px]">
|
||||||
|
<div className="content-stretch flex gap-[4px] h-[12px] items-start relative w-[28px]">
|
||||||
|
<div
|
||||||
|
className="relative shrink-0 size-[12px]"
|
||||||
|
style={{ backgroundImage: "linear-gradient(rgb(31, 43, 145) 0%, rgb(56, 79, 191) 100%), linear-gradient(90deg, rgb(56, 79, 191) 0%, rgb(56, 79, 191) 100%)" }}
|
||||||
|
>
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border size-[12px]" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="basis-0 grow h-[12px] min-h-px min-w-px relative shrink-0"
|
||||||
|
style={{ backgroundImage: "linear-gradient(rgb(31, 43, 145) 0%, rgb(56, 79, 191) 100%), linear-gradient(90deg, rgb(56, 79, 191) 0%, rgb(56, 79, 191) 100%)" }}
|
||||||
|
>
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-[12px] w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="basis-0 grow min-h-px min-w-px relative shrink-0 w-[12px]"
|
||||||
|
style={{ backgroundImage: "linear-gradient(rgb(31, 43, 145) 0%, rgb(56, 79, 191) 100%), linear-gradient(90deg, rgb(56, 79, 191) 0%, rgb(56, 79, 191) 100%)" }}
|
||||||
|
>
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-full w-[12px]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute h-[2px] left-[86px] top-[690px] w-[338px]">
|
||||||
|
<img alt="" className="absolute inset-0 max-w-none object-50%-50% object-cover pointer-events-none size-full" src={imgContainer} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute contents left-[24px] top-[22px]">
|
||||||
|
<div className="absolute content-stretch flex flex-col gap-[4px] h-[28px] items-start left-[24px] top-[24px] w-[40px]">
|
||||||
|
<div className="h-[12px] relative shrink-0 w-[40px]">
|
||||||
|
<div className="content-stretch flex gap-[4px] h-[12px] items-start relative w-[40px]">
|
||||||
|
<div
|
||||||
|
className="relative shrink-0 size-[12px]"
|
||||||
|
style={{ backgroundImage: "linear-gradient(rgb(31, 43, 145) 0%, rgb(56, 79, 191) 100%), linear-gradient(90deg, rgb(56, 79, 191) 0%, rgb(56, 79, 191) 100%)" }}
|
||||||
|
>
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border size-[12px]" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="relative shrink-0 size-[12px]"
|
||||||
|
style={{ backgroundImage: "linear-gradient(rgb(31, 43, 145) 0%, rgb(56, 79, 191) 100%), linear-gradient(90deg, rgb(56, 79, 191) 0%, rgb(56, 79, 191) 100%)" }}
|
||||||
|
>
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border size-[12px]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="basis-0 grow min-h-px min-w-px relative shrink-0 w-[12px]"
|
||||||
|
style={{ backgroundImage: "linear-gradient(rgb(31, 43, 145) 0%, rgb(56, 79, 191) 100%), linear-gradient(90deg, rgb(56, 79, 191) 0%, rgb(56, 79, 191) 100%)" }}
|
||||||
|
>
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-full w-[12px]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute content-stretch flex flex-col gap-[4px] h-[28px] items-end left-[442px] top-[24px] w-[40px]">
|
||||||
|
<div className="h-[12px] relative shrink-0 w-[28px]">
|
||||||
|
<div className="content-stretch flex gap-[4px] h-[12px] items-start relative w-[28px]">
|
||||||
|
<div
|
||||||
|
className="relative shrink-0 size-[12px]"
|
||||||
|
style={{ backgroundImage: "linear-gradient(rgb(31, 43, 145) 0%, rgb(56, 79, 191) 100%), linear-gradient(90deg, rgb(56, 79, 191) 0%, rgb(56, 79, 191) 100%)" }}
|
||||||
|
>
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border size-[12px]" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="basis-0 grow h-[12px] min-h-px min-w-px relative shrink-0"
|
||||||
|
style={{ backgroundImage: "linear-gradient(rgb(31, 43, 145) 0%, rgb(56, 79, 191) 100%), linear-gradient(90deg, rgb(56, 79, 191) 0%, rgb(56, 79, 191) 100%)" }}
|
||||||
|
>
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-[12px] w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="basis-0 grow min-h-px min-w-px relative shrink-0 w-[12px]"
|
||||||
|
style={{ backgroundImage: "linear-gradient(rgb(31, 43, 145) 0%, rgb(56, 79, 191) 100%), linear-gradient(90deg, rgb(56, 79, 191) 0%, rgb(56, 79, 191) 100%)" }}
|
||||||
|
>
|
||||||
|
<div className="bg-clip-padding border-0 border-[transparent] border-solid box-border h-full w-[12px]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute h-[2px] left-[84px] top-[22px] w-[338px]">
|
||||||
|
<img alt="" className="absolute inset-0 max-w-none object-50%-50% object-cover pointer-events-none size-full" src={imgContainer} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="box-border content-stretch flex flex-col gap-[32px] h-[96px] items-center p-[24px] relative shrink-0 w-full"
|
||||||
|
data-node-id="388:22061"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="basis-0 content-stretch flex gap-[12px] grow items-start justify-center min-h-px min-w-px overflow-clip relative shrink-0 w-full"
|
||||||
|
data-name="Actions"
|
||||||
|
data-node-id="388:22062"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="bg-white border border-[#8c95a1] border-solid box-border content-stretch flex gap-[4px] h-[48px] items-center justify-center px-[16px] py-[3px] relative rounded-[10px] shrink-0 w-[136px]"
|
||||||
|
>
|
||||||
|
<div className="relative shrink-0 size-[20px]" data-name="Interface, Essential/download-arrow">
|
||||||
|
<div className="absolute inset-[-0.02%]">
|
||||||
|
<img alt="" className="block max-w-none size-full" src={img1} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#4c5561] text-[16px] text-center text-nowrap whitespace-pre">
|
||||||
|
다운로드
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="bg-[#ecf0ff] box-border content-stretch flex gap-[4px] h-[48px] items-center justify-center px-[16px] py-[8px] relative rounded-[10px] shrink-0 w-[136px]"
|
||||||
|
>
|
||||||
|
<p className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#384fbf] text-[16px] text-center text-nowrap whitespace-pre">
|
||||||
|
출력
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
122
src/app/menu/results/FigmaFeedbackContent.tsx
Normal file
122
src/app/menu/results/FigmaFeedbackContent.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: () => void;
|
||||||
|
learnerName?: string;
|
||||||
|
instructorName?: string;
|
||||||
|
scoreText?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const img = "http://localhost:3845/assets/7adf9a5e43b6c9e5f9bee6adfee64e85eabac44a.svg";
|
||||||
|
const img1 = "http://localhost:3845/assets/498f1d9877c6da3dadf581f98114a7f15bfc6769.svg";
|
||||||
|
|
||||||
|
export default function FigmaFeedbackContent({
|
||||||
|
onClose,
|
||||||
|
learnerName = "{학습자명}",
|
||||||
|
instructorName = "이공필",
|
||||||
|
scoreText = "{점수}",
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-white border border-[#dee1e6] border-solid box-border content-stretch flex flex-col items-center relative rounded-[12px] w-[640px] max-w-[90vw]"
|
||||||
|
data-name="Frame"
|
||||||
|
data-node-id="270:9120"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="box-border content-stretch flex gap-[10px] items-center overflow-clip p-[24px] relative shrink-0 w-full"
|
||||||
|
data-name="header"
|
||||||
|
data-node-id="270:9121"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="basis-0 flex flex-col font-['Pretendard:Bold',sans-serif] grow h-[32px] justify-center leading-[0] min-h-px min-w-px not-italic relative shrink-0 text-[#333c47] text-[20px]"
|
||||||
|
data-node-id="270:9123"
|
||||||
|
>
|
||||||
|
<p className="leading-[1.5]">{`${learnerName}님의 피드백`}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="닫기"
|
||||||
|
onClick={onClose}
|
||||||
|
className="overflow-clip relative shrink-0 size-[24px]"
|
||||||
|
data-name="x-close-lg"
|
||||||
|
data-node-id="270:9124"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute left-[calc(50%-0.02px)] size-[15.167px] top-[calc(50%-0.02px)] translate-x-[-50%] translate-y-[-50%]"
|
||||||
|
data-name="Icon (Stroke)"
|
||||||
|
data-node-id="I270:9124;1422:243"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{ "--fill-0": "rgba(51, 60, 71, 1)" } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<img alt="" className="block max-w-none size-full" src={img} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="box-border content-stretch flex flex-col gap-[16px] items-center px-[24px] py-0 relative shrink-0 w-full"
|
||||||
|
data-node-id="270:9126"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-gray-50 border border-[#dee1e6] border-solid box-border content-stretch flex flex-col gap-[8px] items-start justify-center p-[24px] relative rounded-[16px] shrink-0 w-full"
|
||||||
|
data-node-id="270:9127"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="content-stretch flex gap-[10px] items-center justify-center relative shrink-0"
|
||||||
|
data-node-id="270:9128"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="font-['Pretendard:Bold',sans-serif] h-[23px] leading-[1.5] not-italic relative shrink-0 text-[#4c5561] text-[15px] w-full"
|
||||||
|
data-node-id="270:9129"
|
||||||
|
>
|
||||||
|
평가 강사: {instructorName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="font-['Pretendard:Regular',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#4c5561] text-[15px] whitespace-pre-wrap"
|
||||||
|
data-node-id="270:9130"
|
||||||
|
>
|
||||||
|
{`- ${learnerName}님, 총점 ${scoreText}점으로 합격하셨습니다. 학습 흐름이 안정적이며 이해도가 좋습니다.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="box-border content-stretch flex flex-col gap-[32px] h-[96px] items-center p-[24px] relative shrink-0 w-full"
|
||||||
|
data-node-id="270:9131"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="basis-0 content-stretch flex gap-[12px] grow items-start justify-center min-h-px min-w-px overflow-clip relative shrink-0 w-full"
|
||||||
|
data-name="Actions"
|
||||||
|
data-node-id="270:9132"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="bg-[#1f2b91] box-border content-stretch flex flex-col h-[48px] items-center justify-center px-[16px] py-0 relative rounded-[10px] shrink-0 w-[284px] cursor-pointer"
|
||||||
|
data-name="Primary"
|
||||||
|
data-node-id="270:9134"
|
||||||
|
>
|
||||||
|
<div className="h-0 relative shrink-0 w-[82px]" data-name="Min Width" data-node-id="I270:9134;4356:4636">
|
||||||
|
<img alt="" className="block max-w-none size-full" src={img1} />
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="font-['Pretendard:SemiBold',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[16px] text-center text-nowrap text-white whitespace-pre"
|
||||||
|
data-node-id="I270:9134;4356:4637"
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,12 +1,156 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import FeedbackModal from "./FeedbackModal";
|
||||||
|
import CertificateModal from "./CertificateModal";
|
||||||
|
|
||||||
|
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() {
|
export default function ResultsPage() {
|
||||||
|
const [isFeedbackOpen, setIsFeedbackOpen] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<ResultRow | null>(null);
|
||||||
|
const [isCertOpen, setIsCertOpen] = useState(false);
|
||||||
|
|
||||||
|
function openFeedback(row: ResultRow) {
|
||||||
|
setSelected(row);
|
||||||
|
setIsFeedbackOpen(true);
|
||||||
|
}
|
||||||
|
function openCertificate(row: ResultRow) {
|
||||||
|
setSelected(row);
|
||||||
|
setIsCertOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex w-full flex-col">
|
<main className="flex w-full flex-col">
|
||||||
<div className="flex h-[100px] items-center px-8">
|
<div className="flex h-[100px] items-center px-8">
|
||||||
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">학습 결과</h1>
|
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">학습 결과</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-8 pb-20">
|
|
||||||
<p className="text-[16px] leading-[1.5] text-[#4c5561]">콘텐츠 준비 중입니다.</p>
|
<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="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 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openFeedback(row)}
|
||||||
|
className="text-[12px] text-blue-500 underline underline-offset-[3px] cursor-pointer"
|
||||||
|
>
|
||||||
|
확인하기
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="border-t border-[#dee1e6] px-4 text-center text-[15px] leading-[1.5] text-[#1b2027]">
|
||||||
|
{row.certificateLink ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openCertificate(row)}
|
||||||
|
className="text-[12px] text-blue-500 underline underline-offset-[3px] cursor-pointer"
|
||||||
|
>
|
||||||
|
확인하기
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<FeedbackModal
|
||||||
|
open={isFeedbackOpen}
|
||||||
|
onClose={() => setIsFeedbackOpen(false)}
|
||||||
|
learnerName="홍길동"
|
||||||
|
instructorName={selected?.instructor}
|
||||||
|
scoreText={selected?.score?.split(" / ")[0] ?? "0"}
|
||||||
|
/>
|
||||||
|
<CertificateModal
|
||||||
|
open={isCertOpen}
|
||||||
|
onClose={() => setIsCertOpen(false)}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
109
src/app/notices/[id]/page.tsx
Normal file
109
src/app/notices/[id]/page.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import BackCircleSvg from '../../svgs/backcirclesvg';
|
||||||
|
|
||||||
|
type NoticeItem = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
views: number;
|
||||||
|
writer: string;
|
||||||
|
content: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const DATA: NoticeItem[] = [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: '공지사항 제목이 노출돼요',
|
||||||
|
date: '2025-09-10',
|
||||||
|
views: 1230,
|
||||||
|
writer: '문지호',
|
||||||
|
content: [
|
||||||
|
'사이트 이용 관련 주요 변경 사항을 안내드립니다.',
|
||||||
|
'변경되는 내용은 공지일자로부터 즉시 적용됩니다.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: '📢 방사선학 온라인 강의 수강 안내 및 필수 공지',
|
||||||
|
date: '2025-06-28',
|
||||||
|
views: 594,
|
||||||
|
writer: '문지호',
|
||||||
|
content: [
|
||||||
|
'온라인 강의 수강 방법과 필수 확인 사항을 안내드립니다.',
|
||||||
|
'수강 기간 및 출석, 과제 제출 관련 정책을 반드시 확인해 주세요.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default async function NoticeDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const numericId = Number(id);
|
||||||
|
const item = DATA.find((r) => r.id === numericId);
|
||||||
|
if (!item) return notFound();
|
||||||
|
|
||||||
|
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 gap-3 px-8">
|
||||||
|
<Link
|
||||||
|
href="/notices"
|
||||||
|
aria-label="뒤로 가기"
|
||||||
|
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline"
|
||||||
|
>
|
||||||
|
<BackCircleSvg width={32} height={32} />
|
||||||
|
</Link>
|
||||||
|
<h1 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">
|
||||||
|
공지사항 상세
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카드 */}
|
||||||
|
<section className="px-8 pb-8">
|
||||||
|
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="p-8">
|
||||||
|
<h2 className="m-0 text-[20px] font-bold leading-normal text-[#333C47]">
|
||||||
|
{item.title}
|
||||||
|
</h2>
|
||||||
|
<div className="mt-2 flex items-center gap-4 text-[13px] leading-[1.4]">
|
||||||
|
<span className="text-[#8C95A1]">작성자</span>
|
||||||
|
<span className="text-[#333C47]">{item.writer}</span>
|
||||||
|
<span className="w-px h-4 bg-[#DEE1E6]" />
|
||||||
|
<span className="text-[#8C95A1]">게시일</span>
|
||||||
|
<span className="text-[#333C47]">{item.date}</span>
|
||||||
|
<span className="w-px h-4 bg-[#DEE1E6]" />
|
||||||
|
<span className="text-[#8C95A1]">조회수</span>
|
||||||
|
<span className="text-[#333C47]">{item.views.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="h-px bg-[#DEE1E6] w-full" />
|
||||||
|
|
||||||
|
{/* 본문 */}
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="text-[15px] leading-normal text-[#333C47] space-y-2">
|
||||||
|
{item.content.map((p, idx) => (
|
||||||
|
<p key={idx} className="m-0">
|
||||||
|
{p}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
124
src/app/notices/page.tsx
Normal file
124
src/app/notices/page.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import PaperClipSvg from '../svgs/paperclipsvg';
|
||||||
|
|
||||||
|
type NoticeRow = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
views: number;
|
||||||
|
writer: string;
|
||||||
|
hasAttachment?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows: NoticeRow[] = [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: '공지사항 제목이 노출돼요',
|
||||||
|
date: '2025-09-10',
|
||||||
|
views: 1230,
|
||||||
|
writer: '문지호',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: '📢 방사선학 온라인 강의 수강 안내 및 필수 공지',
|
||||||
|
date: '2025-06-28',
|
||||||
|
views: 594,
|
||||||
|
writer: '문지호',
|
||||||
|
hasAttachment: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function NoticesPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
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] whitespace-nowrap">
|
||||||
|
번호
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
||||||
|
제목
|
||||||
|
</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}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => router.push(`/notices/${r.id}`)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
router.push(`/notices/${r.id}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={[
|
||||||
|
'grid grid-cols-[57px_1fr_140px_140px_140px] h-[48px] text-[15px] font-medium text-[#1B2027] border-t border-[#DEE1E6] hover:bg-[rgba(236,240,255,0.5)] cursor-pointer',
|
||||||
|
].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 && (
|
||||||
|
<PaperClipSvg width={16} height={16} className="shrink-0 text-[#8C95A1]" />
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
282
src/app/page.tsx
282
src/app/page.tsx
@@ -3,10 +3,12 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import MainLogoSvg from './svgs/mainlogosvg';
|
import MainLogoSvg from './svgs/mainlogosvg';
|
||||||
|
import ChevronDownSvg from './svgs/chevrondownsvg';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [isNameActive, setIsNameActive] = useState(false);
|
||||||
|
|
||||||
// 코스, 공지사항 더미 데이터
|
// 코스, 공지사항 더미 데이터
|
||||||
const courseCards = useMemo(
|
const courseCards = useMemo(
|
||||||
@@ -16,71 +18,61 @@ export default function Home() {
|
|||||||
id: 'c1',
|
id: 'c1',
|
||||||
title: '원자력 운영 기초',
|
title: '원자력 운영 기초',
|
||||||
meta: 'VOD • 초급 • 4시간 20분',
|
meta: 'VOD • 초급 • 4시간 20분',
|
||||||
image:
|
image: 'https://picsum.photos/seed/xrlms-c1/1200/800',
|
||||||
'https://images.unsplash.com/photo-1555949963-ff9fe0c870eb?q=80&w=1200&auto=format&fit=crop',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'c2',
|
id: 'c2',
|
||||||
title: '반도체',
|
title: '반도체',
|
||||||
meta: 'VOD • 중급 • 3시간 10분',
|
meta: 'VOD • 중급 • 3시간 10분',
|
||||||
image:
|
image: 'https://picsum.photos/seed/xrlms-c2/1200/800',
|
||||||
'https://images.unsplash.com/photo-1581092921461-eab62e97a780?q=80&w=1200&auto=format&fit=crop',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'c3',
|
id: 'c3',
|
||||||
title: '방사선 안전',
|
title: '방사선 안전',
|
||||||
meta: 'VOD • 중급 • 4시간 20분',
|
meta: 'VOD • 중급 • 4시간 20분',
|
||||||
image:
|
image: 'https://picsum.photos/seed/xrlms-c3/1200/800',
|
||||||
'https://images.unsplash.com/photo-1581090464777-f3220bbe1b8b?q=80&w=1200&auto=format&fit=crop',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'c4',
|
id: 'c4',
|
||||||
title: '방사선 폐기물',
|
title: '방사선 폐기물',
|
||||||
meta: 'VOD • 중급 • 4시간 20분',
|
meta: 'VOD • 중급 • 4시간 20분',
|
||||||
image:
|
image: 'https://picsum.photos/seed/xrlms-c4/1200/800',
|
||||||
'https://images.unsplash.com/photo-1581091220351-5a6a4e6f22c1?q=80&w=1200&auto=format&fit=crop',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'c5',
|
id: 'c5',
|
||||||
title: '원자력 운전 개론',
|
title: '원자력 운전 개론',
|
||||||
meta: 'VOD • 초급 • 3시간 00분',
|
meta: 'VOD • 초급 • 3시간 00분',
|
||||||
image:
|
image: 'https://picsum.photos/seed/xrlms-c5/1200/800',
|
||||||
'https://images.unsplash.com/photo-1581090463520-5d09f3c456d2?q=80&w=1200&auto=format&fit=crop',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'c6',
|
id: 'c6',
|
||||||
title: '안전 표지와 표준',
|
title: '안전 표지와 표준',
|
||||||
meta: 'VOD • 초급 • 2시간 40분',
|
meta: 'VOD • 초급 • 2시간 40분',
|
||||||
image:
|
image: 'https://picsum.photos/seed/xrlms-c6/1200/800',
|
||||||
'https://images.unsplash.com/photo-1470167290877-7d5d3446de4c?q=80&w=1200&auto=format&fit=crop',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'c7',
|
id: 'c7',
|
||||||
title: '발전소 운영',
|
title: '발전소 운영',
|
||||||
meta: 'VOD • 중급 • 4시간 20분',
|
meta: 'VOD • 중급 • 4시간 20분',
|
||||||
image:
|
image: 'https://picsum.photos/seed/xrlms-c7/1200/800',
|
||||||
'https://images.unsplash.com/photo-1581092160562-40aa08e78837?q=80&w=1200&auto=format&fit=crop',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'c8',
|
id: 'c8',
|
||||||
title: '방사선 안전 실습',
|
title: '방사선 안전 실습',
|
||||||
meta: 'VOD • 중급 • 3시간 30분',
|
meta: 'VOD • 중급 • 3시간 30분',
|
||||||
image:
|
image: 'https://picsum.photos/seed/xrlms-c8/1200/800',
|
||||||
'https://images.unsplash.com/photo-1581093458791-9d181f5842fd?q=80&w=1200&auto=format&fit=crop',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'c9',
|
id: 'c9',
|
||||||
title: '실험실 안전',
|
title: '실험실 안전',
|
||||||
meta: 'VOD • 초급 • 2시간 10분',
|
meta: 'VOD • 초급 • 2시간 10분',
|
||||||
image:
|
image: 'https://picsum.photos/seed/xrlms-c9/1200/800',
|
||||||
'https://images.unsplash.com/photo-1559757175-08c6d5b3f4b4?q=80&w=1200&auto=format&fit=crop',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'c10',
|
id: 'c10',
|
||||||
title: '기초 장비 운용',
|
title: '기초 장비 운용',
|
||||||
meta: 'VOD • 초급 • 2시간 50분',
|
meta: 'VOD • 초급 • 2시간 50분',
|
||||||
image:
|
image: 'https://picsum.photos/seed/xrlms-c10/1200/800',
|
||||||
'https://images.unsplash.com/photo-1581092338398-16e5b28a2b13?q=80&w=1200&auto=format&fit=crop',
|
|
||||||
},
|
},
|
||||||
] as Array<{ id: string; title: string; meta: string; image: string }>,
|
] as Array<{ id: string; title: string; meta: string; image: string }>,
|
||||||
[]
|
[]
|
||||||
@@ -104,22 +96,19 @@ export default function Home() {
|
|||||||
id: 1,
|
id: 1,
|
||||||
title: '시스템 점검 안내',
|
title: '시스템 점검 안내',
|
||||||
description: '11월 10일 새벽 2시~4시 시스템 점검이 진행됩니다.',
|
description: '11월 10일 새벽 2시~4시 시스템 점검이 진행됩니다.',
|
||||||
imageSrc:
|
imageSrc: 'https://picsum.photos/seed/xrlms-slide1/1600/900',
|
||||||
'https://images.unsplash.com/photo-1581091226825-a6a2a5aee158?q=80&w=1600&auto=format&fit=crop',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
title: '신규 과정 오픈',
|
title: '신규 과정 오픈',
|
||||||
description: '최신 커리큘럼으로 업스킬링하세요.',
|
description: '최신 커리큘럼으로 업스킬링하세요.',
|
||||||
imageSrc:
|
imageSrc: 'https://picsum.photos/seed/xrlms-slide2/1600/900',
|
||||||
'https://images.unsplash.com/photo-1550602921-a0d9a4d5b1a9?q=80&w=1600&auto=format&fit=crop',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
title: '수강 이벤트',
|
title: '수강 이벤트',
|
||||||
description: '이번 달 수강 혜택을 확인해보세요.',
|
description: '이번 달 수강 혜택을 확인해보세요.',
|
||||||
imageSrc:
|
imageSrc: 'https://picsum.photos/seed/xrlms-slide3/1600/900',
|
||||||
'https://images.unsplash.com/photo-1545235617-9465d2a55698?q=80&w=1600&auto=format&fit=crop',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[]
|
[]
|
||||||
@@ -152,29 +141,41 @@ export default function Home() {
|
|||||||
const handlePrev = () => scrollToIndex(currentIndex - 1);
|
const handlePrev = () => scrollToIndex(currentIndex - 1);
|
||||||
const handleNext = () => scrollToIndex(currentIndex + 1);
|
const handleNext = () => scrollToIndex(currentIndex + 1);
|
||||||
|
|
||||||
|
const handleNameClick = () => {
|
||||||
|
setIsNameActive((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full min-h-screen flex flex-col bg-white">
|
<div className="w-full min-h-screen flex flex-col bg-white">
|
||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
{/* 메인 컨테이너 */}
|
{/* 메인 컨테이너 */}
|
||||||
<div className="w-full flex justify-center">
|
<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 className="relative">
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
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) => (
|
{slides.map((slide) => (
|
||||||
<div key={slide.id} className="flex-none w-full h-[360px] relative snap-start overflow-hidden">
|
<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" />
|
<img
|
||||||
<div className="absolute left-0 right-0 bottom-0 h-1/2 bg-linear-to-b from-transparent via-black/55 to-black/75" />
|
alt={slide.title}
|
||||||
<div className="absolute left-6 bottom-6 text-white">
|
src={slide.imageSrc}
|
||||||
<div className="font-bold text-[20px] leading-normal mb-1.5">{slide.title}</div>
|
className="w-full h-full object-cover block"
|
||||||
<div className="font-medium text-[14px] leading-normal opacity-95">
|
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}
|
{slide.description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,27 +189,28 @@ export default function Home() {
|
|||||||
onClick={handlePrev}
|
onClick={handlePrev}
|
||||||
aria-label="이전 배너"
|
aria-label="이전 배너"
|
||||||
disabled={currentIndex <= 0}
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
aria-label="다음 배너"
|
aria-label="다음 배너"
|
||||||
disabled={currentIndex >= slides.length - 1}
|
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>
|
</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) => (
|
{slides.map((_, idx) => (
|
||||||
<span
|
<span key={idx} className={idx === currentIndex ? 'h-2 w-8 rounded-full bg-white' : 'size-2 rounded-full bg-white/50'} />
|
||||||
key={idx}
|
|
||||||
className={'w-1.5 h-1.5 rounded-full ' + (idx === currentIndex ? 'bg-white' : 'bg-white/50')}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,12 +219,24 @@ export default function Home() {
|
|||||||
{/* 사이드 패널 (피그마 디자인 적용) */}
|
{/* 사이드 패널 (피그마 디자인 적용) */}
|
||||||
<aside className="flex-none w-[400px]">
|
<aside className="flex-none w-[400px]">
|
||||||
<div className="bg-[#F1F3F5] rounded-[12px] overflow-hidden h-[510px]">
|
<div className="bg-[#F1F3F5] rounded-[12px] overflow-hidden h-[510px]">
|
||||||
{/* 상단 환영 및 통계 */}
|
{/* 상단 환영 및 통계 (피그마 사이즈/간격 적용) */}
|
||||||
<div className="px-8 py-8">
|
<div className="px-8 py-8">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="text-[18px] leading-normal">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-bold text-[#333C47]">김하늘</span>
|
<button
|
||||||
<span className="font-normal text-[#333C47]">님 환영합니다.</span>
|
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>
|
</div>
|
||||||
<div className="flex items-center justify-center gap-6">
|
<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 },
|
{ label: '문제 제출', value: 0 },
|
||||||
{ label: '수료증 발급', value: 0 },
|
{ label: '수료증', value: 0 },
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<div key={s.label} className="w-[64px] flex flex-col items-center justify-center gap-2">
|
<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">
|
<div className="size-16 rounded-full bg-white flex items-center justify-center">
|
||||||
@@ -241,10 +255,10 @@ export default function Home() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 구분선 */}
|
{/* 구분선 (좌우 32px 여백, 1px 라인) */}
|
||||||
<div className="h-px bg-[#DEE1E6] mx-8" />
|
<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="h-[60px] w-full flex items-center justify-between">
|
||||||
<div className="text-[18px] font-bold text-[#1B2027]">최근 수강 내역</div>
|
<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">
|
<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>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<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>
|
||||||
{ 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,29 +278,60 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* 교육 과정 */}
|
{/* 교육 과정 */}
|
||||||
<section className="mt-8">
|
<section className="mt-8">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between h-[100px] px-0">
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<h2 className="m-0 text-[18px] font-bold text-[#333C47]">
|
<h2 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">교육 과정</h2>
|
||||||
교육 과정
|
<div className="text-[15px] font-medium leading-normal text-[#333C47]">
|
||||||
</h2>
|
총 <span className="text-[#384FBF]">28</span>건
|
||||||
<span className="text-[#8C95A1] text-[13px]">총 28건</span>
|
|
||||||
</div>
|
</div>
|
||||||
<a href="#" className="text-[#8C95A1] text-[13px] no-underline">
|
</div>
|
||||||
전체보기 ›
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-5 gap-4">
|
<div className="grid grid-cols-5 gap-8">
|
||||||
{courseCards.map((c) => (
|
{courseCards.map((c) => (
|
||||||
<article key={c.id} className="bg-white border border-[#DEE1E6] rounded-[10px] overflow-hidden">
|
<article key={c.id} className="flex flex-col gap-4 h-[260px]">
|
||||||
<div className="w-full h-[120px] overflow-hidden">
|
<div className="h-[166.4px] overflow-hidden rounded-[8px]">
|
||||||
<img alt={c.title} src={c.image} className="w-full h-full object-cover" />
|
<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>
|
||||||
<div className="p-2.5">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="text-[#333C47] font-semibold text-[14px] leading-[1.35] mb-1.5" title={c.title}>
|
{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}
|
{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>
|
||||||
<div className="text-[#8C95A1] text-[12px]">{c.meta}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
@@ -306,43 +340,56 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* 공지사항 */}
|
{/* 공지사항 */}
|
||||||
<section className="mt-9">
|
<section className="mt-9">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<h2 className="m-0 text-[16px] font-bold text-[#333C47]">
|
<h2 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">공지사항</h2>
|
||||||
공지사항
|
<div className="text-[15px] font-medium leading-normal text-[#333C47]">
|
||||||
</h2>
|
총 <span className="text-[#384FBF]">{noticeRows.length}</span>건
|
||||||
<span className="text-[#8C95A1] text-[13px]">총 102건</span>
|
|
||||||
</div>
|
</div>
|
||||||
<a href="#" className="text-[#8C95A1] text-[13px] no-underline">
|
</div>
|
||||||
전체보기 ›
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white border border-[#DEE1E6] rounded-[10px] overflow-hidden">
|
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
|
||||||
<div className="grid grid-cols-[80px_1fr_140px_100px_90px] gap-0 bg-[#F9FAFB] text-[#6C7682] text-[13px] border-b border-[#DEE1E6]">
|
<div className="grid grid-cols-[57px_1fr_140px_140px_140px] bg-[#F9FAFB] h-[48px] text-[14px] font-semibold text-[#4C5561] border-b border-[#DEE1E6]">
|
||||||
{['번호', '제목', '작성일', '조회수', '작성자'].map((h) => (
|
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">번호</div>
|
||||||
<div key={h} className="py-2.5 px-3">
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">제목</div>
|
||||||
{h}
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">게시일</div>
|
||||||
</div>
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">조회수</div>
|
||||||
))}
|
<div className="flex items-center px-4">작성자</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{noticeRows.map((r, idx) => (
|
{noticeRows.map((r) => (
|
||||||
<div
|
<div
|
||||||
key={r.id}
|
key={r.id}
|
||||||
className={
|
className="grid grid-cols-[57px_1fr_140px_140px_140px] h-[48px] text-[15px] font-medium text-[#1B2027] border-t border-[#DEE1E6]"
|
||||||
'grid grid-cols-[80px_1fr_140px_100px_90px] gap-0 text-[14px] text-[#4C5561] ' +
|
>
|
||||||
(idx === noticeRows.length - 1 ? '' : 'border-b border-[#F1F3F5]')
|
<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}
|
||||||
>
|
>
|
||||||
<div className="py-3 px-3">{r.id}</div>
|
|
||||||
<div className="py-3 px-3 whitespace-nowrap overflow-hidden text-ellipsis" title={r.title}>
|
|
||||||
{r.title}
|
{r.title}
|
||||||
</div>
|
</div>
|
||||||
<div className="py-3 px-3">{r.date}</div>
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">{r.date}</div>
|
||||||
<div className="py-3 px-3">{r.views}</div>
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">{r.views.toLocaleString()}</div>
|
||||||
<div className="py-3 px-3">{r.writer}</div>
|
<div className="flex items-center px-4">{r.writer}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -352,28 +399,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* 푸터 */}
|
{/* 전역 Footer는 layout.tsx에서 렌더링됩니다. */}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
194
src/app/resources/[id]/page.tsx
Normal file
194
src/app/resources/[id]/page.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import PaperClipSvg from '../../svgs/paperclipsvg';
|
||||||
|
import BackCircleSvg from '../../svgs/backcirclesvg';
|
||||||
|
|
||||||
|
type ResourceRow = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
views: number;
|
||||||
|
writer: string;
|
||||||
|
content: string[];
|
||||||
|
attachments?: Array<{ name: string; size: string; url: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DATA: ResourceRow[] = [
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
title: '방사선과 물질의 상호작용 관련 학습 자료',
|
||||||
|
date: '2025-06-28',
|
||||||
|
views: 1230,
|
||||||
|
writer: '강민재',
|
||||||
|
content: [
|
||||||
|
'방사선(Radiation)이 물질 속을 지나갈 때 발생하는 다양한 상호작용(Interaction)의 기본적인 원리를 이해합니다.',
|
||||||
|
'상호작용의 원리는 방사선 측정, 방사선 이용(의료, 산업), 방사선 차폐 등 방사선 관련 분야의 기본이 됨을 인식합니다.',
|
||||||
|
'방사선의 종류(광자, 하전입자, 중성자 등) 및 에너지에 따라 물질과의 상호작용 형태가 어떻게 달라지는지 학습합니다.',
|
||||||
|
],
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
name: '[PPT] 방사선-물질 상호작용의 3가지 유형.pptx',
|
||||||
|
size: '796.35 KB',
|
||||||
|
url: '#',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: '감마선과 베타선의 특성 및 차이 분석 자료',
|
||||||
|
date: '2025-06-28',
|
||||||
|
views: 594,
|
||||||
|
writer: '강민재',
|
||||||
|
content: [
|
||||||
|
'감마선과 베타선의 발생 원리, 물질과의 상호작용 차이를 비교합니다.',
|
||||||
|
'차폐 설계 시 고려해야 할 변수들을 사례와 함께 설명합니다.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: '방사선량 단위(Sv, Gy) 비교 및 계산 예제',
|
||||||
|
date: '2025-06-28',
|
||||||
|
views: 1230,
|
||||||
|
writer: '강민재',
|
||||||
|
content: ['방사선량 단위 변환 및 예제 계산을 통해 실무 감각을 익힙니다.'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: '의료 영상 촬영 시 방사선 안전 수칙 가이드',
|
||||||
|
date: '2025-06-28',
|
||||||
|
views: 1230,
|
||||||
|
writer: '강민재',
|
||||||
|
content: ['촬영 환경에서의 방사선 안전 수칙을 체크리스트 형태로 정리합니다.'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'X선 발생 원리 및 특성에 대한 이해 자료',
|
||||||
|
date: '2025-06-28',
|
||||||
|
views: 1230,
|
||||||
|
writer: '강민재',
|
||||||
|
content: ['X선의 발생 원리와 에너지 스펙트럼의 특성을 개관합니다.'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: '방사선의 기초 개념과 물질과의 상호작용 정리 자료',
|
||||||
|
date: '2025-06-28',
|
||||||
|
views: 1230,
|
||||||
|
writer: '강민재',
|
||||||
|
content: ['방사선 기초 개념을 한눈에 정리한 입문용 자료입니다.'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default async function ResourceDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const numericId = Number(id);
|
||||||
|
const item = DATA.find((r) => r.id === numericId);
|
||||||
|
if (!item) return notFound();
|
||||||
|
|
||||||
|
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 gap-3 px-8">
|
||||||
|
<Link
|
||||||
|
href="/resources"
|
||||||
|
aria-label="뒤로 가기"
|
||||||
|
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline"
|
||||||
|
>
|
||||||
|
<BackCircleSvg width={32} height={32} />
|
||||||
|
</Link>
|
||||||
|
<h1 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">
|
||||||
|
학습 자료 상세
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카드 */}
|
||||||
|
<section className="px-8 pb-8">
|
||||||
|
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="p-8">
|
||||||
|
<h2 className="m-0 text-[20px] font-bold leading-normal text-[#333C47]">
|
||||||
|
{item.title}
|
||||||
|
</h2>
|
||||||
|
<div className="mt-2 flex items-center gap-4 text-[13px] leading-[1.4]">
|
||||||
|
<span className="text-[#8C95A1]">작성자</span>
|
||||||
|
<span className="text-[#333C47]">{item.writer}</span>
|
||||||
|
<span className="w-px h-4 bg-[#DEE1E6]" />
|
||||||
|
<span className="text-[#8C95A1]">게시일</span>
|
||||||
|
<span className="text-[#333C47]">{item.date}</span>
|
||||||
|
<span className="w-px h-4 bg-[#DEE1E6]" />
|
||||||
|
<span className="text-[#8C95A1]">조회수</span>
|
||||||
|
<span className="text-[#333C47]">{item.views.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="h-px bg-[#DEE1E6] w-full" />
|
||||||
|
|
||||||
|
{/* 본문 */}
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="text-[15px] leading-normal text-[#333C47] space-y-2">
|
||||||
|
{item.content.map((p, idx) => (
|
||||||
|
<p key={idx} className="m-0">
|
||||||
|
{p}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 첨부 파일 */}
|
||||||
|
{item.attachments?.length ? (
|
||||||
|
<div className="p-8 pt-0">
|
||||||
|
<div className="mb-2 text-[15px] font-semibold text-[#6C7682]">
|
||||||
|
첨부 파일
|
||||||
|
</div>
|
||||||
|
{item.attachments.map((f, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="bg-white border border-[#DEE1E6] h-[64px] rounded-[6px] flex items-center gap-3 px-[17px]"
|
||||||
|
>
|
||||||
|
<div className="text-[#8C95A1]">
|
||||||
|
<PaperClipSvg width={20} height={20} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-[15px] text-[#1B2027] truncate">
|
||||||
|
{f.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[13px] text-[#8C95A1] whitespace-nowrap">
|
||||||
|
{f.size}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={f.url}
|
||||||
|
className="h-8 px-4 rounded-[6px] border border-[#8C95A1] text-[13px] text-[#4C5561] inline-flex items-center gap-1 hover:bg-[#F9FAFB] no-underline"
|
||||||
|
download
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden>
|
||||||
|
<path
|
||||||
|
d="M12 3v12m0 0l-4-4m4 4l4-4M5 21h14"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
다운로드
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
151
src/app/resources/page.tsx
Normal file
151
src/app/resources/page.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import PaperClipSvg from '../svgs/paperclipsvg';
|
||||||
|
|
||||||
|
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() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
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] whitespace-nowrap">
|
||||||
|
번호
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
||||||
|
제목
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
||||||
|
게시일
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
||||||
|
조회수
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center px-4 whitespace-nowrap">등록자</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 바디 */}
|
||||||
|
<div>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => router.push(`/resources/${r.id}`)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
router.push(`/resources/${r.id}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={[
|
||||||
|
'grid grid-cols-[57px_1fr_140px_140px_140px] h-[48px] text-[15px] font-medium text-[#1B2027] border-t border-[#DEE1E6] hover:bg-[rgba(236,240,255,0.5)] cursor-pointer',
|
||||||
|
].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 && (
|
||||||
|
<PaperClipSvg width={16} height={16} className="shrink-0 text-[#8C95A1]" />
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
46
src/app/svgs/backcirclesvg.tsx
Normal file
46
src/app/svgs/backcirclesvg.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
export default function BackCircleSvg(
|
||||||
|
{
|
||||||
|
width = 32,
|
||||||
|
height = 32,
|
||||||
|
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 32 32"
|
||||||
|
fill="none"
|
||||||
|
className={className}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M16 4C22.628 4 28 9.372 28 16C28 22.628 22.628 28 16 28C9.372 28 4 22.628 4 16C4 9.372 9.372 4 16 4Z"
|
||||||
|
fill="#8C95A1"
|
||||||
|
stroke="#8C95A1"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M10.666 15.9999H21.3327"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M14.666 20L10.666 16L14.666 12"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
28
src/app/svgs/chevrondownsvg.tsx
Normal file
28
src/app/svgs/chevrondownsvg.tsx
Normal 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="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
16
src/app/svgs/closexsvg.tsx
Normal file
16
src/app/svgs/closexsvg.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
29
src/app/svgs/paperclipsvg.tsx
Normal file
29
src/app/svgs/paperclipsvg.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export default function PaperClipSvg(
|
||||||
|
{
|
||||||
|
width = 16,
|
||||||
|
height = 16,
|
||||||
|
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 16 16"
|
||||||
|
fill="none"
|
||||||
|
className={className}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M8.9721 6.2539L5.2839 9.94144C4.85621 10.3693 4.85621 11.0629 5.2839 11.4908V11.4908C5.71179 11.9184 6.40532 11.9184 6.83321 11.4908L12.5863 5.73769C12.9972 5.3268 13.2281 4.76949 13.2281 4.18838C13.2281 3.60726 12.9972 3.04995 12.5863 2.63906V2.63906C12.1755 2.22817 11.6183 1.99731 11.0373 1.99731C10.4563 1.99731 9.89908 2.22817 9.48831 2.63906L3.73525 8.39279C3.1188 9.00896 2.77246 9.84483 2.77246 10.7164C2.77246 11.588 3.1188 12.4239 3.73525 13.0401V13.0401C4.35151 13.6563 5.18735 14.0026 6.05889 14.0026C6.93042 14.0026 7.76626 13.6563 8.38252 13.0401L12.5843 8.83831"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.25"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user