CHORE(merge): merge from develop
Some checks failed
Build Docker Image / build-and-push (push) Has been cancelled
CI / lint-and-build (push) Has been cancelled

- Initial setup and all features from develop branch
- Includes: auth, deploy, docker, style fixes
- K3S deployment configuration
This commit is contained in:
2026-01-06 17:29:16 +09:00
parent b4ce36ba3b
commit f78454c2a1
159 changed files with 18365 additions and 774 deletions

View 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();
}}
>
&lsaquo;
</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();
}}
>
&rsaquo;
</button>
<button
className="absolute top-4 right-4 text-white text-3xl hover:text-gray-300"
onClick={close}
>
&times;
</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>
);
}

View 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>
);
}

View 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}</>;
}

View 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>
);
}

View 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();
}}
>
&lsaquo;
</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();
}}
>
&rsaquo;
</button>
<button
className="absolute top-4 right-4 text-white text-3xl hover:text-gray-300"
onClick={close}
>
&times;
</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>
);
}

View 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}</>;
}

View 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>
);
}

View 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>
);
}