강사페이지

This commit is contained in:
2025-11-28 21:45:31 +09:00
parent 5a26d96386
commit 5e4337c7e8
2 changed files with 426 additions and 19 deletions

View File

@@ -12,15 +12,23 @@ const NAV_ITEMS = [
{ label: "공지사항", href: "/notices" }, { label: "공지사항", href: "/notices" },
]; ];
const INSTRUCTOR_NAV_ITEMS = [
{ label: "강좌 현황", href: "/admin/courses" },
{ label: "학습 자료실", href: "/admin/resources" },
{ label: "공지사항", href: "/admin/notices" },
];
export default function NavBar() { export default function NavBar() {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const [userName, setUserName] = useState<string>(''); const [userName, setUserName] = useState<string>('');
const [userRole, setUserRole] = useState<string>('');
const userMenuRef = useRef<HTMLDivElement | null>(null); const userMenuRef = useRef<HTMLDivElement | null>(null);
const userButtonRef = useRef<HTMLButtonElement | null>(null); const userButtonRef = useRef<HTMLButtonElement | null>(null);
const hideCenterNav = /^\/[^/]+\/review$/.test(pathname); const hideCenterNav = /^\/[^/]+\/review$/.test(pathname);
const isAdminPage = pathname.startsWith('/admin'); const isAdminPage = pathname.startsWith('/admin');
const isInstructorPage = pathname === '/instructor';
// 사용자 정보 가져오기 및 비활성화 계정 체크 // 사용자 정보 가져오기 및 비활성화 계정 체크
useEffect(() => { useEffect(() => {
@@ -86,8 +94,12 @@ export default function NavBar() {
return; return;
} }
if (isMounted && data.name) { if (isMounted) {
setUserName(data.name); const role = data.role || data.userRole || '';
setUserRole(role);
if (data.name) {
setUserName(data.name);
}
} }
} catch (error) { } catch (error) {
console.error('사용자 정보 조회 오류:', error); console.error('사용자 정보 조회 오류:', error);
@@ -129,11 +141,30 @@ export default function NavBar() {
<header className="bg-[#060958] h-20"> <header className="bg-[#060958] h-20">
<div className="mx-auto flex h-full w-full max-w-[1440px] items-center justify-between px-8"> <div className="mx-auto flex h-full w-full max-w-[1440px] items-center justify-between px-8">
<div className="flex flex-1 items-center gap-9"> <div className="flex flex-1 items-center gap-9">
<Link href="/" aria-label="XR LMS 홈" className="flex items-center gap-2"> <Link
href={(userRole === 'ADMIN' || userRole === 'admin') ? "/instructor" : "/"}
aria-label="XR LMS 홈"
className="flex items-center gap-2"
>
<MainLogoSvg width={46.703} height={36} /> <MainLogoSvg width={46.703} height={36} />
<span className="text-2xl font-extrabold leading-[1.45] text-white">XR LMS</span> <span className="text-2xl font-extrabold leading-[1.45] text-white">XR LMS</span>
</Link> </Link>
{!hideCenterNav && !isAdminPage && ( {!hideCenterNav && !isAdminPage && isInstructorPage && (
<nav className="flex h-full items-center">
{INSTRUCTOR_NAV_ITEMS.map((item) => {
return (
<Link
key={item.href}
href={item.href}
className={["px-4 py-2 text-[16px] font-semibold text-white"].join(" ")}
>
{item.label}
</Link>
);
})}
</nav>
)}
{!hideCenterNav && !isAdminPage && !isInstructorPage && (
<nav className="flex h-full items-center"> <nav className="flex h-full items-center">
{NAV_ITEMS.map((item) => { {NAV_ITEMS.map((item) => {
return ( return (
@@ -150,26 +181,47 @@ export default function NavBar() {
)} )}
</div> </div>
<div className="relative flex items-center gap-2"> <div className="relative flex items-center gap-2">
{isAdminPage ? ( {(isAdminPage || isInstructorPage) ? (
<> <>
<Link href="/menu/account" className="px-4 py-2 text-[16px] font-semibold text-white">
</Link>
<button <button
ref={userButtonRef}
type="button" type="button"
onClick={() => { onClick={() => setIsUserMenuOpen((v) => !v)}
// 로컬 스토리지에서 토큰 제거 aria-haspopup="menu"
localStorage.removeItem('token'); aria-expanded={isUserMenuOpen}
localStorage.removeItem('user'); className="flex items-center gap-1 px-4 py-2 text-[16px] font-semibold text-white cursor-pointer"
// 쿠키에서 토큰 제거
document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
// 로그인 페이지로 리다이렉트
window.location.href = '/login';
}}
className="px-4 py-2 text-[16px] font-semibold text-white cursor-pointer"
> >
{userName || '사용자'}
<ChevronDownSvg
width={16}
height={16}
className={["transition-transform", isUserMenuOpen ? "rotate-180" : "rotate-0"].join(" ")}
/>
</button> </button>
{isUserMenuOpen && (
<div
ref={userMenuRef}
role="menu"
aria-label="사용자 메뉴"
className="absolute right-0 top-full mt-2 bg-white rounded-lg shadow-[0_0_8px_0_rgba(0,0,0,0.25)] p-3 z-50"
>
<button
role="menuitem"
className="flex items-center w-[136px] 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={() => {
// 로컬 스토리지에서 토큰 제거
localStorage.removeItem('token');
localStorage.removeItem('user');
// 쿠키에서 토큰 제거
document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
// 로그인 페이지로 리다이렉트
window.location.href = '/login';
}}
>
</button>
</div>
)}
</> </>
) : ( ) : (
<> <>

355
src/app/instructor/page.tsx Normal file
View File

@@ -0,0 +1,355 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import MainLogoSvg from '../svgs/mainlogosvg';
import ChevronDownSvg from '../svgs/chevrondownsvg';
// 아이콘 컴포넌트들
function BookIcon({ className }: { className?: string }) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M4 19.5C4 18.837 4.263 18.201 4.732 17.732C5.201 17.263 5.837 17 6.5 17H20"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M6.5 2H20V22H6.5C5.837 22 5.201 21.737 4.732 21.268C4.263 20.799 4 20.163 4 19.5V4.5C4 3.837 4.263 3.201 4.732 2.732C5.201 2.263 5.837 2 6.5 2Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function DocumentIcon({ className }: { className?: string }) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M14 2H6C5.46957 2 4.96086 2.21071 4.58579 2.58579C4.21071 2.96086 4 3.46957 4 4V20C4 20.5304 4.21071 21.0391 4.58579 21.4142C4.96086 21.7893 5.46957 22 6 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V8L14 2Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M14 2V8H20"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16 13H8"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16 17H8"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10 9H9H8"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function CheckCircleIcon({ className }: { className?: string }) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M22 11.08V12C21.9988 14.1564 21.3005 16.2547 20.0093 17.9818C18.7182 19.7088 16.9033 20.9725 14.8354 21.5839C12.7674 22.1953 10.5573 22.1219 8.53447 21.3746C6.51168 20.6273 4.78465 19.2461 3.61096 17.4371C2.43727 15.628 1.87979 13.4881 2.02168 11.3363C2.16356 9.18455 2.99721 7.13631 4.39828 5.49706C5.79935 3.85781 7.69279 2.71537 9.79619 2.24013C11.8996 1.7649 14.1003 1.98232 16.07 2.85999"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M22 4L12 14.01L9 11.01"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function UserIcon({ className }: { className?: string }) {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M8 8C9.65685 8 11 6.65685 11 5C11 3.34315 9.65685 2 8 2C6.34315 2 5 3.34315 5 5C5 6.65685 6.34315 8 8 8Z"
fill="currentColor"
/>
<path
d="M2 13.3333C2 11.0862 3.75333 9.33333 6 9.33333H10C12.2467 9.33333 14 11.0862 14 13.3333V14H2V13.3333Z"
fill="currentColor"
/>
</svg>
);
}
function ChevronRightIcon({ className }: { className?: string }) {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M6 12L10 8L6 4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
type Activity = {
id: string;
userName: string;
message: string;
timestamp: string;
};
export default function InstructorPage() {
const router = useRouter();
const [userName, setUserName] = useState<string>('');
const [userRole, setUserRole] = useState<string>('');
const [totalCourses, setTotalCourses] = useState<number>(5);
const [submissionStatus, setSubmissionStatus] = useState<{ current: number; total: number }>({ current: 10, total: 50 });
const [completionStatus, setCompletionStatus] = useState<{ current: number; total: number }>({ current: 14, total: 50 });
const [activities, setActivities] = useState<Activity[]>([
{ id: '1', userName: '김하늘', message: '{강좌명} 문제를 제출했습니다.', timestamp: '2025-12-12 14:44' },
{ id: '2', userName: '김하늘', message: '{강좌명} 문제를 제출했습니다.', timestamp: '2025-12-12 14:44' },
{ id: '3', userName: '김하늘', message: '모든 강좌를 수강했습니다.', timestamp: '2025-12-12 14:44' },
]);
// 사용자 정보 가져오기
useEffect(() => {
let isMounted = true;
async function fetchUserInfo() {
try {
const localStorageToken = localStorage.getItem('token');
const cookieToken = document.cookie
.split('; ')
.find(row => row.startsWith('token='))
?.split('=')[1];
const token = localStorageToken || cookieToken;
if (!token) {
router.push('/login');
return;
}
const apiUrl = process.env.NEXT_PUBLIC_API_BASE_URL
? `${process.env.NEXT_PUBLIC_API_BASE_URL}/auth/me`
: 'https://hrdi.coconutmeet.net/auth/me';
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
if (response.status === 401) {
localStorage.removeItem('token');
document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
if (isMounted) {
router.push('/login');
}
}
return;
}
const data = await response.json();
if (isMounted) {
const role = data.role || data.userRole || '';
setUserRole(role);
// admin이 아니면 접근 불가
if (role !== 'ADMIN' && role !== 'admin') {
router.push('/');
return;
}
if (data.name) {
setUserName(data.name);
}
}
} catch (error) {
console.error('사용자 정보 조회 오류:', error);
if (isMounted) {
router.push('/login');
}
}
}
fetchUserInfo();
return () => {
isMounted = false;
};
}, [router]);
return (
<div className="bg-white min-h-screen flex flex-col">
<div className="flex-1 max-w-[1440px] w-full mx-auto px-0">
<div className="flex flex-col gap-[40px] w-full">
{/* 강좌별 상세 내역 섹션 */}
<div className="flex flex-col w-full">
<div className="flex h-[100px] items-center justify-between px-[32px]">
<h2 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
</h2>
<Link
href="/admin/courses"
className="flex items-center gap-[2px] text-[14px] font-medium text-[#6c7682]"
>
<span></span>
<ChevronRightIcon />
</Link>
</div>
<div className="flex flex-col gap-[16px] pb-[32px] pt-0 px-[32px]">
<div className="flex gap-[16px] h-[120px]">
{/* 총 강좌 수 카드 */}
<div className="flex-1 bg-white border border-[#dee1e6] rounded-[16px] flex gap-[16px] items-center justify-center p-[24px]">
<div className="bg-[#ecf0ff] rounded-full size-[48px] flex items-center justify-center shrink-0">
<BookIcon className="size-[24px] text-[#060958]" />
</div>
<div className="flex-1 flex flex-col items-start">
<p className="text-[14px] font-normal text-[#333c47] leading-[1.5]">
</p>
<p className="text-[20px] font-bold text-[#333c47] leading-[1.5]">
{totalCourses}
</p>
</div>
</div>
{/* 학습자 문제 제출 현황 카드 */}
<div className="flex-1 bg-white border border-[#dee1e6] rounded-[16px] flex gap-[16px] items-center justify-center p-[24px]">
<div className="bg-[#ecf0ff] rounded-full size-[48px] flex items-center justify-center shrink-0">
<DocumentIcon className="size-[24px] text-[#060958]" />
</div>
<div className="flex-1 flex flex-col items-start">
<p className="text-[14px] font-normal text-[#333c47] leading-[1.5]">
</p>
<p className="text-[20px] font-bold text-[#333c47] leading-[1.5]">
{submissionStatus.current} / {submissionStatus.total}
</p>
</div>
</div>
{/* 학습자 수료 현황 카드 */}
<div className="flex-1 bg-white border border-[#dee1e6] rounded-[16px] flex gap-[16px] items-center justify-center p-[24px]">
<div className="bg-[#ecf0ff] rounded-full size-[48px] flex items-center justify-center shrink-0">
<CheckCircleIcon className="size-[24px] text-[#060958]" />
</div>
<div className="flex-1 flex flex-col items-start">
<p className="text-[14px] font-normal text-[#333c47] leading-[1.5]">
</p>
<p className="text-[20px] font-bold text-[#333c47] leading-[1.5]">
{completionStatus.current} / {completionStatus.total}
</p>
</div>
</div>
</div>
</div>
</div>
{/* 최근 학습자 활동 섹션 */}
<div className="flex flex-col w-full">
<div className="flex gap-[10px] h-[100px] items-center px-[32px]">
<h2 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
</h2>
</div>
<div className="flex flex-col gap-[16px] pb-[80px] pt-0 px-[32px]">
<div className="flex flex-col gap-[8px] min-h-[256px]">
{activities.map((activity) => (
<div
key={activity.id}
className="bg-white border border-[#dee1e6] rounded-[8px] flex gap-[12px] items-center p-[17px]"
>
<div className="bg-[#f1f3f5] rounded-full size-[32px] flex items-center justify-center shrink-0">
<UserIcon className="size-[16px] text-[#333c47]" />
</div>
<div className="flex-1 flex flex-col items-start">
<div className="flex flex-col text-[15px] leading-[1.5] text-[#1b2027]">
<span className="font-semibold">{activity.userName}</span>
<span className="font-normal">{activity.message}</span>
</div>
</div>
<p className="text-[13px] font-normal text-[#6c7682] leading-[1.4] shrink-0">
{activity.timestamp}
</p>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
}