메일페이지 수정중1
This commit is contained in:
@@ -274,7 +274,8 @@ export default function AdminNoticeDetailPage() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{attachments.map((attachment, idx) => (
|
||||
<div className="pt-3">
|
||||
{attachments.map((attachment, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-white border border-[#DEE1E6] rounded-[6px] h-[64px] flex items-center gap-[12px] px-[17px] py-1 w-full"
|
||||
@@ -301,7 +302,8 @@ export default function AdminNoticeDetailPage() {
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import apiService from '../../lib/apiService';
|
||||
import BackCircleSvg from '../../svgs/backcirclesvg';
|
||||
|
||||
const imgPlay = '/imgs/play.svg';
|
||||
const imgMusicAudioPlay = '/imgs/music-audio-play.svg';
|
||||
|
||||
type Lesson = {
|
||||
id: string;
|
||||
@@ -39,23 +43,98 @@ export default function CourseDetailPage() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await apiService.getLecture(params.id as string);
|
||||
|
||||
let data: any = null;
|
||||
|
||||
// 먼저 getLecture 시도
|
||||
try {
|
||||
const response = await apiService.getLecture(params.id as string);
|
||||
data = response.data;
|
||||
} catch (lectureErr) {
|
||||
console.warn('getLecture 실패, getSubjects로 재시도:', lectureErr);
|
||||
|
||||
// getLecture 실패 시 getSubjects로 폴백
|
||||
try {
|
||||
const subjectsResponse = await apiService.getSubjects();
|
||||
let subjectsData: any[] = [];
|
||||
|
||||
if (Array.isArray(subjectsResponse.data)) {
|
||||
subjectsData = subjectsResponse.data;
|
||||
} else if (subjectsResponse.data && typeof subjectsResponse.data === 'object') {
|
||||
subjectsData = subjectsResponse.data.items ||
|
||||
subjectsResponse.data.courses ||
|
||||
subjectsResponse.data.data ||
|
||||
subjectsResponse.data.list ||
|
||||
subjectsResponse.data.subjects ||
|
||||
subjectsResponse.data.subjectList ||
|
||||
[];
|
||||
}
|
||||
|
||||
// ID로 과목 찾기
|
||||
data = subjectsData.find((s: any) => String(s.id || s.subjectId) === String(params.id));
|
||||
|
||||
if (!data) {
|
||||
// getLectures로도 시도
|
||||
try {
|
||||
const lecturesResponse = await apiService.getLectures();
|
||||
const lectures = Array.isArray(lecturesResponse.data)
|
||||
? lecturesResponse.data
|
||||
: lecturesResponse.data?.items || lecturesResponse.data?.lectures || lecturesResponse.data?.data || [];
|
||||
|
||||
data = lectures.find((l: any) => String(l.id || l.lectureId) === String(params.id));
|
||||
} catch (lecturesErr) {
|
||||
console.error('getLectures 실패:', lecturesErr);
|
||||
}
|
||||
}
|
||||
} catch (subjectsErr) {
|
||||
console.error('getSubjects 실패:', subjectsErr);
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error('강좌를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 썸네일 이미지 가져오기
|
||||
let thumbnail = '/imgs/talk.png';
|
||||
if (data.imageKey) {
|
||||
try {
|
||||
const imageUrl = await apiService.getFile(data.imageKey);
|
||||
if (imageUrl) {
|
||||
thumbnail = imageUrl;
|
||||
}
|
||||
} catch (imgErr) {
|
||||
console.error('이미지 다운로드 실패:', imgErr);
|
||||
}
|
||||
}
|
||||
|
||||
// 강좌의 차시(lessons) 정보 가져오기
|
||||
let lessons: any[] = [];
|
||||
if (data.lessons && Array.isArray(data.lessons)) {
|
||||
lessons = data.lessons;
|
||||
} else if (data.lectureId) {
|
||||
// lectureId가 있으면 해당 강좌의 차시 정보 가져오기 시도
|
||||
try {
|
||||
const lectureResponse = await apiService.getLecture(data.lectureId);
|
||||
if (lectureResponse.data?.lessons) {
|
||||
lessons = lectureResponse.data.lessons;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('차시 정보 가져오기 실패:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// API 응답 데이터를 CourseDetail 타입으로 변환
|
||||
const data = response.data;
|
||||
|
||||
// API 응답 구조에 맞게 데이터 매핑
|
||||
// 실제 API 응답 구조에 따라 조정 필요
|
||||
const courseDetail: CourseDetail = {
|
||||
id: String(data.id || params.id),
|
||||
status: data.status || "수강 예정",
|
||||
title: data.title || data.lectureName || '',
|
||||
title: data.title || data.lectureName || data.subjectName || '',
|
||||
goal: data.objective || data.goal || '',
|
||||
method: data.method || '',
|
||||
summary: data.summary || `VOD · 총 ${data.lessons?.length || 0}강`,
|
||||
summary: data.summary || `VOD · 총 ${lessons.length || 0}강`,
|
||||
submitSummary: data.submitSummary || '',
|
||||
thumbnail: data.thumbnail || data.imageKey || data.imageUrl || '/imgs/talk.png',
|
||||
lessons: (data.lessons || []).map((lesson: any, index: number) => ({
|
||||
thumbnail: thumbnail,
|
||||
lessons: lessons.map((lesson: any, index: number) => ({
|
||||
id: String(lesson.id || lesson.lessonId || index + 1),
|
||||
title: `${index + 1}. ${lesson.title || lesson.lessonName || ''}`,
|
||||
duration: lesson.duration || '00:00',
|
||||
@@ -78,11 +157,19 @@ export default function CourseDetailPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="flex w-full flex-col">
|
||||
<div className="flex h-[100px] items-center px-8">
|
||||
<main className="flex w-full flex-col items-center">
|
||||
<div className="flex h-[100px] w-full max-w-[1440px] items-center gap-[12px] px-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
aria-label="뒤로 가기"
|
||||
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline cursor-pointer"
|
||||
>
|
||||
<BackCircleSvg width={32} height={32} />
|
||||
</button>
|
||||
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">교육 과정 상세보기</h1>
|
||||
</div>
|
||||
<section className="px-8 pb-20">
|
||||
<section className="w-full max-w-[1440px] px-8 pb-20">
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<p className="text-[16px] text-[#8c95a1]">로딩 중...</p>
|
||||
</div>
|
||||
@@ -93,11 +180,19 @@ export default function CourseDetailPage() {
|
||||
|
||||
if (error || !course) {
|
||||
return (
|
||||
<main className="flex w-full flex-col">
|
||||
<div className="flex h-[100px] items-center px-8">
|
||||
<main className="flex w-full flex-col items-center">
|
||||
<div className="flex h-[100px] w-full max-w-[1440px] items-center gap-[12px] px-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
aria-label="뒤로 가기"
|
||||
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline cursor-pointer"
|
||||
>
|
||||
<BackCircleSvg width={32} height={32} />
|
||||
</button>
|
||||
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">교육 과정 상세보기</h1>
|
||||
</div>
|
||||
<section className="px-8 pb-20">
|
||||
<section className="w-full max-w-[1440px] px-8 pb-20">
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<p className="text-[16px] text-red-500 mb-4">{error || '강좌를 찾을 수 없습니다.'}</p>
|
||||
<button
|
||||
@@ -114,96 +209,160 @@ export default function CourseDetailPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex w-full flex-col">
|
||||
<div className="flex h-[100px] items-center px-8">
|
||||
<main className="flex w-full flex-col items-center">
|
||||
{/* 헤더 */}
|
||||
<div className="flex h-[100px] w-full max-w-[1440px] items-center gap-[12px] px-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
aria-label="뒤로 가기"
|
||||
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline cursor-pointer"
|
||||
>
|
||||
<BackCircleSvg width={32} height={32} />
|
||||
</button>
|
||||
<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={course.thumbnail}
|
||||
alt={course.title}
|
||||
fill
|
||||
sizes="292px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
{/* 메인 콘텐츠 */}
|
||||
<section className="w-full max-w-[1440px] px-8 pb-[80px] pt-[24px]">
|
||||
{/* 상단 정보 카드 */}
|
||||
<div className="bg-[#f8f9fa] box-border flex gap-[24px] items-start p-[24px] rounded-[8px] w-full">
|
||||
{/* 이미지 컨테이너 */}
|
||||
<div className="overflow-clip relative rounded-[4px] shrink-0 w-[220.5px] h-[159px]">
|
||||
<Image
|
||||
src={course.thumbnail}
|
||||
alt={course.title}
|
||||
fill
|
||||
sizes="220.5px"
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 교육과정 정보 */}
|
||||
<div className="basis-0 flex flex-col gap-[12px] grow items-start min-h-px min-w-px relative shrink-0">
|
||||
{/* 제목 영역 */}
|
||||
<div className="flex gap-[8px] h-[27px] items-center w-full">
|
||||
<div className="bg-[#e5f5ec] box-border flex h-[20px] items-center justify-center px-[4px] py-0 rounded-[4px] shrink-0">
|
||||
<p className="font-['Pretendard:SemiBold',sans-serif] text-[#0c9d61] text-[13px] leading-[1.4]">{course.status}</p>
|
||||
</div>
|
||||
<h2 className="font-['Pretendard:SemiBold',sans-serif] text-[#333c47] text-[18px] leading-[1.5]">{course.title}</h2>
|
||||
</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]">
|
||||
{course.status}
|
||||
</span>
|
||||
<h2 className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">{course.title}</h2>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1">
|
||||
<p className="text-[15px] leading-[1.5] text-[#333c47]">
|
||||
<span className="font-medium">학습 목표:</span> {course.goal}
|
||||
</p>
|
||||
<p className="text-[15px] leading-[1.5] text-[#333c47]">
|
||||
<span className="font-medium">학습 방법:</span> {course.method}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-5 text-[13px] leading-[1.4] text-[#8c95a1]">
|
||||
<span>{course.summary}</span>
|
||||
{course.submitSummary && <span>{course.submitSummary}</span>}
|
||||
|
||||
{/* 학습 목표 및 방법 */}
|
||||
<div className="flex flex-col gap-[4px] items-start w-full">
|
||||
<p className="font-['Pretendard:Regular',sans-serif] text-[#333c47] text-[15px] leading-[1.5] mb-0">
|
||||
<span className="font-['Pretendard:Medium',sans-serif]">학습 목표:</span>
|
||||
<span>{` ${course.goal}`}</span>
|
||||
</p>
|
||||
<p className="font-['Pretendard:Regular',sans-serif] text-[#333c47] text-[15px] leading-[1.5]">
|
||||
<span className="font-['Pretendard:Medium',sans-serif]">학습 방법:</span>
|
||||
<span>{` ${course.method}`}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 통계 정보 */}
|
||||
<div className="flex gap-[4px] items-center w-full">
|
||||
<div className="flex gap-[20px] items-center">
|
||||
{/* VOD 정보 */}
|
||||
<div className="flex gap-[4px] items-center">
|
||||
<div className="relative shrink-0 size-[16px]">
|
||||
<img src={imgPlay} alt="" className="block max-w-none size-full" />
|
||||
</div>
|
||||
<p className="font-['Pretendard:Medium',sans-serif] text-[#8c95a1] text-[13px] leading-[1.4]">{course.summary}</p>
|
||||
</div>
|
||||
|
||||
{/* 학습 제출 정보 */}
|
||||
{course.submitSummary && (
|
||||
<div className="flex gap-[4px] items-center">
|
||||
<div className="relative shrink-0 size-[16px]">
|
||||
<img src={imgMusicAudioPlay} alt="" className="block max-w-none size-full" />
|
||||
</div>
|
||||
<p className="font-['Pretendard:Medium',sans-serif] text-[#8c95a1] text-[13px] leading-[1.4]">{course.submitSummary}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 차시 리스트 */}
|
||||
<div className="mt-6 space-y-2">
|
||||
{course.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 className="flex flex-col gap-[8px] items-start mt-[24px] w-full">
|
||||
{course.lessons.map((l) => {
|
||||
const isSubmitted = l.state === "제출완료";
|
||||
const submitBtnBorder = isSubmitted
|
||||
? "border-transparent"
|
||||
: (l.action === "이어서 수강하기" || l.action === "수강하기" ? "border-[#b1b8c0]" : "border-[#8c95a1]");
|
||||
const submitBtnText = isSubmitted ? "text-[#384fbf]" : (l.action === "이어서 수강하기" || l.action === "수강하기" ? "text-[#b1b8c0]" : "text-[#4c5561]");
|
||||
const rightBtnStyle =
|
||||
l.action === "이어서 수강하기" || l.action === "수강하기"
|
||||
? "bg-[#ecf0ff] text-[#384fbf]"
|
||||
: "bg-[#f1f3f5] text-[#4c5561]";
|
||||
|
||||
return (
|
||||
<div key={l.id} className="bg-white border border-[#dee1e6] border-solid relative rounded-[8px] w-full">
|
||||
<div className="box-border flex gap-[16px] items-center overflow-clip px-[24px] py-[16px] rounded-[inherit] w-full">
|
||||
<div className="basis-0 flex grow h-[46px] items-center justify-between min-h-px min-w-px relative shrink-0">
|
||||
{/* 차시 정보 */}
|
||||
<div className="basis-0 grow min-h-px min-w-px relative shrink-0">
|
||||
<div className="flex flex-col gap-[4px] items-start w-full">
|
||||
<p className="font-['Pretendard:SemiBold',sans-serif] text-[#333c47] text-[16px] leading-[1.5]">{l.title}</p>
|
||||
<div className="flex gap-[12px] items-center w-full">
|
||||
<p className="font-['Pretendard:Medium',sans-serif] text-[#8c95a1] text-[13px] leading-[1.4] w-[40px]">{l.duration}</p>
|
||||
</div>
|
||||
</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 className="relative shrink-0">
|
||||
<div className="flex gap-[8px] items-center">
|
||||
{/* 학습 제출 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
"bg-white box-border flex flex-col h-[32px] items-center justify-center px-[16px] py-[3px] rounded-[6px] shrink-0",
|
||||
"border border-solid",
|
||||
submitBtnBorder,
|
||||
submitBtnText,
|
||||
].join(" ")}
|
||||
>
|
||||
{isSubmitted ? (
|
||||
<div className="flex gap-[4px] h-[18px] items-center">
|
||||
<div className="relative shrink-0 size-[12px]">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 3L4.5 8.5L2 6" stroke="#384fbf" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-['Pretendard:Medium',sans-serif] text-[#384fbf] text-[13px] leading-[1.4]">학습 제출 완료</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className={[
|
||||
"font-['Pretendard:Medium',sans-serif] text-[14px] leading-[1.5] text-center",
|
||||
submitBtnText,
|
||||
].join(" ")}>학습 제출 하기</p>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 수강/복습 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
"box-border flex flex-col h-[32px] items-center justify-center px-[16px] py-[3px] rounded-[6px] shrink-0",
|
||||
rightBtnStyle,
|
||||
].join(" ")}
|
||||
>
|
||||
<p className={[
|
||||
"font-['Pretendard:Medium',sans-serif] text-[14px] leading-[1.5] text-center",
|
||||
l.action === "이어서 수강하기" || l.action === "수강하기" ? "text-[#384fbf]" : "text-[#4c5561]",
|
||||
].join(" ")}>{l.action}</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import ChevronDownSvg from '../svgs/chevrondownsvg';
|
||||
import apiService from '../lib/apiService';
|
||||
|
||||
// 피그마 선택 컴포넌트의 구조/스타일(타이포/여백/색상)을 반영한 리스트 UI
|
||||
// - 프로젝트는 Tailwind v4(@import "tailwindcss")를 사용하므로 클래스 그대로 적용
|
||||
@@ -10,10 +11,21 @@ import ChevronDownSvg from '../svgs/chevrondownsvg';
|
||||
// - 카드 구성: 섬네일(rounded 8px) + 상태 태그 + 제목 + 메타(재생 아이콘 + 텍스트)
|
||||
|
||||
const imgPlay = '/imgs/play.svg'; // public/imgs/play.svg
|
||||
const imgThumbA = '/imgs/thumb-a.png'; // public/imgs/thumb-a.png
|
||||
const imgThumbB = '/imgs/thumb-b.png'; // public/imgs/thumb-b.png
|
||||
const imgThumbC = '/imgs/thumb-c.png'; // public/imgs/thumb-c.png
|
||||
const imgThumbD = '/imgs/thumb-d.png'; // public/imgs/thumb-d.png
|
||||
|
||||
interface Subject {
|
||||
id: string | number;
|
||||
title: string;
|
||||
imageKey?: string;
|
||||
instructor?: string;
|
||||
}
|
||||
|
||||
interface Course {
|
||||
id: string | number;
|
||||
title: string;
|
||||
image: string;
|
||||
inProgress?: boolean;
|
||||
meta?: string;
|
||||
}
|
||||
|
||||
function ColorfulTag({ text }: { text: string }) {
|
||||
return (
|
||||
@@ -23,37 +35,114 @@ function ColorfulTag({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
type Course = { id: string; title: string; image: string; inProgress?: boolean };
|
||||
|
||||
export default function CourseListPage() {
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
const [page, setPage] = useState(1);
|
||||
const [subjects, setSubjects] = useState<Subject[]>([]);
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const router = useRouter();
|
||||
|
||||
const base: Omit<Course, 'id'>[] = [
|
||||
{ title: '원자로 운전 및 계통', image: imgThumbA, inProgress: true },
|
||||
{ title: '핵연료', image: imgThumbB },
|
||||
{ title: '방사선 안전', image: imgThumbC },
|
||||
{ title: '방사선 폐기물', image: imgThumbD },
|
||||
{ title: '원자로 운전 및 계통', image: imgThumbA },
|
||||
{ title: '핵연료', image: imgThumbB },
|
||||
{ title: '방사선 안전', image: imgThumbC },
|
||||
];
|
||||
const courses: Course[] = Array.from({ length: 28 }, (_, i) => {
|
||||
const item = base[i % base.length];
|
||||
return {
|
||||
id: `p${i + 1}`,
|
||||
title: item.title,
|
||||
image: item.image,
|
||||
inProgress: item.inProgress ?? (i % 7 === 0),
|
||||
// 과목 리스트 가져오기
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function fetchSubjects() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiService.getSubjects();
|
||||
|
||||
if (response.status !== 200 || !response.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 응답 데이터 구조 확인 및 배열 추출
|
||||
let subjectsData: any[] = [];
|
||||
let total = 0;
|
||||
|
||||
if (Array.isArray(response.data)) {
|
||||
subjectsData = response.data;
|
||||
total = 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 등의 필드에서 가져오거나 배열 길이 사용
|
||||
total = response.data.total !== undefined ? response.data.total :
|
||||
response.data.totalCount !== undefined ? response.data.totalCount :
|
||||
response.data.count !== undefined ? response.data.count :
|
||||
subjectsData.length;
|
||||
}
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
setTotalCount(total);
|
||||
setSubjects(subjectsData);
|
||||
|
||||
// 각 과목의 이미지 다운로드
|
||||
const coursesWithImages = await Promise.all(
|
||||
subjectsData.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 || '',
|
||||
image: imageUrl,
|
||||
inProgress: false, // TODO: 수강 중 상태는 진행률 API에서 가져와야 함
|
||||
meta: subject.instructor ? `강사: ${subject.instructor}` : 'VOD • 온라인',
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
if (isMounted) {
|
||||
setCourses(coursesWithImages);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('과목 리스트 조회 오류:', error);
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchSubjects();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
});
|
||||
const totalPages = Math.ceil(courses.length / ITEMS_PER_PAGE);
|
||||
}, []);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
|
||||
const pagedCourses = useMemo(
|
||||
() => courses.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE),
|
||||
[courses, page]
|
||||
);
|
||||
|
||||
// 페이지네이션: 10개씩 표시
|
||||
const pageGroup = Math.floor((page - 1) / 10);
|
||||
const startPage = pageGroup * 10 + 1;
|
||||
const endPage = Math.min(startPage + 9, totalPages);
|
||||
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
|
||||
|
||||
return (
|
||||
<main className="flex w-full flex-col items-center">
|
||||
{/* 상단 타이틀 영역 */}
|
||||
@@ -66,95 +155,135 @@ export default function CourseListPage() {
|
||||
{/* 상단 카운트/정렬 영역 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[15px] font-medium leading-normal text-neutral-700">
|
||||
총 <span className="text-primary">{courses.length}</span>건
|
||||
총 <span className="text-primary">{totalCount}</span>건
|
||||
</p>
|
||||
<div className="h-[40px] w-[114px]" />
|
||||
</div>
|
||||
|
||||
{/* 카드 그리드(고정 5열, gap 32px) */}
|
||||
<div className="mt-4 flex flex-col items-center">
|
||||
<div className="w-[1376px] grid grid-cols-5 gap-[32px]">
|
||||
{pagedCourses.map((c) => (
|
||||
<article
|
||||
key={c.id}
|
||||
onClick={() => router.push(`/course-list/${c.id}`)}
|
||||
className="flex h-[260px] w-[249.6px] flex-col gap-[16px] rounded-[8px] bg-white cursor-pointer hover:shadow-lg transition-shadow"
|
||||
>
|
||||
{/* 섬네일 */}
|
||||
<div className="relative h-[166.4px] w-full overflow-clip rounded-[8px]">
|
||||
<img
|
||||
src={c.image}
|
||||
alt=""
|
||||
className="absolute inset-0 size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 본문 텍스트 블록 */}
|
||||
<div className="flex w-full flex-col gap-[4px]">
|
||||
<div className="flex flex-col gap-[4px]">
|
||||
{c.inProgress && <ColorfulTag text="수강 중" />}
|
||||
<h2 className="text-[18px] font-semibold leading-normal text-neutral-700">
|
||||
{c.title}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-[4px]">
|
||||
<img src={imgPlay} alt="" className="size-[16px]" />
|
||||
<p className="text-[13px] font-medium leading-[1.4] text-text-meta">
|
||||
VOD · 총 6강 · 4시간 20분
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{loading ? (
|
||||
<div className="mt-4 flex items-center justify-center h-[400px]">
|
||||
<p className="text-[16px] font-medium text-[#6C7682]">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 카드 그리드(고정 5열, gap 32px) */}
|
||||
<div className="mt-4 flex flex-col items-center">
|
||||
<div className="w-[1376px] grid grid-cols-5 gap-[32px]">
|
||||
{pagedCourses.map((c) => (
|
||||
<article
|
||||
key={c.id}
|
||||
onClick={() => router.push(`/course-list/${c.id}`)}
|
||||
className="flex h-[260px] w-[249.6px] flex-col gap-[16px] rounded-[8px] bg-white cursor-pointer"
|
||||
>
|
||||
{/* 섬네일 */}
|
||||
<div className="relative h-[166.4px] w-full overflow-clip rounded-[8px] flex items-center justify-center bg-[#F1F3F5] hover:shadow-lg transition-shadow">
|
||||
<img
|
||||
src={c.image}
|
||||
alt={c.title}
|
||||
className="h-full w-auto object-contain"
|
||||
onError={(e) => {
|
||||
const t = e.currentTarget as HTMLImageElement;
|
||||
if (!t.src.includes('picsum.photos')) t.src = 'https://picsum.photos/seed/xrlms-fallback-card/1200/800';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 (피그마 스타일 반영) */}
|
||||
<div className="mt-8 flex items-center justify-center gap-[8px]">
|
||||
{/* Prev */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
aria-label="이전 페이지"
|
||||
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40"
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
|
||||
</button>
|
||||
{/* 본문 텍스트 블록 */}
|
||||
<div className="flex w-full flex-col gap-[4px]">
|
||||
<div className="flex flex-col gap-[4px]">
|
||||
{c.inProgress && <ColorfulTag text="수강 중" />}
|
||||
<h2 className="text-[18px] font-semibold leading-normal text-neutral-700 truncate" title={c.title}>
|
||||
{c.title}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-[4px]">
|
||||
<img src={imgPlay} alt="" className="size-[16px]" />
|
||||
<p className="text-[13px] font-medium leading-[1.4] text-text-meta">
|
||||
{c.meta || 'VOD • 온라인'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Numbers */}
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => {
|
||||
const active = n === page;
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => setPage(n)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={[
|
||||
'flex items-center justify-center rounded-[1000px] size-[32px]',
|
||||
active ? 'bg-bg-primary-light' : 'bg-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-[16px] leading-[1.4] text-neutral-700">{n}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* 페이지네이션 - admin 페이지와 동일한 메커니즘 (10개씩 표시) */}
|
||||
{totalCount > ITEMS_PER_PAGE && (
|
||||
<div className="mt-8 flex items-center justify-center gap-[8px]">
|
||||
{/* First (맨 앞으로) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage(1)}
|
||||
aria-label="맨 앞 페이지"
|
||||
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||
disabled={page === 1}
|
||||
>
|
||||
<div className="relative flex items-center justify-center w-full h-full">
|
||||
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
|
||||
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Next */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
aria-label="다음 페이지"
|
||||
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40"
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Prev */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
aria-label="이전 페이지"
|
||||
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
|
||||
</button>
|
||||
|
||||
{/* Numbers */}
|
||||
{visiblePages.map((n) => {
|
||||
const active = n === page;
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => setPage(n)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={[
|
||||
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
|
||||
active ? 'bg-bg-primary-light' : 'bg-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-[16px] leading-[1.4] text-neutral-700">{n}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Next */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
aria-label="다음 페이지"
|
||||
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
|
||||
</button>
|
||||
|
||||
{/* Last (맨 뒤로) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage(totalPages)}
|
||||
aria-label="맨 뒤 페이지"
|
||||
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
<div className="relative flex items-center justify-center w-full h-full">
|
||||
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
|
||||
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -412,7 +412,6 @@ class ApiService {
|
||||
method: 'PATCH',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 대량 정지 (ID 배열) - 관리자 전용
|
||||
*/
|
||||
|
||||
@@ -1,17 +1,169 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import BackCircleSvg from '../../svgs/backcirclesvg';
|
||||
import { MOCK_NOTICES } from '../../admin/notices/mockData';
|
||||
'use client';
|
||||
|
||||
export default async function NoticeDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const numericId = Number(id);
|
||||
const item = MOCK_NOTICES.find((r) => r.id === numericId);
|
||||
if (!item || !item.content) return notFound();
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import BackCircleSvg from '../../svgs/backcirclesvg';
|
||||
import DownloadIcon from '../../svgs/downloadicon';
|
||||
import apiService from '../../lib/apiService';
|
||||
import type { Notice } from '../../admin/notices/mockData';
|
||||
|
||||
type Attachment = {
|
||||
name: string;
|
||||
size: string;
|
||||
url?: string;
|
||||
fileKey?: string;
|
||||
};
|
||||
|
||||
export default function NoticeDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const [notice, setNotice] = useState<Notice | null>(null);
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 날짜를 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;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchNotice() {
|
||||
if (!params?.id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const noticeId = Array.isArray(params.id) ? params.id[0] : params.id;
|
||||
const response = await apiService.getNotice(noticeId);
|
||||
const data = response.data;
|
||||
|
||||
// API 응답 데이터를 Notice 형식으로 변환
|
||||
const transformedNotice: Notice = {
|
||||
id: data.id || data.noticeId || Number(params.id),
|
||||
title: data.title || '',
|
||||
date: formatDate(data.date || data.createdAt || data.createdDate || new Date().toISOString().split('T')[0]),
|
||||
views: data.views || data.viewCount || 0,
|
||||
writer: data.writer || data.author || data.createdBy || '관리자',
|
||||
content: data.content
|
||||
? (Array.isArray(data.content)
|
||||
? data.content
|
||||
: typeof data.content === 'string'
|
||||
? data.content.split('\n').filter((line: string) => line.trim())
|
||||
: [String(data.content)])
|
||||
: [],
|
||||
hasAttachment: data.hasAttachment || data.attachment || false,
|
||||
};
|
||||
|
||||
// 첨부파일 정보 처리
|
||||
if (data.attachments && Array.isArray(data.attachments)) {
|
||||
setAttachments(data.attachments.map((att: any) => ({
|
||||
name: att.name || att.fileName || att.filename || '',
|
||||
size: att.size || att.fileSize || '',
|
||||
url: att.url || att.downloadUrl,
|
||||
fileKey: att.fileKey || att.key || att.fileId,
|
||||
})));
|
||||
} else if (transformedNotice.hasAttachment && data.attachment) {
|
||||
// 단일 첨부파일인 경우
|
||||
setAttachments([{
|
||||
name: data.attachment.name || data.attachment.fileName || data.attachment.filename || '첨부파일',
|
||||
size: data.attachment.size || data.attachment.fileSize || '',
|
||||
url: data.attachment.url || data.attachment.downloadUrl,
|
||||
fileKey: data.attachment.fileKey || data.attachment.key || data.attachment.fileId,
|
||||
}]);
|
||||
}
|
||||
|
||||
if (!transformedNotice.title) {
|
||||
setError('공지사항을 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setNotice(transformedNotice);
|
||||
} catch (err) {
|
||||
console.error('공지사항 조회 오류:', err);
|
||||
setError('공지사항을 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchNotice();
|
||||
}, [params?.id]);
|
||||
|
||||
const handleDownload = async (fileKey?: string, url?: string, fileName?: string) => {
|
||||
if (url) {
|
||||
// URL이 있으면 직접 다운로드
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName || 'download';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} else if (fileKey) {
|
||||
// fileKey가 있으면 API를 통해 다운로드
|
||||
try {
|
||||
const fileUrl = await apiService.getFile(fileKey);
|
||||
if (fileUrl) {
|
||||
const link = document.createElement('a');
|
||||
link.href = fileUrl;
|
||||
link.download = fileName || 'download';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('파일 다운로드 오류:', error);
|
||||
alert('파일 다운로드 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
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 justify-center">
|
||||
<p className="text-[16px] font-medium text-[#6C7682]">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !notice) {
|
||||
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 justify-center">
|
||||
<p className="text-[16px] font-medium text-[#6C7682]">{error || '공지사항을 찾을 수 없습니다.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full bg-white">
|
||||
@@ -19,13 +171,14 @@ export default async function NoticeDetailPage({
|
||||
<div className="w-full max-w-[1440px]">
|
||||
{/* 상단 타이틀 */}
|
||||
<div className="h-[100px] flex items-center gap-3 px-8">
|
||||
<Link
|
||||
href="/notices"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
aria-label="뒤로 가기"
|
||||
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline"
|
||||
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline cursor-pointer"
|
||||
>
|
||||
<BackCircleSvg width={32} height={32} />
|
||||
</Link>
|
||||
</button>
|
||||
<h1 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">
|
||||
공지사항 상세
|
||||
</h1>
|
||||
@@ -37,17 +190,17 @@ export default async function NoticeDetailPage({
|
||||
{/* 헤더 */}
|
||||
<div className="p-8">
|
||||
<h2 className="m-0 text-[20px] font-bold leading-normal text-[#333C47]">
|
||||
{item.title}
|
||||
{notice.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="text-[#333C47]">{notice.writer}</span>
|
||||
<span className="w-px h-4 bg-[#DEE1E6]" />
|
||||
<span className="text-[#8C95A1]">게시일</span>
|
||||
<span className="text-[#333C47]">{item.date}</span>
|
||||
<span className="text-[#333C47]">{notice.date}</span>
|
||||
<span className="w-px h-4 bg-[#DEE1E6]" />
|
||||
<span className="text-[#8C95A1]">조회수</span>
|
||||
<span className="text-[#333C47]">{item.views.toLocaleString()}</span>
|
||||
<span className="text-[#333C47]">{notice.views.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,14 +208,79 @@ export default async function NoticeDetailPage({
|
||||
<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 className="p-8 flex flex-col gap-10">
|
||||
<div className="text-[15px] leading-normal text-[#333C47]">
|
||||
{notice.content && notice.content.length > 0 ? (
|
||||
notice.content.map((p, idx) => (
|
||||
<p key={idx} className="m-0 mb-2 last:mb-0">
|
||||
{p}
|
||||
</p>
|
||||
))
|
||||
) : (
|
||||
<p className="m-0">내용이 없습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 첨부파일 섹션 */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="text-[15px] font-semibold leading-[1.5] text-[#6C7682]">
|
||||
첨부 파일
|
||||
</div>
|
||||
<div className="pt-3">
|
||||
{attachments.map((attachment, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-white border border-[#DEE1E6] rounded-[6px] h-[64px] flex items-center gap-3 px-[17px]"
|
||||
>
|
||||
<div className="size-6 flex items-center justify-center">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-[#8C95A1]"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center gap-2 min-w-0">
|
||||
<p className="text-[15px] font-normal leading-[1.5] text-[#1B2027] truncate">
|
||||
{attachment.name}
|
||||
</p>
|
||||
<p className="text-[13px] font-normal leading-[1.4] text-[#8C95A1] whitespace-nowrap">
|
||||
{attachment.size}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDownload(attachment.fileKey, attachment.url, attachment.name)}
|
||||
className="bg-white border border-[#8C95A1] rounded-[6px] h-8 px-4 flex items-center justify-center gap-1 hover:bg-[#F9FAFB] transition-colors"
|
||||
>
|
||||
<DownloadIcon width={16} height={16} className="text-[#4C5561]" />
|
||||
<span className="text-[13px] font-medium leading-[1.4] text-[#4C5561]">
|
||||
다운로드
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -71,6 +289,3 @@ export default async function NoticeDetailPage({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import PaperClipSvg from '../svgs/paperclipsvg';
|
||||
import { MOCK_NOTICES } from '../admin/notices/mockData';
|
||||
import ChevronDownSvg from '../svgs/chevrondownsvg';
|
||||
import apiService from '../lib/apiService';
|
||||
import type { Notice } from '../admin/notices/mockData';
|
||||
|
||||
export default function NoticesPage() {
|
||||
const router = useRouter();
|
||||
|
||||
// 날짜 내림차순 정렬 (최신 날짜가 먼저)
|
||||
const rows = [...MOCK_NOTICES].sort((a, b) => b.date.localeCompare(a.date));
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [notices, setNotices] = useState<Notice[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// 공지사항 리스트 가져오기
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function fetchNotices() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiService.getNotices();
|
||||
|
||||
if (response.status !== 200 || !response.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
// API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태)
|
||||
let noticesArray: any[] = [];
|
||||
let total = 0;
|
||||
|
||||
if (Array.isArray(response.data)) {
|
||||
noticesArray = response.data;
|
||||
total = response.data.length;
|
||||
} else if (response.data && typeof response.data === 'object') {
|
||||
noticesArray = response.data.items || response.data.notices || response.data.data || response.data.list || [];
|
||||
total = response.data.total !== undefined ? response.data.total :
|
||||
response.data.totalCount !== undefined ? response.data.totalCount :
|
||||
response.data.count !== undefined ? response.data.count :
|
||||
noticesArray.length;
|
||||
}
|
||||
|
||||
// 날짜를 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);
|
||||
setTotalCount(total || sortedNotices.length);
|
||||
} catch (error) {
|
||||
console.error('공지사항 리스트 조회 오류:', error);
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchNotices();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
|
||||
const pagedNotices = useMemo(
|
||||
() => notices.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE),
|
||||
[notices, currentPage]
|
||||
);
|
||||
|
||||
// 페이지네이션: 10개씩 표시
|
||||
const pageGroup = Math.floor((currentPage - 1) / 10);
|
||||
const startPage = pageGroup * 10 + 1;
|
||||
const endPage = Math.min(startPage + 9, totalPages);
|
||||
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
|
||||
|
||||
return (
|
||||
<div className="w-full bg-white">
|
||||
@@ -26,74 +127,162 @@ export default function NoticesPage() {
|
||||
{/* 총 건수 */}
|
||||
<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>건
|
||||
총 <span className="text-[#384FBF]">{totalCount}</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>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-[240px]">
|
||||
<p className="text-[16px] font-medium text-[#6C7682]">로딩 중...</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, index) => {
|
||||
// 번호는 정렬된 목록에서의 순서
|
||||
const noticeNumber = rows.length - index;
|
||||
return (
|
||||
<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]">
|
||||
{noticeNumber}
|
||||
{/* 바디 */}
|
||||
<div>
|
||||
{pagedNotices.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-[240px]">
|
||||
<p className="text-[16px] font-medium text-[#6C7682]">공지사항이 없습니다.</p>
|
||||
</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]" />
|
||||
)}
|
||||
) : (
|
||||
pagedNotices.map((notice, index) => {
|
||||
// 번호는 전체 목록에서의 순서 (최신이 1번)
|
||||
const noticeNumber = totalCount - ((currentPage - 1) * ITEMS_PER_PAGE + 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 gap-1.5 px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
title={notice.title}
|
||||
>
|
||||
{notice.title}
|
||||
{notice.hasAttachment && (
|
||||
<PaperClipSvg width={16} height={16} className="shrink-0 text-[#8C95A1]" />
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 - 10개 초과일 때만 표시 */}
|
||||
{totalCount > ITEMS_PER_PAGE && (
|
||||
<div className="pt-8 pb-[32px] flex items-center justify-center">
|
||||
<div className="flex items-center justify-center gap-[8px]">
|
||||
{/* First (맨 앞으로) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(1)}
|
||||
aria-label="맨 앞 페이지"
|
||||
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<div className="relative flex items-center justify-center w-full h-full">
|
||||
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
|
||||
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Prev */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
aria-label="이전 페이지"
|
||||
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
|
||||
</button>
|
||||
|
||||
{/* Numbers */}
|
||||
{visiblePages.map((n) => {
|
||||
const active = n === currentPage;
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(n)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={[
|
||||
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
|
||||
active ? 'bg-bg-primary-light' : 'bg-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-[16px] leading-[1.4] text-neutral-700">{n}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Next */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
aria-label="다음 페이지"
|
||||
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
|
||||
</button>
|
||||
|
||||
{/* Last (맨 뒤로) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
aria-label="맨 뒤 페이지"
|
||||
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-neutral-700 disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<div className="relative flex items-center justify-center w-full h-full">
|
||||
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
|
||||
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
|
||||
</div>
|
||||
</button>
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
427
src/app/page.tsx
427
src/app/page.tsx
@@ -3,93 +3,36 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import MainLogoSvg from './svgs/mainlogosvg';
|
||||
import apiService from './lib/apiService';
|
||||
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;
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [userName, setUserName] = useState<string>('');
|
||||
|
||||
// 코스, 공지사항 더미 데이터
|
||||
const courseCards = useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
id: 'c1',
|
||||
title: '원자력 운영 기초',
|
||||
meta: 'VOD • 초급 • 4시간 20분',
|
||||
image: 'https://picsum.photos/seed/xrlms-c1/1200/800',
|
||||
},
|
||||
{
|
||||
id: 'c2',
|
||||
title: '반도체',
|
||||
meta: 'VOD • 중급 • 3시간 10분',
|
||||
image: 'https://picsum.photos/seed/xrlms-c2/1200/800',
|
||||
},
|
||||
{
|
||||
id: 'c3',
|
||||
title: '방사선 안전',
|
||||
meta: 'VOD • 중급 • 4시간 20분',
|
||||
image: 'https://picsum.photos/seed/xrlms-c3/1200/800',
|
||||
},
|
||||
{
|
||||
id: 'c4',
|
||||
title: '방사선 폐기물',
|
||||
meta: 'VOD • 중급 • 4시간 20분',
|
||||
image: 'https://picsum.photos/seed/xrlms-c4/1200/800',
|
||||
},
|
||||
{
|
||||
id: 'c5',
|
||||
title: '원자력 운전 개론',
|
||||
meta: 'VOD • 초급 • 3시간 00분',
|
||||
image: 'https://picsum.photos/seed/xrlms-c5/1200/800',
|
||||
},
|
||||
{
|
||||
id: 'c6',
|
||||
title: '안전 표지와 표준',
|
||||
meta: 'VOD • 초급 • 2시간 40분',
|
||||
image: 'https://picsum.photos/seed/xrlms-c6/1200/800',
|
||||
},
|
||||
{
|
||||
id: 'c7',
|
||||
title: '발전소 운영',
|
||||
meta: 'VOD • 중급 • 4시간 20분',
|
||||
image: 'https://picsum.photos/seed/xrlms-c7/1200/800',
|
||||
},
|
||||
{
|
||||
id: 'c8',
|
||||
title: '방사선 안전 실습',
|
||||
meta: 'VOD • 중급 • 3시간 30분',
|
||||
image: 'https://picsum.photos/seed/xrlms-c8/1200/800',
|
||||
},
|
||||
{
|
||||
id: 'c9',
|
||||
title: '실험실 안전',
|
||||
meta: 'VOD • 초급 • 2시간 10분',
|
||||
image: 'https://picsum.photos/seed/xrlms-c9/1200/800',
|
||||
},
|
||||
{
|
||||
id: 'c10',
|
||||
title: '기초 장비 운용',
|
||||
meta: 'VOD • 초급 • 2시간 50분',
|
||||
image: 'https://picsum.photos/seed/xrlms-c10/1200/800',
|
||||
},
|
||||
] as Array<{ id: string; title: string; meta: string; image: string }>,
|
||||
[]
|
||||
);
|
||||
const noticeRows = useMemo(
|
||||
() =>
|
||||
[
|
||||
{ id: 5, title: '(공지)시스템 개선이 완료되었...', date: '2025-09-10', views: 1320, writer: '운영팀' },
|
||||
{ id: 4, title: '(공지)서버 점검 안내(9/10 새벽)', date: '2025-09-10', views: 1210, writer: '운영팀' },
|
||||
{ id: 3, title: '(공지)서비스 개선 안내', date: '2025-09-10', views: 1230, writer: '운영팀' },
|
||||
{ id: 2, title: '(공지)시장점검 공지', date: '2025-09-10', views: 1320, writer: '관리자' },
|
||||
{ id: 1, title: '뉴: 봉사시간 안내 및 한눈에 보는 현황 정리', date: '2025-08-28', views: 594, writer: '운영팀' },
|
||||
],
|
||||
[]
|
||||
);
|
||||
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);
|
||||
|
||||
// NOTE: 실제 이미지 자산 연결 시 해당 src를 교체하세요.
|
||||
const slides = useMemo(
|
||||
@@ -167,6 +110,176 @@ export default function Home() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 과목 리스트 가져오기
|
||||
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;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const containerEl = containerRef.current;
|
||||
if (!containerEl) return;
|
||||
@@ -321,11 +434,11 @@ export default function Home() {
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">교육 과정</h2>
|
||||
<div className="text-[15px] font-medium leading-normal text-[#333C47]">
|
||||
총 <span className="text-[#384FBF]">28</span>건
|
||||
총 <span className="text-[#384FBF]">{totalSubjectsCount}</span>건
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="#"
|
||||
<Link
|
||||
href="/course-list"
|
||||
className="flex items-center gap-0.5 text-[14px] font-medium leading-normal text-[#6C7682] no-underline"
|
||||
>
|
||||
전체보기
|
||||
@@ -339,42 +452,47 @@ export default function Home() {
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-5 gap-8">
|
||||
{courseCards.map((c) => (
|
||||
<article key={c.id} className="flex flex-col gap-4 h-[260px]">
|
||||
<div className="h-[166.4px] overflow-hidden rounded-[8px]">
|
||||
<img
|
||||
alt={c.title}
|
||||
src={c.image}
|
||||
className="w-full h-full object-cover block"
|
||||
onError={(e) => {
|
||||
const t = e.currentTarget as HTMLImageElement;
|
||||
if (!t.src.includes('picsum.photos')) t.src = 'https://picsum.photos/seed/xrlms-fallback-card/1200/800';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{c.id === 'c1' && (
|
||||
<span className="inline-flex h-[20px] items-center justify-center px-1 bg-[#E5F5EC] rounded-[4px] text-[13px] font-semibold leading-[1.4] text-[#0C9D61]">
|
||||
수강 중
|
||||
</span>
|
||||
)}
|
||||
<h5 className="m-0 text-[#333C47] font-semibold text-[18px] leading-normal truncate" title={c.title}>
|
||||
{c.title}
|
||||
</h5>
|
||||
<div className="flex items-center gap-1">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden className="text-[#8C95A1]">
|
||||
<path d="M8 5v14l11-7z" fill="currentColor" />
|
||||
</svg>
|
||||
<p className="m-0 text-[#8C95A1] text-[13px] font-medium leading-[1.4]">{c.meta}</p>
|
||||
{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';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 공지사항 */}
|
||||
@@ -383,11 +501,11 @@ export default function Home() {
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">공지사항</h2>
|
||||
<div className="text-[15px] font-medium leading-normal text-[#333C47]">
|
||||
총 <span className="text-[#384FBF]">{noticeRows.length}</span>건
|
||||
총 <span className="text-[#384FBF]">{notices.length}</span>건
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="#"
|
||||
<Link
|
||||
href="/notices"
|
||||
className="flex items-center gap-0.5 text-[14px] font-medium leading-normal text-[#6C7682] no-underline"
|
||||
>
|
||||
전체보기
|
||||
@@ -401,38 +519,57 @@ export default function Home() {
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
|
||||
<div className="grid grid-cols-[57px_1fr_140px_140px_140px] bg-[#F9FAFB] h-[48px] text-[14px] font-semibold text-[#4C5561] border-b border-[#DEE1E6]">
|
||||
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">번호</div>
|
||||
<div className="flex items-center px-4 border-r border-[#DEE1E6]">제목</div>
|
||||
<div className="flex items-center px-4 border-r border-[#DEE1E6]">게시일</div>
|
||||
<div className="flex items-center px-4 border-r border-[#DEE1E6]">조회수</div>
|
||||
<div className="flex items-center px-4">작성자</div>
|
||||
{loadingNotices ? (
|
||||
<div className="flex items-center justify-center h-[240px]">
|
||||
<p className="text-[16px] font-medium text-[#6C7682]">로딩 중...</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-2 whitespace-nowrap border-r border-[#DEE1E6]">번호</div>
|
||||
<div className="flex items-center px-4 border-r border-[#DEE1E6]">제목</div>
|
||||
<div className="flex items-center px-4 border-r border-[#DEE1E6]">게시일</div>
|
||||
<div className="flex items-center px-4 border-r border-[#DEE1E6]">조회수</div>
|
||||
<div className="flex items-center px-4">작성자</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{noticeRows.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="grid grid-cols-[57px_1fr_140px_140px_140px] h-[48px] text-[15px] font-medium text-[#1B2027] border-t border-[#DEE1E6]"
|
||||
>
|
||||
<div className="flex items-center justify-center px-2 whitespace-nowrap border-r border-[#DEE1E6]">{r.id}</div>
|
||||
<div
|
||||
className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
title={r.title}
|
||||
>
|
||||
{r.title}
|
||||
</div>
|
||||
<div className="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>
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,92 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import PaperClipSvg from '../../svgs/paperclipsvg';
|
||||
import BackCircleSvg from '../../svgs/backcirclesvg';
|
||||
import DownloadIcon from '../../svgs/downloadicon';
|
||||
import apiService from '../../lib/apiService';
|
||||
import type { Resource } from '../../admin/resources/mockData';
|
||||
|
||||
type ResourceRow = {
|
||||
id: number;
|
||||
title: string;
|
||||
date: string;
|
||||
views: number;
|
||||
writer: string;
|
||||
content: string[];
|
||||
attachments?: Array<{ name: string; size: string; url: string }>;
|
||||
type Attachment = {
|
||||
name: string;
|
||||
size: string;
|
||||
url?: string;
|
||||
fileKey?: 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 function ResourceDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const [resource, setResource] = useState<Resource | null>(null);
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
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();
|
||||
useEffect(() => {
|
||||
async function fetchResource() {
|
||||
if (!params?.id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await apiService.getLibraryItem(params.id);
|
||||
const data = response.data;
|
||||
|
||||
// API 응답 데이터를 Resource 형식으로 변환
|
||||
const transformedResource: Resource = {
|
||||
id: data.id || data.resourceId || Number(params.id),
|
||||
title: data.title || '',
|
||||
date: data.date || data.createdAt || data.createdDate || new Date().toISOString().split('T')[0],
|
||||
views: data.views || data.viewCount || 0,
|
||||
writer: data.writer || data.author || data.createdBy || '관리자',
|
||||
content: data.content
|
||||
? (Array.isArray(data.content)
|
||||
? data.content
|
||||
: typeof data.content === 'string'
|
||||
? data.content.split('\n').filter((line: string) => line.trim())
|
||||
: [String(data.content)])
|
||||
: [],
|
||||
hasAttachment: data.hasAttachment || data.attachment || false,
|
||||
};
|
||||
|
||||
// 첨부파일 정보 처리
|
||||
if (data.attachments && Array.isArray(data.attachments)) {
|
||||
setAttachments(data.attachments.map((att: any) => ({
|
||||
name: att.name || att.fileName || att.filename || '',
|
||||
size: att.size || att.fileSize || '',
|
||||
url: att.url || att.downloadUrl,
|
||||
fileKey: att.fileKey || att.key || att.fileId,
|
||||
})));
|
||||
} else if (transformedResource.hasAttachment && data.attachment) {
|
||||
// 단일 첨부파일인 경우
|
||||
setAttachments([{
|
||||
name: data.attachment.name || data.attachment.fileName || data.attachment.filename || '첨부파일',
|
||||
size: data.attachment.size || data.attachment.fileSize || '',
|
||||
url: data.attachment.url || data.attachment.downloadUrl,
|
||||
fileKey: data.attachment.fileKey || data.attachment.key || data.attachment.fileId,
|
||||
}]);
|
||||
}
|
||||
|
||||
if (!transformedResource.title) {
|
||||
throw new Error('학습 자료를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
setResource(transformedResource);
|
||||
} catch (err) {
|
||||
console.error('학습 자료 조회 오류:', err);
|
||||
setError('학습 자료를 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchResource();
|
||||
}, [params?.id]);
|
||||
|
||||
const handleDownload = async (fileKey?: string, url?: string, fileName?: string) => {
|
||||
if (url) {
|
||||
// URL이 있으면 직접 다운로드
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName || 'download';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} else if (fileKey) {
|
||||
// fileKey가 있으면 API를 통해 다운로드
|
||||
try {
|
||||
const fileUrl = await apiService.getFile(fileKey);
|
||||
if (fileUrl) {
|
||||
const link = document.createElement('a');
|
||||
link.href = fileUrl;
|
||||
link.download = fileName || 'download';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('파일 다운로드 실패:', err);
|
||||
alert('파일 다운로드에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
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 justify-center px-8">
|
||||
<p className="text-[16px] font-medium text-[#333c47]">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !resource) {
|
||||
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>
|
||||
<div className="flex-1 flex items-center justify-center px-8 pb-8">
|
||||
<p className="text-[16px] font-medium text-[#333c47]">
|
||||
{error || '학습 자료를 찾을 수 없습니다.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const item = resource;
|
||||
|
||||
return (
|
||||
<div className="w-full bg-white">
|
||||
@@ -119,7 +189,11 @@ export default async function ResourceDetailPage({
|
||||
<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="text-[#333C47]">
|
||||
{item.date.includes('T')
|
||||
? new Date(item.date).toISOString().split('T')[0]
|
||||
: item.date}
|
||||
</span>
|
||||
<span className="w-px h-4 bg-[#DEE1E6]" />
|
||||
<span className="text-[#8C95A1]">조회수</span>
|
||||
<span className="text-[#333C47]">{item.views.toLocaleString()}</span>
|
||||
@@ -132,21 +206,25 @@ export default async function ResourceDetailPage({
|
||||
{/* 본문 */}
|
||||
<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>
|
||||
))}
|
||||
{item.content && item.content.length > 0 ? (
|
||||
item.content.map((p, idx) => (
|
||||
<p key={idx} className="m-0">
|
||||
{p}
|
||||
</p>
|
||||
))
|
||||
) : (
|
||||
<p className="m-0 text-[#8C95A1]">내용이 없습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 첨부 파일 */}
|
||||
{item.attachments?.length ? (
|
||||
{attachments.length > 0 && (
|
||||
<div className="p-8 pt-0">
|
||||
<div className="mb-2 text-[15px] font-semibold text-[#6C7682]">
|
||||
첨부 파일
|
||||
</div>
|
||||
{item.attachments.map((f, idx) => (
|
||||
{attachments.map((f, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-white border border-[#DEE1E6] h-[64px] rounded-[6px] flex items-center gap-3 px-[17px]"
|
||||
@@ -158,31 +236,24 @@ export default async function ResourceDetailPage({
|
||||
<span className="text-[15px] text-[#1B2027] truncate">
|
||||
{f.name}
|
||||
</span>
|
||||
<span className="text-[13px] text-[#8C95A1] whitespace-nowrap">
|
||||
{f.size}
|
||||
</span>
|
||||
{f.size && (
|
||||
<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
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDownload(f.fileKey, f.url, f.name)}
|
||||
className="h-8 px-4 rounded-[6px] border border-[#8C95A1] text-[13px] text-[#4C5561] inline-flex items-center gap-1 hover:bg-[#F9FAFB] cursor-pointer"
|
||||
>
|
||||
<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>
|
||||
<DownloadIcon width={16} height={16} className="text-[#4C5561]" />
|
||||
다운로드
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,65 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
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: '강민재',
|
||||
},
|
||||
];
|
||||
import ChevronDownSvg from '../svgs/chevrondownsvg';
|
||||
import apiService from '../lib/apiService';
|
||||
import type { Resource } from '../admin/resources/mockData';
|
||||
|
||||
export default function ResourcesPage() {
|
||||
const router = useRouter();
|
||||
const [resources, setResources] = useState<Resource[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 날짜를 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에서 학습 자료 목록 가져오기
|
||||
useEffect(() => {
|
||||
async function fetchResources() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await apiService.getLibrary();
|
||||
const data = response.data;
|
||||
|
||||
// API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태)
|
||||
let resourcesArray: any[] = [];
|
||||
if (Array.isArray(data)) {
|
||||
resourcesArray = data;
|
||||
} else if (data && typeof data === 'object') {
|
||||
resourcesArray = data.items || data.resources || data.data || data.list || [];
|
||||
}
|
||||
|
||||
// API 응답 데이터를 Resource 형식으로 변환
|
||||
const transformedResources: Resource[] = resourcesArray.map((resource: any) => ({
|
||||
id: resource.id || resource.resourceId || 0,
|
||||
title: resource.title || '',
|
||||
date: resource.date || resource.createdAt || resource.createdDate || new Date().toISOString().split('T')[0],
|
||||
views: resource.views || resource.viewCount || 0,
|
||||
writer: resource.writer || resource.author || resource.createdBy || '관리자',
|
||||
content: resource.content ? (Array.isArray(resource.content) ? resource.content : [resource.content]) : undefined,
|
||||
hasAttachment: resource.hasAttachment || resource.attachment || false,
|
||||
}));
|
||||
|
||||
setResources(transformedResources);
|
||||
} catch (error) {
|
||||
console.error('학습 자료 목록 조회 오류:', error);
|
||||
// 에러 발생 시 빈 배열로 설정
|
||||
setResources([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchResources();
|
||||
}, []);
|
||||
|
||||
const totalCount = useMemo(() => resources.length, [resources]);
|
||||
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
const sortedResources = useMemo(() => {
|
||||
return [...resources].sort((a, b) => {
|
||||
// 생성일 내림차순 정렬 (최신 날짜가 먼저)
|
||||
return b.date.localeCompare(a.date);
|
||||
});
|
||||
}, [resources]);
|
||||
|
||||
const totalPages = Math.ceil(sortedResources.length / ITEMS_PER_PAGE);
|
||||
const paginatedResources = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||
return sortedResources.slice(startIndex, endIndex);
|
||||
}, [sortedResources, currentPage]);
|
||||
|
||||
return (
|
||||
<div className="w-full bg-white">
|
||||
@@ -77,70 +109,173 @@ export default function ResourcesPage() {
|
||||
{/* 총 건수 */}
|
||||
<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>건
|
||||
총 <span className="text-[#384FBF]">{totalCount}</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>
|
||||
{isLoading ? (
|
||||
<div className="rounded-[8px] border border-[#DEE1E6] bg-white min-h-[400px] flex items-center justify-center">
|
||||
<p className="text-[16px] font-medium leading-[1.5] text-[#333C47]">
|
||||
로딩 중...
|
||||
</p>
|
||||
</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}
|
||||
) : resources.length === 0 ? (
|
||||
<div className="rounded-[8px] border border-[#DEE1E6] bg-white min-h-[400px] flex items-center justify-center">
|
||||
<p className="text-[16px] font-medium leading-[1.5] text-[#333C47] text-center">
|
||||
등록된 학습 자료가 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 표 */}
|
||||
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
|
||||
{/* 헤더 */}
|
||||
<div className="grid grid-cols-[80px_1fr_140px_120px_120px] 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 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 className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
||||
제목
|
||||
</div>
|
||||
<div className="flex items-center px-4 border-r border-[#DEE1E6]">
|
||||
{r.date}
|
||||
<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]">
|
||||
{r.views.toLocaleString()}
|
||||
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
||||
조회수
|
||||
</div>
|
||||
<div className="flex items-center px-4">{r.writer}</div>
|
||||
<div className="flex items-center px-4 whitespace-nowrap">작성자</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 바디 */}
|
||||
<div>
|
||||
{paginatedResources.map((resource, index) => {
|
||||
// 번호는 전체 목록에서의 순서 (정렬된 목록 기준)
|
||||
const resourceNumber = sortedResources.length - (currentPage - 1) * ITEMS_PER_PAGE - index;
|
||||
return (
|
||||
<div
|
||||
key={resource.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => router.push(`/resources/${resource.id}`)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
router.push(`/resources/${resource.id}`);
|
||||
}
|
||||
}}
|
||||
className={[
|
||||
'grid grid-cols-[80px_1fr_140px_120px_120px] h-[48px] text-[13px] 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-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
||||
{resourceNumber}
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-4 border-r border-[#DEE1E6] whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
title={resource.title}
|
||||
>
|
||||
{resource.title}
|
||||
{resource.hasAttachment && (
|
||||
<PaperClipSvg width={16} height={16} className="shrink-0 text-[#8C95A1]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
||||
{formatDate(resource.date)}
|
||||
</div>
|
||||
<div className="flex items-center px-4 border-r border-[#DEE1E6] whitespace-nowrap">
|
||||
{resource.views.toLocaleString()}
|
||||
</div>
|
||||
<div className="flex items-center px-4 whitespace-nowrap">{resource.writer}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 - 10개 초과일 때만 표시 */}
|
||||
{resources.length > ITEMS_PER_PAGE && (
|
||||
<div className="pt-8 pb-[32px] flex items-center justify-center">
|
||||
<div className="flex items-center justify-center gap-[8px]">
|
||||
{/* First (맨 앞으로) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(1)}
|
||||
aria-label="맨 앞 페이지"
|
||||
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<div className="relative flex items-center justify-center w-full h-full">
|
||||
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute left-[2px]" />
|
||||
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90 absolute right-[2px]" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Prev */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
aria-label="이전 페이지"
|
||||
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronDownSvg width={14.8} height={14.8} className="rotate-90" />
|
||||
</button>
|
||||
|
||||
{/* Numbers */}
|
||||
{(() => {
|
||||
const pageGroup = Math.floor((currentPage - 1) / 10);
|
||||
const startPage = pageGroup * 10 + 1;
|
||||
const endPage = Math.min(startPage + 9, totalPages);
|
||||
const visiblePages = Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i);
|
||||
|
||||
return visiblePages.map((n) => {
|
||||
const active = n === currentPage;
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(n)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={[
|
||||
'flex items-center justify-center rounded-[1000px] size-[32px] cursor-pointer',
|
||||
active ? 'bg-[#ecf0ff]' : 'bg-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-[16px] leading-[1.4] text-[#333c47]">{n}</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
|
||||
{/* Next */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
aria-label="다음 페이지"
|
||||
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90" />
|
||||
</button>
|
||||
|
||||
{/* Last (맨 뒤로) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
aria-label="맨 뒤 페이지"
|
||||
className="flex items-center justify-center rounded-[1000px] p-[8.615px] size-[32px] text-[#333c47] disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<div className="relative flex items-center justify-center w-full h-full">
|
||||
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute left-[2px]" />
|
||||
<ChevronDownSvg width={14.8} height={14.8} className="-rotate-90 absolute right-[2px]" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user