CHORE(merge): merge from develop
- Initial setup and all features from develop branch - Includes: auth, deploy, docker, style fixes - K3S deployment configuration
This commit is contained in:
239
nextjs/app/(subpages)/(news)/announcements/[id]/page.tsx
Normal file
239
nextjs/app/(subpages)/(news)/announcements/[id]/page.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
getAnnouncementById,
|
||||
deleteAnnouncement,
|
||||
getDownloadUrl,
|
||||
type Announcement,
|
||||
type AnnouncementFile,
|
||||
} from "@/lib/services";
|
||||
import { useAuth, useImageModal } from "@/hooks";
|
||||
import { Download } from "lucide-react";
|
||||
|
||||
const IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "webp"];
|
||||
|
||||
export default function AnnouncementDetailPage() {
|
||||
const [announcement, setAnnouncement] = useState<Announcement | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
|
||||
const { user } = useAuth();
|
||||
|
||||
// 이미지 파일만 필터링 (API에서 signedUrl 포함)
|
||||
const imageFiles = useMemo(() => {
|
||||
if (!announcement?.files) return [];
|
||||
return announcement.files.filter((file) => {
|
||||
const ext = file.fileName.split(".").pop()?.toLowerCase();
|
||||
return IMAGE_EXTENSIONS.includes(ext || "") && file.signedUrl;
|
||||
}) as (AnnouncementFile & { signedUrl: string })[];
|
||||
}, [announcement?.files]);
|
||||
|
||||
const { selectedIndex, isOpen, open, close, next, prev } = useImageModal(imageFiles.length);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// 공지사항 상세 불러오기 (signedUrl 포함)
|
||||
const announcementData = await getAnnouncementById(parseInt(id));
|
||||
setAnnouncement(announcementData);
|
||||
} catch {
|
||||
alert("공지사항을 불러올 수 없습니다.");
|
||||
router.push("/announcements");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!announcement || !user) return;
|
||||
if (announcement.authorId !== user.id) {
|
||||
alert("삭제 권한이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm("정말 삭제하시겠습니까?")) return;
|
||||
|
||||
try {
|
||||
await deleteAnnouncement(announcement.id);
|
||||
alert("삭제되었습니다.");
|
||||
router.push("/announcements");
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "삭제에 실패했습니다.";
|
||||
alert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadAll = async () => {
|
||||
for (const img of imageFiles) {
|
||||
try {
|
||||
const downloadUrl = await getDownloadUrl(img.fileKey, img.fileName);
|
||||
window.open(downloadUrl, '_blank');
|
||||
} catch (error) {
|
||||
console.error("Download failed:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white w-full flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!announcement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isAuthor = user && announcement.authorId === user.id;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="py-6 smalltablet:py-12 px-4">
|
||||
<div className="max-w-7xl p-4 smalltablet:p-6 pc:p-8 rounded-xl bg-gray-100 mx-auto">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 smalltablet:mb-8">
|
||||
<div className="flex items-center gap-2 smalltablet:gap-3 mb-3 smalltablet:mb-4">
|
||||
{announcement.isImportant && (
|
||||
<span className="px-2 smalltablet:px-3 py-0.5 smalltablet:py-1 rounded-full text-xs smalltablet:text-sm font-semibold bg-red-100 text-red-700">
|
||||
중요
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-xl smalltablet:text-2xl pc:text-3xl font-bold text-gray-800 mb-3 smalltablet:mb-4 wrap-break-word">
|
||||
{announcement.title}
|
||||
</h1>
|
||||
<div className="flex flex-col smalltablet:flex-row smalltablet:items-center smalltablet:justify-between text-xs smalltablet:text-sm text-gray-600 pb-3 smalltablet:pb-4 border-b gap-3 smalltablet:gap-0">
|
||||
<div className="flex flex-wrap items-center gap-2 smalltablet:gap-4">
|
||||
<span>작성자: {announcement.author.userName}</span>
|
||||
<span className="hidden smalltablet:inline">•</span>
|
||||
<span className="hidden smalltablet:inline text-xs smalltablet:text-sm">{formatDate(announcement.createdAt)}</span>
|
||||
<span className="hidden smalltablet:inline">•</span>
|
||||
<span>조회수: {announcement.viewCount}</span>
|
||||
</div>
|
||||
{isAuthor && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="px-3 smalltablet:px-4 py-1.5 smalltablet:py-2 text-xs smalltablet:text-sm text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 이미지 갤러리 */}
|
||||
{imageFiles.length > 0 && (
|
||||
<div className="space-y-4 mb-6">
|
||||
{imageFiles.map((img, index) => (
|
||||
<div
|
||||
key={img.id}
|
||||
className="relative w-full bg-gray-200 rounded-lg overflow-hidden cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => open(index)}
|
||||
>
|
||||
<Image
|
||||
src={img.signedUrl}
|
||||
alt={`${announcement.title} - ${index + 1}`}
|
||||
width={1200}
|
||||
height={800}
|
||||
className="w-full h-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 전체 다운로드 버튼 */}
|
||||
<button
|
||||
onClick={handleDownloadAll}
|
||||
className="w-full px-4 py-3 bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] hover:from-[#6b95c6] hover:to-[#5b85b6] text-white rounded-lg shadow-md hover:shadow-lg transition-all font-semibold flex items-center justify-center gap-2 text-sm"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>이미지 다운로드 ({imageFiles.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 이미지가 없는 경우 */}
|
||||
{imageFiles.length === 0 && (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
첨부된 이미지가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 이미지 모달 */}
|
||||
{isOpen && selectedIndex !== null && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50"
|
||||
onClick={close}
|
||||
>
|
||||
<button
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-white text-4xl hover:text-gray-300 z-10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
prev();
|
||||
}}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
|
||||
<div className="relative max-w-7xl max-h-[90vh] w-full h-full mx-16">
|
||||
{imageFiles[selectedIndex]?.signedUrl && (
|
||||
<Image
|
||||
src={imageFiles[selectedIndex].signedUrl}
|
||||
alt={`${announcement.title} - ${selectedIndex + 1}`}
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white text-4xl hover:text-gray-300 z-10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
next();
|
||||
}}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="absolute top-4 right-4 text-white text-3xl hover:text-gray-300"
|
||||
onClick={close}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white text-xs smalltablet:text-sm">
|
||||
{selectedIndex + 1} / {imageFiles.length}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
180
nextjs/app/(subpages)/(news)/announcements/create/page.tsx
Normal file
180
nextjs/app/(subpages)/(news)/announcements/create/page.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { createAnnouncement, uploadFile } from "@/lib/services";
|
||||
import { useAuth } from "@/hooks";
|
||||
import ImageUpload, { PendingImage } from "@/components/ImageUpload";
|
||||
|
||||
interface AnnouncementFormData {
|
||||
title: string;
|
||||
isImportant: boolean;
|
||||
}
|
||||
|
||||
export default function CreateAnnouncementPage() {
|
||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||
const router = useRouter();
|
||||
|
||||
const { user, isLoading } = useAuth();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<AnnouncementFormData>({
|
||||
defaultValues: {
|
||||
title: "",
|
||||
isImportant: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 로그인하지 않은 경우 리다이렉트
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) {
|
||||
alert("로그인이 필요합니다.");
|
||||
router.push("/login");
|
||||
}
|
||||
}, [isLoading, user, router]);
|
||||
|
||||
const onSubmit = async (data: AnnouncementFormData) => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
// 이미지 업로드
|
||||
let uploadedFiles: {
|
||||
fileKey: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
}[] = [];
|
||||
|
||||
if (pendingImages.length > 0) {
|
||||
const sortedImages = [...pendingImages].sort((a, b) => a.order - b.order);
|
||||
const uploadPromises = sortedImages.map(async (img) => {
|
||||
const result = await uploadFile(img.file, "/announcement");
|
||||
return {
|
||||
fileKey: result.fileKey,
|
||||
fileName: img.file.name,
|
||||
fileSize: img.file.size,
|
||||
mimeType: img.file.type,
|
||||
};
|
||||
});
|
||||
uploadedFiles = await Promise.all(uploadPromises);
|
||||
}
|
||||
|
||||
await createAnnouncement({
|
||||
...data,
|
||||
content: "", // 내용 필드는 빈 문자열로 전송
|
||||
authorId: user.id,
|
||||
files: uploadedFiles.length > 0 ? uploadedFiles : undefined,
|
||||
});
|
||||
|
||||
// 미리보기 URL 정리
|
||||
pendingImages.forEach((img) => {
|
||||
if (img.preview) URL.revokeObjectURL(img.preview);
|
||||
});
|
||||
|
||||
alert("주보가 등록되었습니다.");
|
||||
router.push("/announcements");
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "주보 등록에 실패했습니다.";
|
||||
alert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white w-full flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white w-full">
|
||||
<div className="py-12 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 w-full">
|
||||
{/* 제목 */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
제목 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
{...register("title", {
|
||||
required: "제목을 입력해주세요",
|
||||
minLength: {
|
||||
value: 2,
|
||||
message: "제목은 2자 이상이어야 합니다",
|
||||
},
|
||||
})}
|
||||
disabled={isSubmitting}
|
||||
placeholder="제목을 입력해주세요"
|
||||
className={`w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:border-transparent disabled:opacity-50 transition-all ${
|
||||
errors.title
|
||||
? "border-red-300 focus:ring-red-400"
|
||||
: "border-gray-300 focus:ring-blue-500"
|
||||
}`}
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{errors.title.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 중요 공지 체크박스 */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isImportant"
|
||||
{...register("isImportant")}
|
||||
disabled={isSubmitting}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
<label
|
||||
htmlFor="isImportant"
|
||||
className="ml-2 text-sm font-medium text-gray-700"
|
||||
>
|
||||
중요 공지로 표시
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 이미지 업로드 */}
|
||||
<ImageUpload
|
||||
images={pendingImages}
|
||||
onImagesChange={setPendingImages}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex gap-4 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 px-6 py-3 bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] hover:from-[#6b95c6] hover:to-[#5b85b6] text-white rounded-lg shadow-md hover:shadow-lg transition-all font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? "등록 중..." : "등록하기"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
disabled={isSubmitting}
|
||||
className="px-6 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors font-semibold disabled:opacity-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
nextjs/app/(subpages)/(news)/announcements/layout.tsx
Normal file
19
nextjs/app/(subpages)/(news)/announcements/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '주보',
|
||||
description: '제자들교회의 주보와 공지사항을 확인하실 수 있습니다. 교회의 소식과 일정을 안내해드립니다.',
|
||||
openGraph: {
|
||||
title: '주보 | 제자들교회',
|
||||
description: '제자들교회의 주보와 공지사항을 확인하실 수 있습니다.',
|
||||
},
|
||||
};
|
||||
|
||||
export default function AnnouncementsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
197
nextjs/app/(subpages)/(news)/announcements/page.tsx
Normal file
197
nextjs/app/(subpages)/(news)/announcements/page.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { getAnnouncements, type Announcement } from "@/lib/services";
|
||||
import { useAuth, usePagination } from "@/hooks";
|
||||
import { FileTextIcon } from "lucide-react";
|
||||
import Pagination from "@/components/Pagination";
|
||||
|
||||
export default function AnnouncementsPage() {
|
||||
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const { user } = useAuth();
|
||||
const { currentPage, totalPages, setCurrentPage, setTotalPages } = usePagination();
|
||||
|
||||
useEffect(() => {
|
||||
loadData(currentPage);
|
||||
}, [currentPage]);
|
||||
|
||||
const loadData = async (page: number) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 공지사항 목록 불러오기
|
||||
const announcementsResponse = await getAnnouncements(page, 10);
|
||||
setAnnouncements(announcementsResponse.data);
|
||||
setTotalPages(announcementsResponse.pagination.totalPages);
|
||||
} catch (error) {
|
||||
console.error("Failed to load announcements:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-8 w-full flex flex-col items-center">
|
||||
<div className="max-w-7xl px-4 m-4 smalltablet:m-8 w-full">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
{/* 테이블 헤더 스켈레톤 - 데스크톱 */}
|
||||
<div className="hidden smalltablet:grid smalltablet:grid-cols-12 bg-gray-50 border-b border-gray-200 text-sm font-medium text-gray-700">
|
||||
<div className="col-span-1 px-6 py-4 text-center">번호</div>
|
||||
<div className="col-span-6 px-6 py-4">제목</div>
|
||||
<div className="col-span-2 px-6 py-4 text-center">작성자</div>
|
||||
<div className="col-span-2 px-6 py-4 text-center">작성일</div>
|
||||
<div className="col-span-1 px-6 py-4 text-center">조회수</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 바디 스켈레톤 */}
|
||||
<div className="divide-y divide-gray-200">
|
||||
{[...Array(5)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="grid grid-cols-1 smalltablet:grid-cols-12 animate-pulse"
|
||||
>
|
||||
{/* 모바일 뷰 스켈레톤 */}
|
||||
<div className="smalltablet:hidden px-6 py-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-3 bg-gray-200 rounded w-20"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-24"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데스크톱 뷰 스켈레톤 */}
|
||||
<div className="hidden smalltablet:block smalltablet:col-span-1 px-6 py-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-8 mx-auto"></div>
|
||||
</div>
|
||||
<div className="hidden smalltablet:block smalltablet:col-span-6 px-6 py-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
</div>
|
||||
<div className="hidden smalltablet:block smalltablet:col-span-2 px-6 py-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-16 mx-auto"></div>
|
||||
</div>
|
||||
<div className="hidden smalltablet:block smalltablet:col-span-2 px-6 py-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-20 mx-auto"></div>
|
||||
</div>
|
||||
<div className="hidden smalltablet:block smalltablet:col-span-1 px-6 py-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-8 mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 w-full flex flex-col items-center">
|
||||
<div className="max-w-7xl px-4 m-4 smalltablet:m-8 w-full">
|
||||
{/* 공지 작성 버튼 */}
|
||||
{user && (
|
||||
<div className="flex justify-end mb-4">
|
||||
<Link
|
||||
href="/announcements/create"
|
||||
className="px-6 py-2.5 bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] hover:from-[#6b95c6] hover:to-[#5b85b6] text-white rounded-lg shadow-md hover:shadow-lg transition-all font-medium text-sm"
|
||||
>
|
||||
공지 작성
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블 */}
|
||||
{announcements.length === 0 ? (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 text-center py-20 flex items-center justify-center flex-col">
|
||||
<FileTextIcon className="mx-auto h-16 w-16 text-gray-300 mb-4" />
|
||||
<p className="text-gray-500 text-lg">
|
||||
등록된 주보가 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
{/* 테이블 헤더 - 데스크톱 */}
|
||||
<div className="hidden smalltablet:grid smalltablet:grid-cols-12 bg-gray-50 border-b border-gray-200 text-sm font-medium text-gray-700">
|
||||
<div className="col-span-1 px-6 py-4 text-center">번호</div>
|
||||
<div className="col-span-6 px-6 py-4">제목</div>
|
||||
<div className="col-span-2 px-6 py-4 text-center">작성자</div>
|
||||
<div className="col-span-2 px-6 py-4 text-center">작성일</div>
|
||||
<div className="col-span-1 px-6 py-4 text-center">조회수</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 바디 */}
|
||||
<div className="divide-y divide-gray-200">
|
||||
{announcements.map((item, index) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={`/announcements/${item.id}`}
|
||||
className="grid grid-cols-1 smalltablet:grid-cols-12 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{/* 모바일 뷰 */}
|
||||
<div className="smalltablet:hidden px-6 py-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{item.isImportant && (
|
||||
<span className="px-2 py-0.5 bg-orange-100 text-orange-600 text-xs font-bold rounded">
|
||||
필독
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-medium text-gray-900 mb-2">{item.title}</h3>
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<span>{item.author.userName}</span>
|
||||
<span>{formatDate(item.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데스크톱 뷰 */}
|
||||
<div className="hidden smalltablet:block smalltablet:col-span-1 px-6 py-4 text-center text-sm text-gray-600">
|
||||
{announcements.length - index}
|
||||
</div>
|
||||
<div className="hidden smalltablet:block smalltablet:col-span-6 px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{item.isImportant && (
|
||||
<span className="px-2 py-0.5 bg-orange-100 text-orange-600 text-xs font-bold rounded">
|
||||
필독
|
||||
</span>
|
||||
)}
|
||||
<span className="text-gray-900 font-medium hover:text-blue-600 transition-colors">
|
||||
{item.title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden smalltablet:block smalltablet:col-span-2 px-6 py-4 text-center text-sm text-gray-600">
|
||||
{item.author.userName}
|
||||
</div>
|
||||
<div className="hidden smalltablet:flex smalltablet:col-span-2 px-6 py-4 justify-center text-center text-sm text-gray-600">
|
||||
{formatDate(item.createdAt)}
|
||||
</div>
|
||||
<div className="hidden smalltablet:block smalltablet:col-span-1 px-6 py-4 text-center text-sm text-gray-600">
|
||||
{item.viewCount}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
204
nextjs/app/(subpages)/(news)/gallery/[id]/page.tsx
Normal file
204
nextjs/app/(subpages)/(news)/gallery/[id]/page.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, use } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ClipLoader } from 'react-spinners';
|
||||
import { GalleryPost, getGalleryPost, deleteGalleryPost, getSortedGalleryContent, type GalleryContentItem } from '@/lib/services';
|
||||
import { useAuth, useImageModal } from '@/hooks';
|
||||
|
||||
export default function GalleryDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [post, setPost] = useState<GalleryPost | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const { user } = useAuth();
|
||||
const sortedImages = post?.images.sort((a, b) => a.order - b.order) || [];
|
||||
const { selectedIndex, isOpen, open, close, next, prev } = useImageModal(sortedImages.length);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getGalleryPost(parseInt(id, 10));
|
||||
setPost(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch post:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteGalleryPost(parseInt(id, 10));
|
||||
router.push('/gallery');
|
||||
} catch (error) {
|
||||
console.error('Failed to delete post:', error);
|
||||
alert('삭제에 실패했습니다.');
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white w-full flex items-center justify-center">
|
||||
<ClipLoader color="#7ba5d6" size={50} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!post) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white w-full">
|
||||
<div className="py-12 px-4">
|
||||
<div className="max-w-7xl mx-auto text-center">
|
||||
<p className="text-gray-500">갤러리를 찾을 수 없습니다.</p>
|
||||
<Link href="/gallery" className="text-blue-500 mt-4 inline-block">
|
||||
목록으로 돌아가기
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="py-12 px-4">
|
||||
<div className="max-w-7xl bg-gray-100 rounded-xl p-8 mx-auto">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-8 pb-6 border-b border-gray-200">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-xl smalltablet:text-2xl pc:text-3xl font-bold text-gray-800">{post.title}</h1>
|
||||
<p className="text-xs smalltablet:text-sm text-gray-500 mt-1">
|
||||
{new Date(post.createdAt).toLocaleDateString('ko-KR')}
|
||||
</p>
|
||||
</div>
|
||||
{user && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{deleting ? '삭제 중...' : '삭제'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 콘텐츠 (이미지 + 텍스트 블록) */}
|
||||
<div className="space-y-4">
|
||||
{(() => {
|
||||
const sortedContent = getSortedGalleryContent(post);
|
||||
|
||||
return sortedContent.map((item: GalleryContentItem, idx: number) => {
|
||||
if (item.type === 'image') {
|
||||
const imageId = item.data.id;
|
||||
const aspectRatio = item.data.aspectRatio;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`image-${imageId}`}
|
||||
className="relative w-full bg-gray-200 rounded-lg overflow-hidden cursor-pointer hover:opacity-90 transition-opacity"
|
||||
style={{
|
||||
aspectRatio: aspectRatio ? `${aspectRatio}` : 'auto',
|
||||
minHeight: aspectRatio ? 'auto' : '200px',
|
||||
}}
|
||||
onClick={() => {
|
||||
// 전체 이미지 배열에서의 인덱스 찾기
|
||||
const actualIndex = sortedImages.findIndex(img => img.id === item.data.id);
|
||||
open(actualIndex);
|
||||
}}
|
||||
>
|
||||
{item.data.displayUrl && (
|
||||
<Image
|
||||
src={item.data.displayUrl}
|
||||
alt={`${post.title} - ${idx + 1}`}
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
key={`text-${item.data.id}`}
|
||||
className="p-4 rounded-lg text-sm smalltablet:text-base pc:text-xl text-gray-700 whitespace-pre-wrap"
|
||||
>
|
||||
{item.data.content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 이미지 모달 */}
|
||||
{isOpen && selectedIndex !== null && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50"
|
||||
onClick={close}
|
||||
>
|
||||
<button
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-white text-4xl hover:text-gray-300 z-10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
prev();
|
||||
}}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
|
||||
<div className="relative max-w-4xl max-h-[90vh] w-full h-full mx-16">
|
||||
{sortedImages[selectedIndex]?.displayUrl && (
|
||||
<Image
|
||||
src={sortedImages[selectedIndex].displayUrl}
|
||||
alt={`${post.title} - ${selectedIndex + 1}`}
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white text-4xl hover:text-gray-300 z-10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
next();
|
||||
}}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="absolute top-4 right-4 text-white text-3xl hover:text-gray-300"
|
||||
onClick={close}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white text-xs smalltablet:text-sm">
|
||||
{selectedIndex + 1} / {sortedImages.length}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
nextjs/app/(subpages)/(news)/gallery/layout.tsx
Normal file
19
nextjs/app/(subpages)/(news)/gallery/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '갤러리',
|
||||
description: '제자들교회의 다양한 활동과 행사 사진을 보실 수 있습니다. 교회 공동체의 아름다운 순간들을 함께 나눕니다.',
|
||||
openGraph: {
|
||||
title: '갤러리 | 제자들교회',
|
||||
description: '제자들교회 갤러리 - 교회 활동 및 행사 사진',
|
||||
},
|
||||
};
|
||||
|
||||
export default function GalleryLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
114
nextjs/app/(subpages)/(news)/gallery/page.tsx
Normal file
114
nextjs/app/(subpages)/(news)/gallery/page.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { GalleryPost, getGalleryPosts } from '@/lib/services';
|
||||
import { useAuth, usePagination } from '@/hooks';
|
||||
import Pagination from '@/components/Pagination';
|
||||
import { FileTextIcon } from 'lucide-react';
|
||||
|
||||
export default function GalleryPage() {
|
||||
const [posts, setPosts] = useState<GalleryPost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { user } = useAuth();
|
||||
const { currentPage, totalPages, setCurrentPage, setTotalPages } = usePagination();
|
||||
|
||||
useEffect(() => {
|
||||
fetchPosts(currentPage);
|
||||
}, [currentPage]);
|
||||
|
||||
const fetchPosts = async (page: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await getGalleryPosts(page, 9);
|
||||
setPosts(result.data);
|
||||
setTotalPages(result.pagination.totalPages);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch posts:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 w-full">
|
||||
<div className="py-12 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* 글쓰기 버튼 */}
|
||||
{user && (
|
||||
<div className="flex justify-end mb-4">
|
||||
<Link
|
||||
href="/gallery/write"
|
||||
className="px-4 smalltablet:px-6 py-2 smalltablet:py-2.5 bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] hover:from-[#6b95c6] hover:to-[#5b85b6] text-white rounded-lg shadow-md hover:shadow-lg transition-all font-medium text-xs smalltablet:text-sm"
|
||||
>
|
||||
갤러리 작성
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 갤러리 그리드 */}
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 smalltablet:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 9 }).map((_, idx) => (
|
||||
<div key={idx} className="bg-gray-200 rounded-lg overflow-hidden animate-pulse">
|
||||
<div className="aspect-4/3" />
|
||||
<div className="p-4">
|
||||
<div className="h-5 bg-gray-300 rounded w-3/4 mb-2" />
|
||||
<div className="h-4 bg-gray-300 rounded w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : posts.length === 0 ? (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 text-center py-20 flex items-center justify-center flex-col">
|
||||
<FileTextIcon className="mx-auto h-12 w-12 smalltablet:h-16 smalltablet:w-16 text-gray-300 mb-4" />
|
||||
<p className="text-gray-500 text-base smalltablet:text-lg">
|
||||
등록된 갤러리가 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 smalltablet:grid-cols-3 gap-4">
|
||||
{posts.map((post) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
href={`/gallery/${post.id}`}
|
||||
className="bg-white rounded-lg overflow-hidden shadow-md hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="relative aspect-4/3 bg-gray-100">
|
||||
{post.thumbnailUrl && (
|
||||
<Image
|
||||
src={post.thumbnailUrl}
|
||||
alt={post.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
)}
|
||||
{post.images.length > 1 && (
|
||||
<div className="absolute top-2 right-2 bg-black bg-opacity-60 text-white text-[10px] smalltablet:text-xs px-2 py-1 rounded">
|
||||
+{post.images.length - 1}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="text-sm smalltablet:text-base font-semibold text-gray-800 truncate">{post.title}</h3>
|
||||
<p className="text-xs smalltablet:text-sm text-gray-500 mt-1">
|
||||
{new Date(post.createdAt).toLocaleDateString('ko-KR')}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
327
nextjs/app/(subpages)/(news)/gallery/write/page.tsx
Normal file
327
nextjs/app/(subpages)/(news)/gallery/write/page.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { uploadGalleryFiles, createGalleryPost, calculateImageAspectRatio } from "@/lib/services";
|
||||
import { X, ArrowUp, ArrowDown, Plus } from "lucide-react";
|
||||
import { extractImagesFromClipboard } from "@/components/ImageUpload";
|
||||
|
||||
type ContentItem =
|
||||
| { type: "image"; id: string; file: File; preview: string; order: number }
|
||||
| { type: "text"; id: string; content: string; order: number };
|
||||
|
||||
export default function GalleryWritePage() {
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState("");
|
||||
const [items, setItems] = useState<ContentItem[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const addImages = useCallback((files: File[]) => {
|
||||
setItems((prevItems) => {
|
||||
const newItems: ContentItem[] = files.map((file, index) => ({
|
||||
type: "image",
|
||||
id: `img-${Date.now()}-${Math.random()}`,
|
||||
file,
|
||||
preview: URL.createObjectURL(file),
|
||||
order: prevItems.length + index,
|
||||
}));
|
||||
return [...prevItems, ...newItems];
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 클립보드 붙여넣기 핸들러
|
||||
useEffect(() => {
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
if (submitting) return;
|
||||
|
||||
const imageFiles = extractImagesFromClipboard(e);
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
addImages(imageFiles);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("paste", handlePaste);
|
||||
return () => document.removeEventListener("paste", handlePaste);
|
||||
}, [submitting, addImages]);
|
||||
|
||||
const addTextBlock = () => {
|
||||
const newItem: ContentItem = {
|
||||
type: "text",
|
||||
id: `text-${Date.now()}-${Math.random()}`,
|
||||
content: "",
|
||||
order: items.length,
|
||||
};
|
||||
setItems([...items, newItem]);
|
||||
};
|
||||
|
||||
const removeItem = (id: string) => {
|
||||
const newItems = items.filter((item) => item.id !== id);
|
||||
// order 재정렬
|
||||
const reorderedItems = newItems.map((item, index) => ({
|
||||
...item,
|
||||
order: index,
|
||||
}));
|
||||
setItems(reorderedItems);
|
||||
};
|
||||
|
||||
const moveItem = (id: string, direction: "up" | "down") => {
|
||||
const index = items.findIndex((item) => item.id === id);
|
||||
if (index === -1) return;
|
||||
|
||||
const newIndex = direction === "up" ? index - 1 : index + 1;
|
||||
if (newIndex < 0 || newIndex >= items.length) return;
|
||||
|
||||
const newItems = [...items];
|
||||
[newItems[index], newItems[newIndex]] = [newItems[newIndex], newItems[index]];
|
||||
|
||||
// order 재정렬
|
||||
const reorderedItems = newItems.map((item, idx) => ({
|
||||
...item,
|
||||
order: idx,
|
||||
}));
|
||||
setItems(reorderedItems);
|
||||
};
|
||||
|
||||
const updateTextContent = (id: string, content: string) => {
|
||||
setItems(
|
||||
items.map((item) =>
|
||||
item.id === id && item.type === "text" ? { ...item, content } : item
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!title.trim()) {
|
||||
alert("제목을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const imageItems = items.filter((item) => item.type === "image");
|
||||
if (imageItems.length === 0) {
|
||||
alert("최소 1개 이상의 이미지를 업로드해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// 이미지 파일 업로드
|
||||
const imageFiles = imageItems.map((item) =>
|
||||
item.type === "image" ? item.file : null
|
||||
).filter((f): f is File => f !== null);
|
||||
|
||||
const fileKeys = await uploadGalleryFiles(imageFiles);
|
||||
|
||||
// 이미지 비율 계산
|
||||
const imageAspectRatios = await Promise.all(
|
||||
imageFiles.map((file) => calculateImageAspectRatio(file))
|
||||
);
|
||||
|
||||
// fileKeys와 비율을 이미지 아이템에 매핑
|
||||
let fileKeyIndex = 0;
|
||||
const itemsWithFileKeys = items.map((item) => {
|
||||
if (item.type === "image") {
|
||||
return {
|
||||
type: "image" as const,
|
||||
fileKey: fileKeys[fileKeyIndex],
|
||||
order: item.order,
|
||||
aspectRatio: imageAspectRatios[fileKeyIndex++],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: "text" as const,
|
||||
content: item.content,
|
||||
order: item.order,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 갤러리 포스트 생성
|
||||
await createGalleryPost({
|
||||
title: title.trim(),
|
||||
content: "",
|
||||
items: itemsWithFileKeys,
|
||||
});
|
||||
|
||||
// 미리보기 URL 정리
|
||||
items.forEach((item) => {
|
||||
if (item.type === "image" && item.preview) {
|
||||
URL.revokeObjectURL(item.preview);
|
||||
}
|
||||
});
|
||||
|
||||
router.push("/gallery");
|
||||
} catch (error) {
|
||||
console.error("Submit failed:", error);
|
||||
alert("등록에 실패했습니다.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="py-12 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 제목 */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
제목 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
disabled={submitting}
|
||||
placeholder="제목을 입력해주세요"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 콘텐츠 목록 */}
|
||||
<div className="space-y-4">
|
||||
{items.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500 border-2 border-dashed border-gray-300 rounded-lg">
|
||||
<p className="mb-2">이미지나 텍스트를 추가해주세요</p>
|
||||
<p className="text-sm text-blue-600 font-medium">
|
||||
Ctrl+V로 이미지 붙여넣기 가능
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
items
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="border border-gray-300 rounded-lg p-4 bg-white"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="text-sm text-gray-500 font-medium pt-2 min-w-[24px]">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
{item.type === "image" ? (
|
||||
<div className="relative w-full bg-gray-100 rounded-lg overflow-hidden">
|
||||
<Image
|
||||
src={item.preview}
|
||||
alt="미리보기"
|
||||
width={1200}
|
||||
height={800}
|
||||
className="w-full h-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
value={item.content}
|
||||
onChange={(e) =>
|
||||
updateTextContent(item.id, e.target.value)
|
||||
}
|
||||
disabled={submitting}
|
||||
placeholder="텍스트를 입력하세요"
|
||||
rows={4}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 transition-all resize-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveItem(item.id, "up")}
|
||||
disabled={index === 0 || submitting}
|
||||
className="p-1 text-gray-600 hover:text-gray-900 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="위로 이동"
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveItem(item.id, "down")}
|
||||
disabled={index === items.length - 1 || submitting}
|
||||
className="p-1 text-gray-600 hover:text-gray-900 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="아래로 이동"
|
||||
>
|
||||
<ArrowDown className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(item.id)}
|
||||
disabled={submitting}
|
||||
className="p-1 text-red-600 hover:text-red-800 disabled:opacity-50"
|
||||
title="삭제"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 콘텐츠 추가 버튼 */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.multiple = true;
|
||||
input.onchange = (e) => {
|
||||
const files = Array.from(
|
||||
(e.target as HTMLInputElement).files || []
|
||||
);
|
||||
if (files.length > 0) {
|
||||
addImages(files);
|
||||
}
|
||||
// 같은 파일을 다시 선택해도 이벤트가 발생하도록 value 초기화
|
||||
input.value = '';
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
이미지 추가
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addTextBlock}
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
텍스트 추가
|
||||
</button>
|
||||
|
||||
</div>
|
||||
{/* 버튼 */}
|
||||
<div className="flex gap-4 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="flex-1 px-6 py-3 bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] hover:from-[#6b95c6] hover:to-[#5b85b6] text-white rounded-lg shadow-md hover:shadow-lg transition-all font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{submitting ? "등록 중..." : "등록하기"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
disabled={submitting}
|
||||
className="px-6 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors font-semibold disabled:opacity-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user