banner page

This commit is contained in:
2025-11-30 00:25:16 +09:00
parent 109ca05e23
commit 54d763e008
7 changed files with 1899 additions and 35 deletions

View File

@@ -0,0 +1,606 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import ModalCloseSvg from "@/app/svgs/closexsvg";
import CloseXOSvg from "@/app/svgs/closexo";
import apiService from "@/app/lib/apiService";
export type Banner = {
id: number;
order: number;
imageUrl: string;
title: string;
description: string;
registeredDate: string;
};
type Props = {
open: boolean;
onClose: () => void;
onSave?: (title: string, description: string, imageKey?: string) => void;
onDelete?: () => void;
editingBanner?: Banner | null;
};
export default function BannerRegistrationModal({ open, onClose, onSave, onDelete, editingBanner }: Props) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [errors, setErrors] = useState<Record<string, string>>({});
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
const [selectedImage, setSelectedImage] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isImageDeleted, setIsImageDeleted] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// previewUrl 변경 시 이전 Blob URL 정리
useEffect(() => {
return () => {
if (previewUrl && previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(previewUrl);
}
};
}, [previewUrl]);
// 수정 모드일 때 기존 데이터 채우기
useEffect(() => {
if (open && editingBanner) {
setTitle(editingBanner.title);
setDescription(editingBanner.description);
// 수정 모드일 때 이미지 로드
if (editingBanner.imageUrl) {
setIsImageDeleted(false);
setSelectedImage(null);
setPreviewUrl(editingBanner.imageUrl);
} else {
setIsImageDeleted(false);
setSelectedImage(null);
setPreviewUrl(null);
}
} else if (!open) {
setTitle("");
setDescription("");
setErrors({});
setIsDeleteConfirmOpen(false);
setSelectedImage(null);
setPreviewUrl(null);
setIsDragging(false);
setIsImageDeleted(false);
}
}, [open, editingBanner]);
// 모달 클릭 시 이벤트 전파 방지
const handleModalClick = (e: React.MouseEvent) => {
e.stopPropagation();
};
// 저장 버튼 클릭 핸들러
const handleSave = async () => {
const nextErrors: Record<string, string> = {};
if (!title.trim()) {
nextErrors.title = "배너 제목을 입력해 주세요.";
}
if (!description.trim()) {
nextErrors.description = "배너 설명을 입력해 주세요.";
}
setErrors(nextErrors);
if (Object.keys(nextErrors).length > 0) {
return;
}
if (isSaving) return;
setIsSaving(true);
setErrors((prev) => {
const next = { ...prev };
delete next.submit;
return next;
});
try {
let imageKey: string | null = null;
// 새 이미지가 선택된 경우 업로드
if (selectedImage) {
try {
const uploadResponse = await apiService.uploadFile(selectedImage);
if (uploadResponse.data) {
imageKey = uploadResponse.data.imageKey
|| uploadResponse.data.key
|| uploadResponse.data.id
|| uploadResponse.data.fileKey
|| uploadResponse.data.fileId
|| (uploadResponse.data.data && (uploadResponse.data.data.imageKey || uploadResponse.data.data.key))
|| null;
}
} catch (uploadError) {
const errorMessage = uploadError instanceof Error ? uploadError.message : '이미지 업로드 중 오류가 발생했습니다.';
console.error('이미지 업로드 오류:', errorMessage);
setErrors((prev) => ({
...prev,
image: '이미지 업로드 중 오류가 발생했습니다. 이미지 없이 계속 진행됩니다.',
}));
}
}
// TODO: 배너 API가 추가되면 아래 주석을 해제하고 실제 API 호출
/*
if (editingBanner && editingBanner.id) {
// 수정 모드
await apiService.updateBanner(editingBanner.id, {
title: title.trim(),
description: description.trim(),
imageKey: isImageDeleted ? "null" : (imageKey || editingBanner.imageUrl || "null"),
});
} else {
// 등록 모드
await apiService.createBanner({
title: title.trim(),
description: description.trim(),
imageKey: imageKey || undefined,
});
}
*/
// 성공 시 onSave 콜백 호출 및 모달 닫기
if (onSave) {
onSave(title.trim(), description.trim(), imageKey || undefined);
}
onClose();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '네트워크 오류가 발생했습니다.';
console.error('배너 저장 오류:', errorMessage);
setErrors({ submit: errorMessage });
} finally {
setIsSaving(false);
}
};
// 삭제 버튼 클릭 핸들러
const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation();
setIsDeleteConfirmOpen(true);
};
// 삭제 확인 핸들러
const handleDeleteConfirm = async () => {
if (!editingBanner || !editingBanner.id) {
console.error('삭제할 배너 정보가 없습니다.');
setErrors({ submit: '삭제할 배너 정보가 없습니다.' });
return;
}
if (isDeleting) return;
setIsDeleting(true);
setErrors((prev) => {
const next = { ...prev };
delete next.submit;
return next;
});
try {
// TODO: 배너 API가 추가되면 아래 주석을 해제하고 실제 API 호출
// await apiService.deleteBanner(editingBanner.id);
// 성공 시 모달 닫기 및 콜백 호출
setIsDeleteConfirmOpen(false);
if (onDelete) {
onDelete();
}
onClose();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '배너 삭제 중 오류가 발생했습니다.';
console.error('배너 삭제 실패:', errorMessage);
setErrors({ submit: errorMessage });
} finally {
setIsDeleting(false);
}
};
// 삭제 취소 핸들러
const handleDeleteCancel = () => {
setIsDeleteConfirmOpen(false);
};
// 파일 유효성 검사
const validateImageFile = (file: File): string | null => {
const maxSize = 30 * 1024 * 1024; // 30MB
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg'];
if (!allowedTypes.includes(file.type)) {
return "PNG 또는 JPG 파일만 업로드 가능합니다.";
}
if (file.size > maxSize) {
return "파일 크기는 30MB 미만이어야 합니다.";
}
return null;
};
// 이미지 파일 처리
const handleImageFile = (file: File) => {
const error = validateImageFile(file);
if (error) {
setErrors((prev) => ({ ...prev, image: error }));
return;
}
setSelectedImage(file);
setIsImageDeleted(false);
setErrors((prev) => {
const next = { ...prev };
delete next.image;
return next;
});
// 기존 previewUrl이 Blob URL인 경우 메모리 해제
if (previewUrl && previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(previewUrl);
}
// 미리보기 URL 생성
const reader = new FileReader();
reader.onloadend = () => {
setPreviewUrl(reader.result as string);
};
reader.readAsDataURL(file);
};
// 파일 선택 핸들러
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleImageFile(file);
}
};
// 클릭으로 파일 선택
const handleImageAreaClick = () => {
fileInputRef.current?.click();
};
// 드래그 오버 핸들러
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
// 드래그 리브 핸들러
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
// 드롭 핸들러
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const file = e.dataTransfer.files?.[0];
if (file) {
handleImageFile(file);
}
};
// 이미지 삭제 핸들러
const handleRemoveImage = (e: React.MouseEvent) => {
e.stopPropagation();
setSelectedImage(null);
// previewUrl이 Blob URL인 경우 메모리 해제
if (previewUrl && previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(previewUrl);
}
setPreviewUrl(null);
setIsImageDeleted(true);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setErrors((prev) => {
const next = { ...prev };
delete next.image;
return next;
});
};
if (!open && !isDeleteConfirmOpen) return null;
return (
<>
{/* 메인 모달 */}
{open && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
aria-hidden={!open}
onClick={onClose}
>
<div
className="absolute inset-0 bg-black/40"
aria-hidden="true"
/>
<div
ref={modalRef}
className="relative z-10 shadow-xl"
onClick={handleModalClick}
>
<div className="bg-white border border-[var(--color-neutral-40)] rounded-[12px] w-full min-w-[480px] max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between gap-[10px] p-6">
<h2 className="text-[20px] font-bold leading-normal text-[var(--color-neutral-700)]">
{editingBanner ? "배너 수정" : "배너 등록"}
</h2>
<button
type="button"
onClick={onClose}
className="w-6 h-6 flex items-center justify-center cursor-pointer hover:opacity-80 shrink-0"
aria-label="닫기"
>
<ModalCloseSvg />
</button>
</div>
{/* Form Container */}
<div className="px-6 py-0">
<div className="flex flex-col gap-6">
{/* 배너 제목 */}
<div className="flex flex-col gap-2">
<label className="text-[15px] font-semibold leading-normal text-[var(--color-text-label)] w-[100px]">
<span className="text-[var(--color-error)]">*</span>
</label>
<input
type="text"
value={title}
onChange={(e) => {
setTitle(e.target.value);
if (errors.title) {
setErrors((prev) => {
const next = { ...prev };
delete next.title;
return next;
});
}
}}
placeholder="배너 제목을 입력해 주세요."
className={`h-[40px] px-3 py-2 border rounded-[8px] bg-white text-[16px] font-normal leading-normal text-[var(--color-text-title)] placeholder:text-[var(--color-text-placeholder-alt)] focus:outline-none ${
errors.title
? "border-[var(--color-error)] focus:shadow-[inset_0_0_0_1px_var(--color-neutral-700)]"
: "border-[var(--color-neutral-40)] focus:shadow-[inset_0_0_0_1px_var(--color-neutral-700)]"
}`}
/>
{errors.title && (
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.title}</p>
)}
</div>
{/* 배너 설명 */}
<div className="flex flex-col gap-2">
<label className="text-[15px] font-semibold leading-normal text-[var(--color-text-label)] w-[100px]">
<span className="text-[var(--color-error)]">*</span>
</label>
<textarea
value={description}
onChange={(e) => {
setDescription(e.target.value);
if (errors.description) {
setErrors((prev) => {
const next = { ...prev };
delete next.description;
return next;
});
}
}}
placeholder="배너 설명을 입력해 주세요."
rows={4}
className={`px-3 py-2 border rounded-[8px] bg-white text-[16px] font-normal leading-normal text-[var(--color-text-title)] placeholder:text-[var(--color-text-placeholder-alt)] focus:outline-none resize-none ${
errors.description
? "border-[var(--color-error)] focus:shadow-[inset_0_0_0_1px_var(--color-neutral-700)]"
: "border-[var(--color-neutral-40)] focus:shadow-[inset_0_0_0_1px_var(--color-neutral-700)]"
}`}
/>
{errors.description && (
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.description}</p>
)}
</div>
{/* 배너 이미지 */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<label className="text-[15px] font-semibold leading-normal text-[var(--color-text-label)] whitespace-pre">
</label>
<span className="text-[13px] font-normal leading-[1.4] text-[var(--color-text-meta)]">
30MB PNG, JPG
</span>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/jpg"
onChange={handleFileSelect}
className="hidden"
aria-label="이미지 파일 선택"
/>
<div
onClick={handleImageAreaClick}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`border border-dashed min-h-[192px] rounded-[8px] flex flex-col items-center justify-center gap-3 cursor-pointer transition-colors px-0 relative overflow-hidden ${
isDragging
? "bg-blue-50 border-blue-300"
: previewUrl
? "bg-white border-[var(--color-neutral-40)]"
: "bg-gray-50 border-[var(--color-neutral-40)] hover:bg-gray-100"
}`}
>
{previewUrl ? (
<>
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative flex items-start">
<img
src={previewUrl}
alt="미리보기"
className="h-[160px] w-auto object-contain"
/>
<button
type="button"
onClick={handleRemoveImage}
className="w-[18px] h-[18px] flex items-center justify-center ml-1 z-10 hover:opacity-80 transition-opacity shrink-0"
aria-label="이미지 삭제"
>
<CloseXOSvg width={18} height={18} />
</button>
</div>
</div>
<div className="absolute inset-0 bg-black/0 hover:bg-black/20 transition-colors flex items-center justify-center my-4">
<div className="text-center opacity-0 hover:opacity-100 transition-opacity">
<p className="text-[14px] font-normal leading-normal text-white">
</p>
</div>
</div>
</>
) : (
<>
<div className="w-10 h-10 flex items-center justify-center shrink-0">
<svg
width="40"
height="40"
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20 13.3333V26.6667M13.3333 20H26.6667"
stroke="var(--color-text-meta)"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<div className="text-center">
<p className="text-[14px] font-normal leading-normal text-[var(--color-text-meta)] whitespace-pre">
( )
<br aria-hidden="true" />
.
</p>
</div>
</>
)}
</div>
{errors.image && (
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.image}</p>
)}
</div>
</div>
</div>
{/* 에러 메시지 표시 */}
{errors.submit && (
<div className="px-6 pb-2">
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.submit}</p>
</div>
)}
{/* Actions Container */}
<div className="flex flex-col gap-8 h-[96px] items-center p-6">
<div className="flex items-center justify-center gap-3 w-full">
{editingBanner && (
<button
type="button"
onClick={handleDeleteClick}
className="h-[48px] px-4 rounded-[10px] bg-[#fef2f2] text-[16px] font-semibold leading-normal text-[var(--color-error)] w-[136px] hover:bg-[#fae6e6] transition-colors cursor-pointer"
>
</button>
)}
<button
type="button"
onClick={onClose}
className="h-[48px] px-4 rounded-[10px] bg-[var(--color-bg-gray-light)] text-[16px] font-semibold leading-normal text-[var(--color-basic-text)] w-[136px] hover:bg-[var(--color-bg-gray-hover)] transition-colors cursor-pointer"
>
</button>
<button
type="button"
onClick={handleSave}
disabled={isSaving}
className="h-[48px] px-4 rounded-[10px] bg-[var(--color-active-button)] text-[16px] font-semibold leading-normal text-white w-[136px] hover:bg-[var(--color-active-button-hover)] transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving ? '저장 중...' : '저장'}
</button>
</div>
</div>
</div>
</div>
</div>
)}
{/* 삭제 확인 모달 */}
{isDeleteConfirmOpen && (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={handleDeleteCancel}
aria-hidden="true"
/>
<div className="relative z-10 bg-white rounded-[8px] p-[24px] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)] flex flex-col gap-[32px] items-end justify-end">
<div className="flex flex-col gap-[16px] items-start justify-center w-full">
<div className="flex gap-[8px] items-start w-full">
<p className="text-[18px] font-semibold leading-[1.5] text-[#333c47]">
?
</p>
</div>
<div className="flex gap-[8px] items-start w-full">
<div className="text-[15px] font-normal leading-[1.5] text-[#4c5561]">
<p className="mb-0"> .</p>
<p> ?</p>
</div>
</div>
{errors.submit && (
<p className="text-[var(--color-error)] text-[13px] leading-tight">{errors.submit}</p>
)}
</div>
<div className="flex gap-[8px] items-center justify-end shrink-0">
<button
type="button"
onClick={handleDeleteCancel}
className="bg-[#f1f3f5] h-[40px] rounded-[8px] px-[8px] flex items-center justify-center shrink-0 w-[80px] hover:bg-[#e9ecef] cursor-pointer transition-colors"
>
<p className="text-[16px] font-semibold leading-[1.5] text-[#4c5561] text-center whitespace-pre">
</p>
</button>
<button
type="button"
onClick={handleDeleteConfirm}
disabled={isDeleting}
className="bg-[#f64c4c] h-[40px] rounded-[8px] px-[8px] flex items-center justify-center shrink-0 w-[80px] hover:bg-[#e63939] cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<p className="text-[16px] font-semibold leading-[1.5] text-white text-center whitespace-pre">
{isDeleting ? '삭제 중...' : '삭제'}
</p>
</button>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,180 @@
'use client';
import { useState } from "react";
import AdminSidebar from "@/app/components/AdminSidebar";
import BannerRegistrationModal, { type Banner } from "./BannerRegistrationModal";
export default function AdminBannerPage() {
// TODO: 나중에 실제 데이터로 교체
const [banners, setBanners] = useState<Banner[]>([
{
id: 1,
order: 1,
imageUrl: "http://localhost:3845/assets/43be88ae6f992fc221d0d9c29e82073e7b202f46.png",
title: "XR 교육 플랫폼에 오신 것을 환영합니다",
description: "다양한 강좌와 함께 성장하는 학습 경험을 시작하세요.",
registeredDate: "2025-09-10",
},
]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingBanner, setEditingBanner] = useState<Banner | null>(null);
const handleRegister = () => {
setEditingBanner(null);
setIsModalOpen(true);
};
const handleModalClose = () => {
setIsModalOpen(false);
setEditingBanner(null);
};
const handleSaveBanner = (title: string, description: string, imageKey?: string) => {
// TODO: API가 추가되면 실제로 배너를 저장하고 리스트를 새로고침
console.log('배너 저장:', { title, description, imageKey });
setIsModalOpen(false);
setEditingBanner(null);
};
const handleDeleteBanner = () => {
// TODO: API가 추가되면 실제로 배너를 삭제하고 리스트를 새로고침
console.log('배너 삭제');
setIsModalOpen(false);
setEditingBanner(null);
};
const handleRowClick = (banner: Banner) => {
setEditingBanner(banner);
setIsModalOpen(true);
};
return (
<div className="min-h-screen flex flex-col bg-white">
{/* 메인 레이아웃 */}
<div className="flex flex-1 min-h-0 justify-center">
<div className="w-[1440px] flex min-h-0">
{/* 사이드바 */}
<div className="flex">
<AdminSidebar />
</div>
{/* 메인 콘텐츠 */}
<main className="w-[1120px] bg-white">
<div className="h-full flex flex-col px-8">
{/* 제목 영역 */}
<div className="h-[100px] flex items-center">
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
</h1>
</div>
{/* 콘텐츠 영역 */}
<div className="flex-1 pt-8 flex flex-col gap-4">
{/* 상단 정보 및 버튼 */}
<div className="flex items-center justify-between">
<p className="text-[15px] font-medium leading-[1.5] text-[#333c47]">
{banners.length}
</p>
<div className="flex gap-3">
<button
type="button"
onClick={handleRegister}
className="bg-[#1f2b91] text-white text-[16px] font-semibold leading-[1.5] px-4 py-2 rounded-lg hover:bg-[#1a2478] transition-colors"
>
</button>
</div>
</div>
{/* 테이블 */}
{banners.length === 0 ? (
<div className="rounded-lg 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 className="border border-[#dee1e6] rounded-lg overflow-hidden">
<div className="flex flex-col">
{/* 테이블 헤더 */}
<div className="bg-gray-50 flex h-12">
<div className="border-r border-[#dee1e6] flex items-center justify-center px-4 py-3 shrink-0 w-[57px]">
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">
</p>
</div>
<div className="border-r border-[#dee1e6] flex items-center px-4 py-3 shrink-0 w-[240px]">
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">
</p>
</div>
<div className="border-r border-[#dee1e6] flex items-center px-4 py-3 flex-1 min-w-0">
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">
</p>
</div>
<div className="flex items-center px-4 py-3 shrink-0 w-[140px]">
<p className="text-[14px] font-semibold leading-[1.5] text-[#4c5561]">
</p>
</div>
</div>
{/* 테이블 바디 */}
{banners.map((banner) => (
<div
key={banner.id}
className="border-t border-[#dee1e6] flex cursor-pointer hover:bg-[#F5F7FF] transition-colors"
onClick={() => handleRowClick(banner)}
>
<div className="border-r border-[#dee1e6] flex items-center justify-center px-4 py-3 shrink-0 w-[57px]">
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">
{banner.order}
</p>
</div>
<div className="border-r border-[#dee1e6] flex items-center justify-center px-4 py-3 shrink-0 w-[240px]">
<div className="h-[120px] w-[208px] rounded overflow-hidden">
<img
src={banner.imageUrl}
alt={banner.title}
className="w-full h-full object-cover"
/>
</div>
</div>
<div className="border-r border-[#dee1e6] flex items-center px-4 py-3 flex-1 min-w-0">
<div className="flex flex-col gap-1">
<p className="text-[16px] font-semibold leading-[1.5] text-[#1b2027]">
{banner.title}
</p>
<p className="text-[14px] font-medium leading-[1.5] text-[#333c47]">
{banner.description}
</p>
</div>
</div>
<div className="flex items-center px-4 py-3 shrink-0 w-[140px]">
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027]">
{banner.registeredDate}
</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</main>
</div>
</div>
<BannerRegistrationModal
open={isModalOpen}
onClose={handleModalClose}
onSave={handleSaveBanner}
onDelete={handleDeleteBanner}
editingBanner={editingBanner}
/>
</div>
);
}

View File

@@ -0,0 +1,483 @@
'use client';
import { useState, useMemo, useRef, ChangeEvent, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import AdminSidebar from "@/app/components/AdminSidebar";
import BackArrowSvg from "@/app/svgs/backarrow";
import CloseXOSvg from "@/app/svgs/closexo";
import apiService from "@/app/lib/apiService";
import NoticeValidationModal from "@/app/admin/notices/NoticeValidationModal";
import NoticeCancelModal from "@/app/admin/notices/NoticeCancelModal";
import NoChangesModal from "@/app/admin/notices/NoChangesModal";
type Attachment = {
name: string;
size: string;
url?: string;
fileKey?: string;
};
export default function AdminResourceEditPage() {
const params = useParams();
const router = useRouter();
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [attachedFile, setAttachedFile] = useState<File | null>(null);
const [fileKey, setFileKey] = useState<string | null>(null);
const [existingAttachment, setExistingAttachment] = useState<Attachment | null>(null);
const [originalTitle, setOriginalTitle] = useState<string>('');
const [originalContent, setOriginalContent] = useState<string>('');
const [originalFileKey, setOriginalFileKey] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const [isValidationModalOpen, setIsValidationModalOpen] = useState(false);
const [isCancelModalOpen, setIsCancelModalOpen] = useState(false);
const [isNoChangesModalOpen, setIsNoChangesModalOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const characterCount = useMemo(() => content.length, [content]);
// 학습 자료 데이터 로드
useEffect(() => {
async function fetchResource() {
if (!params?.id) {
setIsLoadingData(false);
return;
}
try {
setIsLoadingData(true);
const response = await apiService.getLibraryItem(params.id);
const data = response.data;
// 제목 설정
const loadedTitle = data.title || '';
setTitle(loadedTitle);
setOriginalTitle(loadedTitle);
// 내용 설정 (배열이면 join, 문자열이면 그대로)
let loadedContent = '';
if (data.content) {
if (Array.isArray(data.content)) {
loadedContent = data.content.join('\n');
} else if (typeof data.content === 'string') {
loadedContent = data.content;
} else {
loadedContent = String(data.content);
}
}
setContent(loadedContent);
setOriginalContent(loadedContent);
// 기존 첨부파일 정보 설정
if (data.attachments && Array.isArray(data.attachments) && data.attachments.length > 0) {
const att = data.attachments[0];
setExistingAttachment({
name: att.name || att.fileName || att.filename || '첨부파일',
size: att.size || att.fileSize || '',
url: att.url || att.downloadUrl,
fileKey: att.fileKey || att.key || att.fileId,
});
// 기존 파일이 있으면 fileKey도 설정
const loadedFileKey = att.fileKey || att.key || att.fileId;
if (loadedFileKey) {
setFileKey(loadedFileKey);
setOriginalFileKey(loadedFileKey);
}
} else if (data.attachment) {
// 단일 첨부파일인 경우
setExistingAttachment({
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,
});
const loadedFileKey = data.attachment.fileKey || data.attachment.key || data.attachment.fileId;
if (loadedFileKey) {
setFileKey(loadedFileKey);
setOriginalFileKey(loadedFileKey);
}
}
} catch (error) {
console.error('학습 자료 조회 오류:', error);
alert('학습 자료를 불러오는 중 오류가 발생했습니다.');
router.push('/admin/resources');
} finally {
setIsLoadingData(false);
}
}
fetchResource();
}, [params?.id, router]);
const handleBack = () => {
router.push(`/admin/resources/${params.id}`);
};
const handleFileAttach = () => {
fileInputRef.current?.click();
};
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > 30 * 1024 * 1024) {
alert('30MB 미만의 파일만 첨부할 수 있습니다.');
return;
}
try {
setIsLoading(true);
// 단일 파일 업로드
const uploadResponse = await apiService.uploadFile(file);
// 응답에서 fileKey 추출
let extractedFileKey: string | null = null;
if (uploadResponse.data?.fileKey) {
extractedFileKey = uploadResponse.data.fileKey;
} else if (uploadResponse.data?.key) {
extractedFileKey = uploadResponse.data.key;
} else if (uploadResponse.data?.id) {
extractedFileKey = uploadResponse.data.id;
} else if (uploadResponse.data?.imageKey) {
extractedFileKey = uploadResponse.data.imageKey;
} else if (uploadResponse.data?.fileId) {
extractedFileKey = uploadResponse.data.fileId;
} else if (uploadResponse.data?.data && (uploadResponse.data.data.key || uploadResponse.data.data.fileKey)) {
extractedFileKey = uploadResponse.data.data.key || uploadResponse.data.data.fileKey;
} else if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results) && uploadResponse.data.results.length > 0) {
const result = uploadResponse.data.results[0];
if (result.ok && result.fileKey) {
extractedFileKey = result.fileKey;
}
}
if (extractedFileKey) {
setFileKey(extractedFileKey);
setAttachedFile(file);
// 새 파일을 업로드하면 기존 파일 정보 제거
setExistingAttachment(null);
} else {
throw new Error('파일 키를 받아오지 못했습니다.');
}
} catch (error) {
console.error('파일 업로드 실패:', error);
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
setAttachedFile(null);
setFileKey(null);
} finally {
setIsLoading(false);
// 파일 입력 초기화
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
}
};
const handleFileRemove = () => {
setAttachedFile(null);
setExistingAttachment(null);
setFileKey(null);
// 파일 입력 초기화
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleSave = async () => {
if (!title.trim() || !content.trim()) {
setIsValidationModalOpen(true);
return;
}
if (!params?.id) {
alert('학습 자료 ID를 찾을 수 없습니다.');
return;
}
try {
setIsLoading(true);
// 변경된 필드만 포함하는 request body 생성
const resourceData: any = {};
// 제목이 변경되었는지 확인
const trimmedTitle = title.trim();
if (trimmedTitle !== originalTitle) {
resourceData.title = trimmedTitle;
}
// 내용이 변경되었는지 확인
const trimmedContent = content.trim();
if (trimmedContent !== originalContent) {
resourceData.content = trimmedContent;
}
// 파일 변경사항 확인
const currentFileKey = fileKey;
const hasFileChanged = currentFileKey !== originalFileKey;
// 파일이 삭제된 경우 (기존에 파일이 있었는데 지금 없음)
if (originalFileKey && !currentFileKey) {
resourceData.attachments = [];
}
// 파일이 변경되었거나 새로 추가된 경우
else if (hasFileChanged && currentFileKey) {
if (attachedFile) {
// 새로 업로드한 파일
resourceData.attachments = [
{
fileKey: currentFileKey,
filename: attachedFile.name,
mimeType: attachedFile.type || 'application/octet-stream',
size: attachedFile.size,
},
];
} else if (existingAttachment && existingAttachment.fileKey) {
// 기존 파일 유지
resourceData.attachments = [
{
fileKey: existingAttachment.fileKey,
filename: existingAttachment.name,
},
];
}
}
// 변경사항이 없으면 알림 후 리턴
if (Object.keys(resourceData).length === 0) {
setIsNoChangesModalOpen(true);
setIsLoading(false);
return;
}
await apiService.updateLibraryItem(params.id, resourceData);
// 성공 시 학습 자료 리스트로 이동 (토스트는 리스트 페이지에서 표시)
router.push('/admin/resources?updated=true');
} catch (error) {
console.error('학습 자료 수정 실패:', error);
alert('학습 자료 수정에 실패했습니다. 다시 시도해주세요.');
} finally {
setIsLoading(false);
}
};
const handleCancel = () => {
if (title.trim() || content.trim() || attachedFile || fileKey) {
setIsCancelModalOpen(true);
} else {
handleBack();
}
};
const handleCancelConfirm = () => {
setIsCancelModalOpen(false);
handleBack();
};
if (isLoadingData) {
return (
<div className="min-h-screen flex flex-col bg-white">
<div className="flex flex-1 min-h-0 justify-center">
<div className="w-[1440px] flex min-h-0">
<div className="flex">
<AdminSidebar />
</div>
<main className="w-[1120px] bg-white">
<div className="h-full flex flex-col">
<div className="h-[100px] flex items-center justify-center px-[32px]">
<p className="text-[16px] font-medium text-[#333c47]"> ...</p>
</div>
</div>
</main>
</div>
</div>
</div>
);
}
return (
<>
<NoticeValidationModal
open={isValidationModalOpen}
onClose={() => setIsValidationModalOpen(false)}
/>
<NoticeCancelModal
open={isCancelModalOpen}
onClose={() => setIsCancelModalOpen(false)}
onConfirm={handleCancelConfirm}
/>
<NoChangesModal
open={isNoChangesModalOpen}
onClose={() => setIsNoChangesModalOpen(false)}
/>
<div className="min-h-screen flex flex-col bg-white">
{/* 메인 레이아웃 */}
<div className="flex flex-1 min-h-0 justify-center">
<div className="w-[1440px] flex min-h-0">
{/* 사이드바 */}
<div className="flex">
<AdminSidebar />
</div>
{/* 메인 콘텐츠 */}
<main className="w-[1120px] bg-white">
<div className="h-full flex flex-col px-8">
{/* 작성 모드 헤더 */}
<div className="h-[100px] flex items-center">
<div className="flex gap-3 items-center">
<button
type="button"
onClick={handleBack}
className="flex items-center justify-center w-8 h-8 cursor-pointer"
aria-label="뒤로가기"
>
<BackArrowSvg width={32} height={32} />
</button>
<h1 className="text-[24px] font-bold leading-[1.5] text-[#1b2027]">
</h1>
</div>
</div>
{/* 작성 폼 */}
<div className="flex-1 flex flex-col gap-10 pb-20 pt-8 w-full">
<div className="flex flex-col gap-6 w-full">
{/* 제목 입력 */}
<div className="flex flex-col gap-2 items-start justify-center w-full">
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] w-[100px]">
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="제목을 입력해 주세요."
className="w-full h-[40px] px-3 py-2 rounded-[8px] border border-[#dee1e6] bg-white text-[16px] font-normal leading-[1.5] text-[#1b2027] placeholder:text-[#b1b8c0] focus:outline-none focus:ring-2 focus:ring-[#1f2b91] focus:border-transparent"
/>
</div>
{/* 내용 입력 */}
<div className="flex flex-col gap-2 items-start justify-center w-full">
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] w-[100px]">
</label>
<div className="relative w-full">
<textarea
value={content}
onChange={(e) => {
const newContent = e.target.value;
if (newContent.length <= 1000) {
setContent(newContent);
}
}}
placeholder="내용을 입력해 주세요. (최대 1,000자 이내)"
className="w-full h-[320px] px-3 py-2 rounded-[8px] border border-[#dee1e6] bg-white text-[16px] font-normal leading-[1.5] text-[#1b2027] placeholder:text-[#b1b8c0] resize-none focus:outline-none focus:ring-2 focus:ring-[#1f2b91] focus:border-transparent"
/>
<div className="absolute bottom-3 right-3">
<p className="text-[13px] font-normal leading-[1.4] text-[#6c7682] text-right">
{characterCount}/1000
</p>
</div>
</div>
</div>
{/* 첨부 파일 */}
<div className="flex flex-col gap-2 items-start justify-center w-full">
<div className="flex items-center justify-between h-8 w-full">
<div className="flex items-center gap-3 flex-1 min-w-0">
<label className="text-[15px] font-semibold leading-[1.5] text-[#6c7682] whitespace-nowrap">
{' '}
<span className="font-normal">
{(attachedFile || existingAttachment) ? 1 : 0}/1
</span>
</label>
<p className="text-[13px] font-normal leading-[1.4] text-[#8c95a1] whitespace-nowrap">
30MB
</p>
</div>
<button
type="button"
onClick={handleFileAttach}
disabled={isLoading}
className="h-[32px] w-[62px] px-[4px] py-[3px] rounded-[6px] border border-[#8c95a1] bg-white flex items-center justify-center cursor-pointer hover:bg-gray-50 transition-colors shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap">
{isLoading ? '업로드 중...' : '첨부'}
</span>
</button>
<input
ref={fileInputRef}
type="file"
onChange={handleFileChange}
className="hidden"
accept="*/*"
/>
</div>
<div className="h-16 w-full rounded-[8px] border border-[#dee1e6] bg-gray-50 flex items-center justify-between px-4">
{attachedFile ? (
<>
<p className="text-[14px] font-normal leading-[1.5] text-[#1b2027] flex-1 min-w-0 truncate">
{attachedFile.name}
</p>
<button
type="button"
onClick={handleFileRemove}
className="flex items-center justify-center w-6 h-6 cursor-pointer hover:opacity-70 transition-opacity shrink-0 ml-2"
aria-label="파일 삭제"
>
<CloseXOSvg />
</button>
</>
) : existingAttachment ? (
<>
<p className="text-[14px] font-normal leading-[1.5] text-[#1b2027] flex-1 min-w-0 truncate">
{existingAttachment.name}
</p>
<button
type="button"
onClick={handleFileRemove}
className="flex items-center justify-center w-6 h-6 cursor-pointer hover:opacity-70 transition-opacity shrink-0 ml-2"
aria-label="파일 삭제"
>
<CloseXOSvg />
</button>
</>
) : (
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center w-full">
.
</p>
)}
</div>
</div>
</div>
{/* 액션 버튼 */}
<div className="flex gap-3 items-center justify-end shrink-0 w-full">
<button
type="button"
onClick={handleCancel}
className="h-12 px-8 rounded-[10px] bg-[#f1f3f5] text-[16px] font-semibold leading-[1.5] text-[#4c5561] whitespace-nowrap hover:bg-[#e5e8eb] transition-colors cursor-pointer"
>
</button>
<button
type="button"
onClick={handleSave}
disabled={isLoading}
className="h-12 px-4 rounded-[10px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white whitespace-nowrap hover:bg-[#1a2478] transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? '저장 중...' : '저장하기'}
</button>
</div>
</div>
</div>
</main>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,390 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Link from 'next/link';
import AdminSidebar from '@/app/components/AdminSidebar';
import BackCircleSvg from '@/app/svgs/backcirclesvg';
import DownloadIcon from '@/app/svgs/downloadicon';
import PaperClipSvg from '@/app/svgs/paperclipsvg';
import apiService from '@/app/lib/apiService';
import type { Resource } from '@/app/admin/resources/mockData';
import NoticeDeleteModal from '@/app/admin/notices/NoticeDeleteModal';
type Attachment = {
name: string;
size: string;
url?: string;
fileKey?: string;
};
export default function AdminResourceDetailPage() {
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);
const [isDeleting, setIsDeleting] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [showToast, setShowToast] = useState(false);
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]);
// 토스트 자동 닫기
useEffect(() => {
if (showToast) {
const timer = setTimeout(() => {
setShowToast(false);
}, 3000);
return () => clearTimeout(timer);
}
}, [showToast]);
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="min-h-screen flex flex-col bg-white">
<div className="flex flex-1 min-h-0 justify-center">
<div className="w-[1440px] flex min-h-0">
<div className="flex">
<AdminSidebar />
</div>
<main className="w-[1120px] bg-white">
<div className="h-full flex flex-col">
<div className="h-[100px] flex items-center justify-center px-[32px]">
<p className="text-[16px] font-medium text-[#333c47]"> ...</p>
</div>
</div>
</main>
</div>
</div>
</div>
);
}
if (error || !resource || !resource.content || resource.content.length === 0) {
return (
<div className="min-h-screen flex flex-col bg-white">
<div className="flex flex-1 min-h-0 justify-center">
<div className="w-[1440px] flex min-h-0">
<div className="flex">
<AdminSidebar />
</div>
<main className="w-[1120px] bg-white">
<div className="h-full flex flex-col">
<div className="h-[100px] flex items-center gap-[10px] px-[32px]">
<Link
href="/admin/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">
<p className="text-[16px] font-medium text-[#333c47]">
{error || '학습 자료를 찾을 수 없습니다.'}
</p>
</div>
</div>
</main>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex flex-col bg-white">
<div className="flex flex-1 min-h-0 justify-center">
<div className="w-[1440px] flex min-h-0">
{/* 사이드바 */}
<div className="flex">
<AdminSidebar />
</div>
{/* 메인 콘텐츠 */}
<main className="w-[1120px] bg-white">
<div className="h-full flex flex-col">
{/* 상단 타이틀 */}
<div className="h-[100px] flex items-center gap-[10px] px-[32px]">
<Link
href="/admin/resources"
aria-label="뒤로 가기"
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline shrink-0"
>
<BackCircleSvg width={32} height={32} />
</Link>
<h1 className="m-0 text-[24px] font-bold leading-[1.5] text-[#1B2027]">
</h1>
</div>
{/* 카드 */}
<section className="flex flex-col gap-[40px] px-[32px] py-[24px]">
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
{/* 헤더 */}
<div className="p-[32px]">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-[8px]">
<h2 className="m-0 text-[20px] font-bold leading-[1.5] text-[#333C47]">
{resource.title}
</h2>
<div className="flex items-center gap-[16px] text-[13px] font-medium leading-[1.4]">
<div className="flex items-center gap-[8px]">
<span className="text-[#8C95A1]"></span>
<span className="text-[#333C47]">{resource.writer}</span>
</div>
<div className="h-[16px] w-0 flex items-center justify-center">
<div className="h-0 w-[16px] border-t border-[#DEE1E6]" />
</div>
<div className="flex items-center gap-[8px]">
<span className="text-[#8C95A1]"></span>
<span className="text-[#333C47]">
{resource.date.includes('T')
? new Date(resource.date).toISOString().split('T')[0]
: resource.date}
</span>
</div>
<div className="h-[16px] w-0 flex items-center justify-center">
<div className="h-0 w-[16px] border-t border-[#DEE1E6]" />
</div>
<div className="flex items-center gap-[8px]">
<span className="text-[#8C95A1]"></span>
<span className="text-[#333C47]">{resource.views.toLocaleString()}</span>
</div>
</div>
</div>
</div>
</div>
{/* 구분선 */}
<div className="h-px bg-[#DEE1E6] w-full" />
{/* 본문 및 첨부파일 */}
<div className="flex flex-col gap-[40px] p-[32px]">
{/* 본문 */}
<div className="text-[15px] font-normal leading-[1.5] text-[#333C47]">
{resource.content.map((p, idx) => (
<p key={idx} className="mb-0 leading-[1.5]">
{p}
</p>
))}
</div>
{/* 첨부파일 섹션 */}
{attachments.length > 0 && (
<div className="flex flex-col gap-[24px] w-full">
<div className="flex flex-col gap-[8px] w-full">
<div className="flex items-center gap-[12px] h-[32px]">
<div className="flex items-center">
<p className="text-[15px] font-semibold leading-[1.5] text-[#6C7682] m-0">
</p>
</div>
</div>
{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"
>
<div className="size-[24px] shrink-0">
<PaperClipSvg width={24} height={24} className="text-[#333C47]" />
</div>
<div className="flex-1 flex items-center gap-[8px] min-w-0">
<p className="text-[15px] font-normal leading-[1.5] text-[#1B2027] truncate m-0">
{attachment.name}
</p>
<p className="text-[13px] font-normal leading-[1.4] text-[#8C95A1] shrink-0 m-0">
{attachment.size}
</p>
</div>
<button
type="button"
onClick={() => handleDownload(attachment.fileKey, attachment.url, attachment.name)}
className="bg-white border border-[#8C95A1] rounded-[6px] h-[32px] flex items-center justify-center gap-[4px] px-[16px] py-[3px] shrink-0 hover:bg-[#F9FAFB] cursor-pointer"
>
<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>
{/* 버튼 영역 */}
<div className="flex items-center justify-end gap-[12px]">
<button
type="button"
onClick={() => setIsDeleteModalOpen(true)}
disabled={isDeleting}
className="bg-[#FEF2F2] h-[48px] rounded-[10px] px-[8px] shrink-0 min-w-[80px] flex items-center justify-center hover:bg-[#FEE2E2] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="text-[16px] font-semibold leading-[1.5] text-[#F64C4C] text-center">
</span>
</button>
<button
type="button"
onClick={() => router.push(`/admin/resources/${params.id}/edit`)}
className="bg-[#F1F3F5] h-[48px] rounded-[10px] px-[16px] shrink-0 min-w-[90px] flex items-center justify-center hover:bg-[#E9ECEF] transition-colors"
>
<span className="text-[16px] font-semibold leading-[1.5] text-[#4C5561] text-center">
</span>
</button>
</div>
</section>
</div>
</main>
</div>
</div>
{/* 삭제 확인 모달 */}
<NoticeDeleteModal
open={isDeleteModalOpen}
onClose={() => setIsDeleteModalOpen(false)}
onConfirm={async () => {
if (!params?.id) {
alert('학습 자료 ID를 찾을 수 없습니다.');
setIsDeleteModalOpen(false);
return;
}
try {
setIsDeleting(true);
await apiService.deleteLibraryItem(params.id);
setIsDeleteModalOpen(false);
setShowToast(true);
// 토스트 표시 후 목록 페이지로 이동
setTimeout(() => {
router.push('/admin/resources');
}, 1500);
} catch (err) {
console.error('학습 자료 삭제 오류:', err);
const errorMessage = err instanceof Error ? err.message : '학습 자료 삭제에 실패했습니다.';
alert(errorMessage);
setIsDeleteModalOpen(false);
} finally {
setIsDeleting(false);
}
}}
isDeleting={isDeleting}
/>
{/* 삭제 완료 토스트 */}
{showToast && (
<div className="fixed right-[60px] bottom-[60px] z-50">
<div className="bg-white border border-[#dee1e6] rounded-[8px] p-4 min-w-[360px] flex gap-[10px] items-center">
<div className="relative shrink-0 w-[16.667px] h-[16.667px]">
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8.5" cy="8.5" r="8.5" fill="#384FBF"/>
<path d="M5.5 8.5L7.5 10.5L11.5 6.5" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] text-nowrap">
.
</p>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,21 +1,104 @@
'use client';
import { useState, useMemo, useRef, ChangeEvent } from "react";
import { useState, useMemo, useRef, ChangeEvent, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import AdminSidebar from "@/app/components/AdminSidebar";
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
import BackArrowSvg from "@/app/svgs/backarrow";
import { MOCK_RESOURCES, type Resource } from "@/app/admin/resources/mockData";
import { type Resource } from "@/app/admin/resources/mockData";
import apiService from "@/app/lib/apiService";
export default function AdminResourcesPage() {
const [resources, setResources] = useState<Resource[]>(MOCK_RESOURCES);
const router = useRouter();
const searchParams = useSearchParams();
const [resources, setResources] = useState<Resource[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [isWritingMode, setIsWritingMode] = useState(false);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [attachedFile, setAttachedFile] = useState<File | null>(null);
const [fileKey, setFileKey] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [showToast, setShowToast] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(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;
}
};
// 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();
}, []);
// 수정 완료 쿼리 파라미터 확인 및 토스트 표시
useEffect(() => {
if (searchParams.get('updated') === 'true') {
setShowToast(true);
// URL에서 쿼리 파라미터 제거
router.replace('/admin/resources');
// 토스트 자동 닫기
const timer = setTimeout(() => {
setShowToast(false);
}, 3000);
return () => clearTimeout(timer);
}
}, [searchParams, router]);
const totalCount = useMemo(() => resources.length, [resources]);
const characterCount = useMemo(() => content.length, [content]);
@@ -25,6 +108,11 @@ export default function AdminResourcesPage() {
setTitle('');
setContent('');
setAttachedFile(null);
setFileKey(null);
// 파일 입력 초기화
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleFileAttach = () => {
@@ -39,43 +127,116 @@ export default function AdminResourcesPage() {
return;
}
try {
setIsLoading(true);
// 단일 파일 업로드
await apiService.uploadFile(file);
setAttachedFile(file);
const uploadResponse = await apiService.uploadFile(file);
// 응답에서 fileKey 추출
let extractedFileKey: string | null = null;
if (uploadResponse.data?.fileKey) {
extractedFileKey = uploadResponse.data.fileKey;
} else if (uploadResponse.data?.key) {
extractedFileKey = uploadResponse.data.key;
} else if (uploadResponse.data?.id) {
extractedFileKey = uploadResponse.data.id;
} else if (uploadResponse.data?.imageKey) {
extractedFileKey = uploadResponse.data.imageKey;
} else if (uploadResponse.data?.fileId) {
extractedFileKey = uploadResponse.data.fileId;
} else if (uploadResponse.data?.data && (uploadResponse.data.data.key || uploadResponse.data.data.fileKey)) {
extractedFileKey = uploadResponse.data.data.key || uploadResponse.data.data.fileKey;
} else if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results) && uploadResponse.data.results.length > 0) {
const result = uploadResponse.data.results[0];
if (result.ok && result.fileKey) {
extractedFileKey = result.fileKey;
}
}
if (extractedFileKey) {
setFileKey(extractedFileKey);
setAttachedFile(file);
} else {
throw new Error('파일 키를 받아오지 못했습니다.');
}
} catch (error) {
console.error('파일 업로드 실패:', error);
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
setAttachedFile(null);
setFileKey(null);
} finally {
setIsLoading(false);
// 파일 입력 초기화
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
}
};
const handleSave = () => {
if (!title.trim()) {
alert('제목을 입력해주세요.');
return;
}
if (!content.trim()) {
alert('내용을 입력해주세요.');
const handleSave = async () => {
if (!title.trim() || !content.trim()) {
alert('제목과 내용을 입력해주세요.');
return;
}
// 새 학습 자료 추가
const newResource: Resource = {
id: resources.length > 0 ? Math.max(...resources.map(r => r.id)) + 1 : 1,
title: title.trim(),
date: new Date().toISOString().split('T')[0],
views: 0,
writer: '관리자', // TODO: 실제 작성자 정보 사용
content: content.split('\n'),
hasAttachment: attachedFile !== null,
};
setResources([newResource, ...resources]);
handleBack();
try {
setIsLoading(true);
// 학습 자료 생성 API 호출
const resourceData: any = {
title: title.trim(),
content: content.trim(),
};
// fileKey와 파일 정보가 있으면 attachments 배열로 포함
if (fileKey && attachedFile) {
resourceData.attachments = [
{
fileKey: fileKey,
filename: attachedFile.name,
mimeType: attachedFile.type || 'application/octet-stream',
size: attachedFile.size,
},
];
}
const response = await apiService.createLibraryItem(resourceData);
// API 응답 후 목록 새로고침
const fetchResponse = await apiService.getLibrary();
const data = fetchResponse.data;
// API 응답이 배열이 아닌 경우 처리
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 || !!resource.fileKey || false,
}));
setResources(transformedResources);
handleBack();
} catch (error) {
console.error('학습 자료 저장 실패:', error);
alert('학습 자료 저장에 실패했습니다. 다시 시도해주세요.');
} finally {
setIsLoading(false);
}
};
const handleCancel = () => {
if (title.trim() || content.trim() || attachedFile) {
if (title.trim() || content.trim() || attachedFile || fileKey) {
if (confirm('작성 중인 내용이 있습니다. 정말 취소하시겠습니까?')) {
handleBack();
}
@@ -190,10 +351,11 @@ export default function AdminResourcesPage() {
<button
type="button"
onClick={handleFileAttach}
className="h-[32px] w-[62px] px-[4px] py-[3px] rounded-[6px] border border-[#8c95a1] bg-white flex items-center justify-center cursor-pointer hover:bg-gray-50 transition-colors shrink-0"
disabled={isLoading}
className="h-[32px] w-[62px] px-[4px] py-[3px] rounded-[6px] border border-[#8c95a1] bg-white flex items-center justify-center cursor-pointer hover:bg-gray-50 transition-colors shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap">
{isLoading ? '업로드 중...' : '첨부'}
</span>
</button>
<input
@@ -204,13 +366,13 @@ export default function AdminResourcesPage() {
accept="*/*"
/>
</div>
<div className="h-16 w-full rounded-[8px] border border-[#dee1e6] bg-gray-50 flex items-center justify-center">
<div className="h-16 w-full rounded-[8px] border border-[#dee1e6] bg-gray-50 flex items-center px-4">
{attachedFile ? (
<p className="text-[14px] font-normal leading-[1.5] text-[#1b2027]">
{attachedFile.name}
</p>
) : (
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center">
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center w-full">
.
</p>
)}
@@ -230,9 +392,10 @@ export default function AdminResourcesPage() {
<button
type="button"
onClick={handleSave}
className="h-12 px-4 rounded-[10px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white whitespace-nowrap hover:bg-[#1a2478] transition-colors cursor-pointer"
disabled={isLoading}
className="h-12 px-4 rounded-[10px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white whitespace-nowrap hover:bg-[#1a2478] transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? '저장 중...' : '저장하기'}
</button>
</div>
</div>
@@ -264,7 +427,7 @@ export default function AdminResourcesPage() {
<div className="flex-1 pt-2 flex flex-col">
{resources.length === 0 ? (
<div className="rounded-lg 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 className="text-[16px] font-medium leading-[1.5] text-[#333c47] text-center">
.
<br />
.
@@ -298,7 +461,8 @@ export default function AdminResourcesPage() {
return (
<tr
key={resource.id}
className="h-12 hover:bg-[#F5F7FF] transition-colors"
onClick={() => router.push(`/admin/resources/${resource.id}`)}
className="h-12 hover:bg-[#F5F7FF] transition-colors cursor-pointer"
>
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap text-center">
{resourceNumber}
@@ -307,7 +471,7 @@ export default function AdminResourcesPage() {
{resource.title}
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
{resource.date}
{formatDate(resource.date)}
</td>
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
{resource.views.toLocaleString()}
@@ -415,6 +579,23 @@ export default function AdminResourcesPage() {
</main>
</div>
</div>
{/* 수정 완료 토스트 */}
{showToast && (
<div className="fixed right-[60px] bottom-[60px] z-50">
<div className="bg-white border border-[#dee1e6] rounded-[8px] p-4 min-w-[360px] flex gap-[10px] items-center">
<div className="relative shrink-0 w-[16.667px] h-[16.667px]">
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8.5" cy="8.5" r="8.5" fill="#384FBF"/>
<path d="M5.5 8.5L7.5 10.5L11.5 6.5" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<p className="text-[15px] font-medium leading-[1.5] text-[#1b2027] text-nowrap">
.
</p>
</div>
</div>
)}
</div>
);
}

View File

@@ -17,6 +17,7 @@ const ADMIN_SIDEBAR_ITEMS: NavItem[] = [
{ label: "공지사항", href: "/admin/notices" },
{ label: "학습 자료실", href: "/admin/resources" },
{ label: "로그/접속 기록", href: "/admin/logs" },
{ label: "배너 관리", href: "/admin/banner" },
];
export default function AdminSidebar() {

View File

@@ -54,6 +54,29 @@ body {
color: var(--foreground);
}
/* 전역 스크롤바 스타일 - 배경(트랙)만 투명하게, 썸은 보이게 */
html {
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
}
html::-webkit-scrollbar {
width: 8px;
}
html::-webkit-scrollbar-track {
background: transparent;
}
html::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
html::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.3);
}
button {
cursor: pointer;
}