2025-11-18 06:19:26 +09:00
|
|
|
/* eslint-disable @next/next/no-img-element */
|
|
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
2025-11-27 20:36:38 +09:00
|
|
|
import { useRouter } from 'next/navigation';
|
2025-12-01 09:56:04 +09:00
|
|
|
import Link from 'next/link';
|
2025-11-18 06:19:26 +09:00
|
|
|
import MainLogoSvg from './svgs/mainlogosvg';
|
2025-11-29 13:00:50 +09:00
|
|
|
import apiService from './lib/apiService';
|
2025-12-01 09:56:04 +09:00
|
|
|
import type { Notice } from './admin/notices/mockData';
|
|
|
|
|
|
|
|
|
|
interface Subject {
|
|
|
|
|
id: string | number;
|
|
|
|
|
title: string;
|
|
|
|
|
imageKey?: string;
|
|
|
|
|
instructor?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface CourseCard {
|
|
|
|
|
id: string | number;
|
|
|
|
|
title: string;
|
|
|
|
|
meta: string;
|
|
|
|
|
image: string;
|
|
|
|
|
}
|
2025-11-18 06:19:26 +09:00
|
|
|
|
2025-11-15 23:46:47 +09:00
|
|
|
export default function Home() {
|
2025-11-27 20:36:38 +09:00
|
|
|
const router = useRouter();
|
2025-11-18 06:19:26 +09:00
|
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
const [currentIndex, setCurrentIndex] = useState(0);
|
2025-11-27 00:45:55 +09:00
|
|
|
const [userName, setUserName] = useState<string>('');
|
2025-12-01 09:56:04 +09:00
|
|
|
const [subjects, setSubjects] = useState<Subject[]>([]);
|
|
|
|
|
const [courseCards, setCourseCards] = useState<CourseCard[]>([]);
|
|
|
|
|
const [loadingSubjects, setLoadingSubjects] = useState(true);
|
|
|
|
|
const [totalSubjectsCount, setTotalSubjectsCount] = useState(0);
|
|
|
|
|
const [notices, setNotices] = useState<Notice[]>([]);
|
|
|
|
|
const [loadingNotices, setLoadingNotices] = useState(true);
|
2025-11-18 06:19:26 +09:00
|
|
|
|
|
|
|
|
// NOTE: 실제 이미지 자산 연결 시 해당 src를 교체하세요.
|
|
|
|
|
const slides = useMemo(
|
|
|
|
|
() => [
|
|
|
|
|
{
|
|
|
|
|
id: 1,
|
|
|
|
|
title: '시스템 점검 안내',
|
|
|
|
|
description: '11월 10일 새벽 2시~4시 시스템 점검이 진행됩니다.',
|
2025-11-18 07:53:09 +09:00
|
|
|
imageSrc: 'https://picsum.photos/seed/xrlms-slide1/1600/900',
|
2025-11-18 06:19:26 +09:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 2,
|
|
|
|
|
title: '신규 과정 오픈',
|
|
|
|
|
description: '최신 커리큘럼으로 업스킬링하세요.',
|
2025-11-18 07:53:09 +09:00
|
|
|
imageSrc: 'https://picsum.photos/seed/xrlms-slide2/1600/900',
|
2025-11-18 06:19:26 +09:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 3,
|
|
|
|
|
title: '수강 이벤트',
|
|
|
|
|
description: '이번 달 수강 혜택을 확인해보세요.',
|
2025-11-18 07:53:09 +09:00
|
|
|
imageSrc: 'https://picsum.photos/seed/xrlms-slide3/1600/900',
|
2025-11-18 06:19:26 +09:00
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
[]
|
|
|
|
|
);
|
|
|
|
|
|
2025-11-27 00:45:55 +09:00
|
|
|
// 사용자 정보 가져오기
|
|
|
|
|
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) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-29 13:00:50 +09:00
|
|
|
const response = await apiService.getCurrentUser();
|
2025-11-27 00:45:55 +09:00
|
|
|
|
2025-11-29 13:00:50 +09:00
|
|
|
if (response.status !== 200) {
|
2025-11-27 00:45:55 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-29 13:00:50 +09:00
|
|
|
const data = response.data;
|
2025-11-27 00:45:55 +09:00
|
|
|
|
2025-11-27 20:36:38 +09:00
|
|
|
if (isMounted) {
|
|
|
|
|
// 사용자 권한 확인
|
|
|
|
|
const userRole = data.role || data.userRole;
|
|
|
|
|
if (userRole === 'ADMIN' || userRole === 'admin') {
|
|
|
|
|
// admin 권한이면 /admin/id로 리다이렉트
|
|
|
|
|
router.push('/admin/id');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (data.name) {
|
|
|
|
|
setUserName(data.name);
|
|
|
|
|
}
|
2025-11-27 00:45:55 +09:00
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('사용자 정보 조회 오류:', error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fetchUserInfo();
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
isMounted = false;
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-12-01 09:56:04 +09:00
|
|
|
// 과목 리스트 가져오기
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let isMounted = true;
|
|
|
|
|
|
|
|
|
|
async function fetchSubjects() {
|
|
|
|
|
try {
|
|
|
|
|
setLoadingSubjects(true);
|
|
|
|
|
const response = await apiService.getSubjects();
|
|
|
|
|
|
|
|
|
|
if (response.status !== 200 || !response.data) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 응답 데이터 구조 확인 및 배열 추출
|
|
|
|
|
let subjectsData: any[] = [];
|
|
|
|
|
let totalCount = 0;
|
|
|
|
|
|
|
|
|
|
if (Array.isArray(response.data)) {
|
|
|
|
|
subjectsData = response.data;
|
|
|
|
|
totalCount = response.data.length;
|
|
|
|
|
} else if (response.data && typeof response.data === 'object') {
|
|
|
|
|
// 다양한 응답 구조 처리
|
|
|
|
|
subjectsData = response.data.items ||
|
|
|
|
|
response.data.courses ||
|
|
|
|
|
response.data.data ||
|
|
|
|
|
response.data.list ||
|
|
|
|
|
response.data.subjects ||
|
|
|
|
|
response.data.subjectList ||
|
|
|
|
|
[];
|
|
|
|
|
|
|
|
|
|
// 전체 개수는 total, totalCount, count 등의 필드에서 가져오거나 배열 길이 사용
|
|
|
|
|
totalCount = response.data.total !== undefined ? response.data.total :
|
|
|
|
|
response.data.totalCount !== undefined ? response.data.totalCount :
|
|
|
|
|
response.data.count !== undefined ? response.data.count :
|
|
|
|
|
subjectsData.length;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log('과목 리스트 응답:', response.data);
|
|
|
|
|
console.log('추출된 과목 데이터:', subjectsData);
|
|
|
|
|
console.log('전체 과목 개수:', totalCount);
|
|
|
|
|
|
|
|
|
|
if (!isMounted) return;
|
|
|
|
|
|
|
|
|
|
// 전체 과목 개수 저장
|
|
|
|
|
setTotalSubjectsCount(totalCount);
|
|
|
|
|
|
|
|
|
|
// 상위 10개만 가져오기
|
|
|
|
|
const top10Subjects = subjectsData.slice(0, 10);
|
|
|
|
|
setSubjects(top10Subjects);
|
|
|
|
|
|
|
|
|
|
// 각 과목의 이미지 다운로드
|
|
|
|
|
const courseCardsWithImages = await Promise.all(
|
|
|
|
|
top10Subjects.map(async (subject: Subject) => {
|
|
|
|
|
let imageUrl = 'https://picsum.photos/seed/xrlms-fallback-card/1200/800';
|
|
|
|
|
|
|
|
|
|
if (subject.imageKey) {
|
|
|
|
|
try {
|
|
|
|
|
const fileUrl = await apiService.getFile(subject.imageKey);
|
|
|
|
|
if (fileUrl) {
|
|
|
|
|
imageUrl = fileUrl;
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(`이미지 다운로드 실패 (과목 ID: ${subject.id}):`, error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: subject.id,
|
|
|
|
|
title: subject.title || '',
|
|
|
|
|
meta: subject.instructor ? `강사: ${subject.instructor}` : 'VOD • 온라인',
|
|
|
|
|
image: imageUrl,
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (isMounted) {
|
|
|
|
|
setCourseCards(courseCardsWithImages);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('과목 리스트 조회 오류:', error);
|
|
|
|
|
} finally {
|
|
|
|
|
if (isMounted) {
|
|
|
|
|
setLoadingSubjects(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fetchSubjects();
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
isMounted = false;
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 공지사항 리스트 가져오기
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let isMounted = true;
|
|
|
|
|
|
|
|
|
|
async function fetchNotices() {
|
|
|
|
|
try {
|
|
|
|
|
setLoadingNotices(true);
|
|
|
|
|
const response = await apiService.getNotices();
|
|
|
|
|
|
|
|
|
|
if (response.status !== 200 || !response.data) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태)
|
|
|
|
|
let noticesArray: any[] = [];
|
|
|
|
|
if (Array.isArray(response.data)) {
|
|
|
|
|
noticesArray = response.data;
|
|
|
|
|
} else if (response.data && typeof response.data === 'object') {
|
|
|
|
|
noticesArray = response.data.items || response.data.notices || response.data.data || response.data.list || [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 날짜를 yyyy-mm-dd 형식으로 포맷팅
|
|
|
|
|
const formatDate = (dateString: string): string => {
|
|
|
|
|
if (!dateString) return '';
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const date = new Date(dateString);
|
|
|
|
|
if (isNaN(date.getTime())) {
|
|
|
|
|
// 이미 yyyy-mm-dd 형식인 경우 그대로 반환
|
|
|
|
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
|
|
|
|
return dateString;
|
|
|
|
|
}
|
|
|
|
|
return dateString;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const year = date.getFullYear();
|
|
|
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
|
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
|
|
|
return `${year}-${month}-${day}`;
|
|
|
|
|
} catch {
|
|
|
|
|
return dateString;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// API 응답 데이터를 Notice 형식으로 변환
|
|
|
|
|
const transformedNotices: Notice[] = noticesArray.map((notice: any) => ({
|
|
|
|
|
id: notice.id || notice.noticeId || 0,
|
|
|
|
|
title: notice.title || '',
|
|
|
|
|
date: formatDate(notice.date || notice.createdAt || notice.createdDate || new Date().toISOString().split('T')[0]),
|
|
|
|
|
views: notice.views || notice.viewCount || 0,
|
|
|
|
|
writer: notice.writer || notice.author || notice.createdBy || '관리자',
|
|
|
|
|
content: notice.content ? (Array.isArray(notice.content) ? notice.content : [notice.content]) : undefined,
|
|
|
|
|
hasAttachment: notice.hasAttachment || notice.attachment || false,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
if (!isMounted) return;
|
|
|
|
|
|
|
|
|
|
// 날짜 내림차순 정렬 (최신 날짜가 먼저)
|
|
|
|
|
const sortedNotices = [...transformedNotices].sort((a, b) => b.date.localeCompare(a.date));
|
|
|
|
|
setNotices(sortedNotices);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('공지사항 리스트 조회 오류:', error);
|
|
|
|
|
} finally {
|
|
|
|
|
if (isMounted) {
|
|
|
|
|
setLoadingNotices(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fetchNotices();
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
isMounted = false;
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-11-18 06:19:26 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
const containerEl = containerRef.current;
|
|
|
|
|
if (!containerEl) return;
|
|
|
|
|
|
|
|
|
|
const handleScroll = () => {
|
|
|
|
|
const width = containerEl.clientWidth;
|
|
|
|
|
const index = Math.round(containerEl.scrollLeft / Math.max(width, 1));
|
|
|
|
|
setCurrentIndex(index);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
containerEl.addEventListener('scroll', handleScroll, { passive: true });
|
|
|
|
|
return () => {
|
|
|
|
|
containerEl.removeEventListener('scroll', handleScroll);
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const scrollToIndex = (index: number) => {
|
|
|
|
|
const containerEl = containerRef.current;
|
|
|
|
|
if (!containerEl) return;
|
|
|
|
|
const clamped = Math.max(0, Math.min(index, slides.length - 1));
|
|
|
|
|
const width = containerEl.clientWidth;
|
|
|
|
|
containerEl.scrollTo({ left: clamped * width, behavior: 'smooth' });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handlePrev = () => scrollToIndex(currentIndex - 1);
|
|
|
|
|
const handleNext = () => scrollToIndex(currentIndex + 1);
|
|
|
|
|
|
2025-11-15 23:46:47 +09:00
|
|
|
return (
|
2025-11-18 06:19:26 +09:00
|
|
|
<div className="w-full min-h-screen flex flex-col bg-white">
|
|
|
|
|
<main className="flex-1">
|
|
|
|
|
{/* 메인 컨테이너 */}
|
|
|
|
|
<div className="w-full flex justify-center">
|
2025-11-18 07:53:09 +09:00
|
|
|
<div className="w-full max-w-[1376px] px-3 py-6">
|
2025-11-18 06:19:26 +09:00
|
|
|
{/* 배너 + 사이드 */}
|
2025-11-18 07:53:09 +09:00
|
|
|
<div className="flex gap-8">
|
2025-11-18 06:19:26 +09:00
|
|
|
{/* 배너 */}
|
2025-11-18 07:53:09 +09:00
|
|
|
<section className="flex-none w-[944px]" aria-label="홈 상단 배너">
|
2025-11-18 06:19:26 +09:00
|
|
|
<div className="relative">
|
|
|
|
|
<div
|
|
|
|
|
ref={containerRef}
|
2025-11-18 07:53:09 +09:00
|
|
|
className="flex overflow-x-hidden overscroll-none overflow-y-hidden overscroll-y-none snap-x snap-mandatory scroll-smooth rounded-[12px] bg-[#F1F3F5]"
|
2025-11-18 06:19:26 +09:00
|
|
|
>
|
|
|
|
|
|
|
|
|
|
{slides.map((slide) => (
|
2025-11-18 07:53:09 +09:00
|
|
|
<div key={slide.id} className="flex-none w-full h-[510px] relative snap-start overflow-hidden">
|
|
|
|
|
<img
|
|
|
|
|
alt={slide.title}
|
|
|
|
|
src={slide.imageSrc}
|
|
|
|
|
className="w-full h-full object-cover block"
|
|
|
|
|
onError={(e) => {
|
|
|
|
|
const t = e.currentTarget as HTMLImageElement;
|
|
|
|
|
if (!t.src.includes('picsum.photos')) t.src = 'https://picsum.photos/seed/xrlms-fallback-slide/1600/900';
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<div className="absolute left-0 right-0 bottom-0 h-[198px] bg-linear-to-b from-transparent to-black/50" />
|
|
|
|
|
<div className="absolute left-12 bottom-8 text-white">
|
|
|
|
|
<div className="font-bold text-[32px] tracking-[-0.32px] leading-normal mb-1.5">{slide.title}</div>
|
|
|
|
|
<div className="font-bold text-[24px] tracking-[-0.24px] leading-normal opacity-95">
|
2025-11-18 06:19:26 +09:00
|
|
|
{slide.description}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 좌/우 내비게이션 버튼 */}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={handlePrev}
|
|
|
|
|
aria-label="이전 배너"
|
|
|
|
|
disabled={currentIndex <= 0}
|
2025-11-18 07:53:09 +09:00
|
|
|
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"
|
2025-11-18 06:19:26 +09:00
|
|
|
>
|
2025-11-18 07:53:09 +09:00
|
|
|
<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>
|
2025-11-18 06:19:26 +09:00
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={handleNext}
|
|
|
|
|
aria-label="다음 배너"
|
|
|
|
|
disabled={currentIndex >= slides.length - 1}
|
2025-11-18 07:53:09 +09:00
|
|
|
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"
|
2025-11-18 06:19:26 +09:00
|
|
|
>
|
2025-11-18 07:53:09 +09:00
|
|
|
<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>
|
2025-11-18 06:19:26 +09:00
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{/* 인디케이터 */}
|
2025-11-18 07:53:09 +09:00
|
|
|
<div className="absolute left-1/2 bottom-3 -translate-x-1/2 flex items-center gap-2" aria-hidden>
|
2025-11-18 06:19:26 +09:00
|
|
|
{slides.map((_, idx) => (
|
2025-11-18 07:53:09 +09:00
|
|
|
<span key={idx} className={idx === currentIndex ? 'h-2 w-8 rounded-full bg-white' : 'size-2 rounded-full bg-white/50'} />
|
2025-11-18 06:19:26 +09:00
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{/* 사이드 패널 (피그마 디자인 적용) */}
|
|
|
|
|
<aside className="flex-none w-[400px]">
|
|
|
|
|
<div className="bg-[#F1F3F5] rounded-[12px] overflow-hidden h-[510px]">
|
2025-11-18 07:53:09 +09:00
|
|
|
{/* 상단 환영 및 통계 (피그마 사이즈/간격 적용) */}
|
2025-11-18 06:19:26 +09:00
|
|
|
<div className="px-8 py-8">
|
|
|
|
|
<div className="mb-6">
|
2025-11-18 07:53:09 +09:00
|
|
|
<div className="flex items-center gap-2">
|
2025-11-27 00:45:55 +09:00
|
|
|
<span className="text-[18px] font-bold leading-normal text-[#333C47]">
|
|
|
|
|
{userName ? `${userName}님` : '사용자님'}
|
|
|
|
|
</span>
|
2025-11-18 07:53:09 +09:00
|
|
|
<span className="text-[18px] font-bold leading-normal text-[#333C47]">환영합니다.</span>
|
2025-11-18 06:19:26 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center justify-center gap-6">
|
|
|
|
|
{[
|
|
|
|
|
{ label: '수강중', value: 0 },
|
|
|
|
|
{ label: '수강 완료', value: 0 },
|
|
|
|
|
{ label: '문제 제출', value: 0 },
|
2025-11-18 07:53:09 +09:00
|
|
|
{ label: '수료증', value: 0 },
|
2025-11-18 06:19:26 +09:00
|
|
|
].map((s) => (
|
|
|
|
|
<div key={s.label} className="w-[64px] flex flex-col items-center justify-center gap-2">
|
|
|
|
|
<div className="size-16 rounded-full bg-white flex items-center justify-center">
|
|
|
|
|
<div className="text-[20px] font-bold text-[#333C47] leading-none">{s.value}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-[15px] font-semibold text-[#333C47] leading-none">{s.label}</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-11-18 07:53:09 +09:00
|
|
|
{/* 구분선 (좌우 32px 여백, 1px 라인) */}
|
2025-11-18 06:19:26 +09:00
|
|
|
<div className="h-px bg-[#DEE1E6] mx-8" />
|
2025-11-18 07:53:09 +09:00
|
|
|
{/* 최근 수강 내역 헤더 + 빈 상태 영역 (피그마 텍스트/간격 일치) */}
|
|
|
|
|
<div className="px-8 pt-[12px] pb-[24px]">
|
2025-11-18 06:19:26 +09:00
|
|
|
<div className="h-[60px] w-full flex items-center justify-between">
|
|
|
|
|
<div className="text-[18px] font-bold text-[#1B2027]">최근 수강 내역</div>
|
|
|
|
|
<a href="#" className="flex items-center gap-0.5 text-[14px] font-medium text-[#6C7682] no-underline">
|
|
|
|
|
전체보기
|
|
|
|
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden>
|
|
|
|
|
<path d="M8 5l8 7-8 7" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
|
|
|
</svg>
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
2025-11-18 07:53:09 +09:00
|
|
|
<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>
|
2025-11-18 06:19:26 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</aside>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 교육 과정 */}
|
|
|
|
|
<section className="mt-8">
|
2025-11-18 07:53:09 +09:00
|
|
|
<div className="flex items-center justify-between h-[100px] px-0">
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<h2 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">교육 과정</h2>
|
|
|
|
|
<div className="text-[15px] font-medium leading-normal text-[#333C47]">
|
2025-12-01 09:56:04 +09:00
|
|
|
총 <span className="text-[#384FBF]">{totalSubjectsCount}</span>건
|
2025-11-18 07:53:09 +09:00
|
|
|
</div>
|
2025-11-18 06:19:26 +09:00
|
|
|
</div>
|
2025-12-01 09:56:04 +09:00
|
|
|
<Link
|
|
|
|
|
href="/course-list"
|
2025-11-18 07:53:09 +09:00
|
|
|
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>
|
2025-12-01 09:56:04 +09:00
|
|
|
</Link>
|
2025-11-18 06:19:26 +09:00
|
|
|
</div>
|
|
|
|
|
|
2025-12-01 09:56:04 +09:00
|
|
|
{loadingSubjects ? (
|
|
|
|
|
<div className="flex items-center justify-center h-[260px]">
|
|
|
|
|
<p className="text-[16px] font-medium text-[#6C7682]">로딩 중...</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="grid grid-cols-5 gap-8">
|
|
|
|
|
{courseCards.map((c) => (
|
|
|
|
|
<article
|
|
|
|
|
key={c.id}
|
|
|
|
|
onClick={() => router.push(`/course-list/${c.id}`)}
|
|
|
|
|
className="flex flex-col gap-4 h-[260px] cursor-pointer"
|
|
|
|
|
>
|
|
|
|
|
<div className="h-[166.4px] overflow-hidden rounded-[8px] flex items-center justify-center bg-[#F1F3F5] hover:shadow-lg transition-shadow">
|
|
|
|
|
<img
|
|
|
|
|
alt={c.title}
|
|
|
|
|
src={c.image}
|
|
|
|
|
className="h-full w-auto object-contain 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';
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2025-11-18 06:19:26 +09:00
|
|
|
</div>
|
2025-12-01 09:56:04 +09:00
|
|
|
<div className="flex flex-col gap-1">
|
|
|
|
|
<h5 className="m-0 text-[#333C47] font-semibold text-[18px] leading-normal truncate" title={c.title}>
|
|
|
|
|
{c.title}
|
|
|
|
|
</h5>
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden className="text-[#8C95A1]">
|
|
|
|
|
<path d="M8 5v14l11-7z" fill="currentColor" />
|
|
|
|
|
</svg>
|
|
|
|
|
<p className="m-0 text-[#8C95A1] text-[13px] font-medium leading-[1.4]">{c.meta}</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</article>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-11-18 06:19:26 +09:00
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{/* 공지사항 */}
|
|
|
|
|
<section className="mt-9">
|
2025-11-18 07:53:09 +09:00
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<h2 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">공지사항</h2>
|
|
|
|
|
<div className="text-[15px] font-medium leading-normal text-[#333C47]">
|
2025-12-01 09:56:04 +09:00
|
|
|
총 <span className="text-[#384FBF]">{notices.length}</span>건
|
2025-11-18 07:53:09 +09:00
|
|
|
</div>
|
2025-11-18 06:19:26 +09:00
|
|
|
</div>
|
2025-12-01 09:56:04 +09:00
|
|
|
<Link
|
|
|
|
|
href="/notices"
|
2025-11-18 07:53:09 +09:00
|
|
|
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>
|
2025-12-01 09:56:04 +09:00
|
|
|
</Link>
|
2025-11-18 06:19:26 +09:00
|
|
|
</div>
|
|
|
|
|
|
2025-12-01 09:56:04 +09:00
|
|
|
{loadingNotices ? (
|
|
|
|
|
<div className="flex items-center justify-center h-[240px]">
|
|
|
|
|
<p className="text-[16px] font-medium text-[#6C7682]">로딩 중...</p>
|
2025-11-18 06:19:26 +09:00
|
|
|
</div>
|
2025-12-01 09:56:04 +09:00
|
|
|
) : (
|
|
|
|
|
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
|
|
|
|
|
<div className="grid grid-cols-[57px_1fr_140px_140px_140px] bg-[#F9FAFB] h-[48px] text-[14px] font-semibold text-[#4C5561] border-b border-[#DEE1E6]">
|
|
|
|
|
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">번호</div>
|
|
|
|
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">제목</div>
|
|
|
|
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">게시일</div>
|
|
|
|
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">조회수</div>
|
|
|
|
|
<div className="flex items-center px-4">작성자</div>
|
|
|
|
|
</div>
|
2025-11-18 06:19:26 +09:00
|
|
|
|
2025-12-01 09:56:04 +09:00
|
|
|
<div>
|
|
|
|
|
{notices.map((notice, index) => {
|
|
|
|
|
// 번호는 정렬된 목록에서의 순서 (최신이 1번)
|
|
|
|
|
const noticeNumber = notices.length - index;
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={notice.id}
|
|
|
|
|
role="button"
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
onClick={() => router.push(`/notices/${notice.id}`)}
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
router.push(`/notices/${notice.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"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">{noticeNumber}</div>
|
|
|
|
|
<div
|
|
|
|
|
className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis"
|
|
|
|
|
title={notice.title}
|
|
|
|
|
>
|
|
|
|
|
{notice.title}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">{notice.date}</div>
|
|
|
|
|
<div className="flex items-center px-4 border-r border-[#DEE1E6]">{notice.views.toLocaleString()}</div>
|
|
|
|
|
<div className="flex items-center px-4">{notice.writer}</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
2025-11-18 06:19:26 +09:00
|
|
|
</div>
|
2025-12-01 09:56:04 +09:00
|
|
|
)}
|
2025-11-18 06:19:26 +09:00
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</main>
|
|
|
|
|
|
2025-11-18 07:53:09 +09:00
|
|
|
{/* 전역 Footer는 layout.tsx에서 렌더링됩니다. */}
|
2025-11-15 23:46:47 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|