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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user