banner page
This commit is contained in:
606
src/app/admin/banner/BannerRegistrationModal.tsx
Normal file
606
src/app/admin/banner/BannerRegistrationModal.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
180
src/app/admin/banner/page.tsx
Normal file
180
src/app/admin/banner/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
483
src/app/admin/resources/[id]/edit/page.tsx
Normal file
483
src/app/admin/resources/[id]/edit/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
390
src/app/admin/resources/[id]/page.tsx
Normal file
390
src/app/admin/resources/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user