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,186 @@
import React from "react";
import Image from "next/image";
import { X, Upload, Image as ImageIcon, FileText, File as FileIcon } from "lucide-react";
export interface PendingFile {
file: File;
preview?: string;
}
interface FileUploadProps {
files: PendingFile[];
onFilesChange: (files: PendingFile[]) => void;
accept?: string;
multiple?: boolean;
disabled?: boolean;
label?: string;
required?: boolean;
description?: string;
showImagePreview?: boolean;
maxSizeMB?: number;
}
export default function FileUpload({
files,
onFilesChange,
accept,
multiple = true,
disabled = false,
label = "파일 첨부",
required = false,
description = "이미지, PDF, 문서 등 모든 파일 형식 지원",
showImagePreview = false,
maxSizeMB,
}: FileUploadProps) {
const getFileIcon = (fileType: string) => {
if (fileType.startsWith("image/")) {
return ImageIcon;
} else if (fileType.includes("pdf")) {
return FileText;
} else {
return FileIcon;
}
};
const isImage = (fileType: string) => {
return fileType.startsWith("image/");
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = e.target.files;
if (!selectedFiles || selectedFiles.length === 0) return;
let newFiles = Array.from(selectedFiles);
// 파일 크기 검사
if (maxSizeMB) {
const maxSize = maxSizeMB * 1024 * 1024;
const oversizedFiles = newFiles.filter((f) => f.size > maxSize);
if (oversizedFiles.length > 0) {
alert(
`파일 크기는 ${maxSizeMB}MB를 초과할 수 없습니다: ${oversizedFiles.map((f) => f.name).join(", ")}`
);
newFiles = newFiles.filter((f) => f.size <= maxSize);
if (newFiles.length === 0) return;
}
}
const newPendingFiles: PendingFile[] = newFiles.map((file) => ({
file,
preview: file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined,
}));
onFilesChange([...files, ...newPendingFiles]);
// input 초기화
e.target.value = "";
};
const removeFile = (index: number) => {
const file = files[index];
if (file.preview) {
URL.revokeObjectURL(file.preview);
}
onFilesChange(files.filter((_, i) => i !== index));
};
return (
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
{label} {required && <span className="text-red-500">*</span>}
</label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-400 transition-colors">
<input
type="file"
accept={accept}
multiple={multiple}
onChange={handleFileChange}
disabled={disabled}
className="hidden"
id="file-upload"
/>
<label htmlFor="file-upload" className="cursor-pointer flex flex-col items-center">
<Upload className="w-12 h-12 text-gray-400 mb-3" />
<p className="text-gray-600 mb-1"> </p>
<p className="text-sm text-gray-500">{description}</p>
</label>
</div>
{/* 파일 목록 */}
{files.length > 0 && (
<div className="mt-4 space-y-2">
<p className="text-sm font-medium text-gray-700">
({files.length})
</p>
{/* 이미지 미리보기 그리드 */}
{showImagePreview ? (
<div className="grid grid-cols-3 gap-2">
{files.map((pf, index) => (
<div key={index} className="relative aspect-square">
{pf.preview ? (
<Image
src={pf.preview}
alt={`Preview ${index + 1}`}
fill
className="object-cover rounded-lg"
/>
) : (
<div className="w-full h-full bg-gray-100 rounded-lg flex items-center justify-center">
<FileIcon className="w-8 h-8 text-gray-400" />
</div>
)}
<button
type="button"
onClick={() => removeFile(index)}
className="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm hover:bg-red-600"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
) : (
/* 파일 리스트 */
<div className="space-y-2">
{files.map((pf, index) => {
const FileIconComponent = getFileIcon(pf.file.type);
return (
<div
key={index}
className="relative group border rounded-lg p-3 hover:shadow-md transition-shadow flex items-center gap-3"
>
<div className="shrink-0">
<div className="w-12 h-12 bg-gray-100 rounded flex items-center justify-center">
{isImage(pf.file.type) ? (
<ImageIcon className="w-6 h-6 text-blue-500" />
) : (
<FileIconComponent className="w-6 h-6 text-gray-500" />
)}
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-800 truncate">
{pf.file.name}
</p>
<p className="text-xs text-gray-500">
{(pf.file.size / 1024).toFixed(1)} KB
</p>
</div>
<button
type="button"
onClick={() => removeFile(index)}
className="shrink-0 bg-red-500 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-600"
>
<X className="w-4 h-4" />
</button>
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,286 @@
"use client";
import React, { useRef, useCallback, useEffect } from "react";
import Image from "next/image";
import { X, Upload, ArrowUp, ArrowDown } from "lucide-react";
export interface PendingImage {
id: string;
file: File;
preview: string;
order: number;
}
interface ImageUploadProps {
images: PendingImage[];
onImagesChange: (images: PendingImage[]) => void;
disabled?: boolean;
maxSizeMB?: number;
accept?: string;
showOrder?: boolean;
}
const DEFAULT_ACCEPTED_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp"];
const DEFAULT_ACCEPTED_EXTENSIONS = ".jpg,.jpeg,.png,.webp";
export default function ImageUpload({
images,
onImagesChange,
disabled = false,
maxSizeMB = 10,
accept = DEFAULT_ACCEPTED_EXTENSIONS,
showOrder = true,
}: ImageUploadProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const validateAndAddFiles = useCallback(
(files: File[]) => {
const maxSize = maxSizeMB * 1024 * 1024;
// 이미지 파일만 필터링
const validFiles = files.filter((file) => {
if (!DEFAULT_ACCEPTED_TYPES.includes(file.type)) {
return false;
}
if (file.size > maxSize) {
alert(`파일 크기는 ${maxSizeMB}MB를 초과할 수 없습니다: ${file.name}`);
return false;
}
return true;
});
if (validFiles.length === 0) return;
const newImages: PendingImage[] = validFiles.map((file, index) => ({
id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
file,
preview: URL.createObjectURL(file),
order: images.length + index,
}));
onImagesChange([...images, ...newImages]);
},
[images, onImagesChange, maxSizeMB]
);
// 클립보드 붙여넣기 핸들러
useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => {
if (disabled) return;
const clipboardItems = e.clipboardData?.items;
if (!clipboardItems) return;
const imageFiles: File[] = [];
for (const item of Array.from(clipboardItems)) {
// 직접 이미지 파일인 경우
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
if (file) {
imageFiles.push(file);
}
}
}
if (imageFiles.length > 0) {
e.preventDefault();
validateAndAddFiles(imageFiles);
}
};
document.addEventListener("paste", handlePaste);
return () => document.removeEventListener("paste", handlePaste);
}, [disabled, validateAndAddFiles]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = e.target.files;
if (!selectedFiles || selectedFiles.length === 0) return;
validateAndAddFiles(Array.from(selectedFiles));
e.target.value = "";
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
if (disabled) return;
const droppedFiles = Array.from(e.dataTransfer.files);
validateAndAddFiles(droppedFiles);
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
};
const removeImage = (id: string) => {
const image = images.find((img) => img.id === id);
if (image?.preview) {
URL.revokeObjectURL(image.preview);
}
const newImages = images
.filter((img) => img.id !== id)
.map((img, index) => ({ ...img, order: index }));
onImagesChange(newImages);
};
const moveImage = (id: string, direction: "up" | "down") => {
const index = images.findIndex((img) => img.id === id);
if (index === -1) return;
const newIndex = direction === "up" ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= images.length) return;
const newImages = [...images];
[newImages[index], newImages[newIndex]] = [newImages[newIndex], newImages[index]];
// order 재정렬
const reorderedImages = newImages.map((img, idx) => ({
...img,
order: idx,
}));
onImagesChange(reorderedImages);
};
const sortedImages = [...images].sort((a, b) => a.order - b.order);
return (
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
</label>
{/* 업로드 영역 */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
disabled
? "border-gray-200 bg-gray-50 cursor-not-allowed"
: "border-gray-300 hover:border-blue-400 cursor-pointer"
}`}
>
<input
ref={fileInputRef}
type="file"
accept={accept}
multiple
onChange={handleFileChange}
disabled={disabled}
className="hidden"
id="image-upload"
/>
<label
htmlFor="image-upload"
className={`flex flex-col items-center ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
>
<Upload className="w-12 h-12 text-gray-400 mb-3" />
<p className="text-gray-600 mb-1">
</p>
<p className="text-sm text-blue-600 font-medium mb-1">
Ctrl+V로
</p>
<p className="text-xs text-gray-500">
JPG, PNG, WebP ( {maxSizeMB}MB)
</p>
</label>
</div>
{/* 이미지 목록 */}
{sortedImages.length > 0 && (
<div className="mt-4 space-y-3">
<p className="text-sm font-medium text-gray-700">
({sortedImages.length})
</p>
<div className="space-y-3">
{sortedImages.map((img, index) => (
<div
key={img.id}
className="border border-gray-300 rounded-lg p-3 bg-white"
>
<div className="flex items-start gap-3">
{showOrder && (
<span className="text-sm text-gray-500 font-medium pt-2 min-w-[24px]">
{index + 1}
</span>
)}
<div className="flex-1">
<div className="relative w-full bg-gray-100 rounded-lg overflow-hidden">
<Image
src={img.preview}
alt={`미리보기 ${index + 1}`}
width={1200}
height={800}
className="w-full h-auto object-contain max-h-[300px]"
/>
</div>
<p className="text-xs text-gray-500 mt-1 truncate">
{img.file.name} ({(img.file.size / 1024).toFixed(1)} KB)
</p>
</div>
<div className="flex flex-col gap-1 pt-2">
<button
type="button"
onClick={() => moveImage(img.id, "up")}
disabled={index === 0 || disabled}
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={() => moveImage(img.id, "down")}
disabled={index === sortedImages.length - 1 || disabled}
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={() => removeImage(img.id)}
disabled={disabled}
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>
)}
</div>
);
}
// 이미지 추가 함수 (외부에서 사용 가능) - Gallery에서 붙여넣기 연동용
export function createPendingImage(file: File, currentLength: number): PendingImage {
return {
id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
file,
preview: URL.createObjectURL(file),
order: currentLength,
};
}
// 붙여넣기 이벤트에서 이미지 파일 추출
export function extractImagesFromClipboard(e: ClipboardEvent): File[] {
const clipboardItems = e.clipboardData?.items;
if (!clipboardItems) return [];
const imageFiles: File[] = [];
for (const item of Array.from(clipboardItems)) {
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
if (file) {
imageFiles.push(file);
}
}
}
return imageFiles;
}

View File

@@ -0,0 +1,87 @@
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export default function Pagination({
currentPage,
totalPages,
onPageChange,
}: PaginationProps) {
if (totalPages <= 1) return null;
const getPageNumbers = () => {
const pages: (number | string)[] = [];
const maxVisible = 5;
if (totalPages <= maxVisible) {
for (let i = 1; i <= totalPages; i++) pages.push(i);
} else {
if (currentPage <= 3) {
for (let i = 1; i <= 4; i++) pages.push(i);
pages.push('...');
pages.push(totalPages);
} else if (currentPage >= totalPages - 2) {
pages.push(1);
pages.push('...');
for (let i = totalPages - 3; i <= totalPages; i++) pages.push(i);
} else {
pages.push(1);
pages.push('...');
for (let i = currentPage - 1; i <= currentPage + 1; i++) pages.push(i);
pages.push('...');
pages.push(totalPages);
}
}
return pages;
};
return (
<div className="flex justify-center items-center gap-2 mt-8">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className={`px-3 py-2 rounded-lg border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
currentPage === 1
? 'border-gray-300 text-gray-400'
: 'border-[#94b7d6] text-[#6d96c5] hover:bg-[#a9c6e1]'
}`}
>
</button>
{getPageNumbers().map((page, idx) =>
typeof page === 'number' ? (
<button
key={idx}
onClick={() => onPageChange(page)}
className={`w-10 h-10 flex items-center justify-center rounded-lg transition-colors ${
currentPage === page
? 'bg-[#6d96c5] text-white'
: 'border border-[#94b7d6] text-[#6d96c5] hover:bg-[#a9c6e1]'
}`}
>
{page}
</button>
) : (
<span key={idx} className="px-2">
{page}
</span>
)
)}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className={`px-3 py-2 rounded-lg border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
currentPage === totalPages
? 'border-gray-300 text-gray-400'
: 'border-[#94b7d6] text-[#6d96c5] hover:bg-[#a9c6e1]'
}`}
>
</button>
</div>
);
}

View File

@@ -0,0 +1,70 @@
"use client";
import React, { useState, useEffect } from 'react';
import Image from 'next/image';
import { getDownloadUrl } from '@/lib/services';
interface SignedImageProps {
fileKey: string;
alt: string;
className?: string;
fill?: boolean;
sizes?: string;
}
const SignedImage: React.FC<SignedImageProps> = ({
fileKey,
alt,
className = "",
fill = false,
sizes
}) => {
const [signedUrl, setSignedUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const getSignedUrl = async () => {
try {
const downloadUrl = await getDownloadUrl(fileKey);
setSignedUrl(downloadUrl);
} catch (error) {
console.error('서명된 URL 생성 실패:', error);
setError('이미지를 불러올 수 없습니다.');
} finally {
setIsLoading(false);
}
};
getSignedUrl();
}, [fileKey]);
if (isLoading) {
return (
<div className={`flex items-center justify-center bg-gray-200 ${className}`}>
<div className="w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
if (error || !signedUrl) {
return (
<div className={`flex items-center justify-center bg-gray-200 ${className}`}>
<span className="text-gray-500 text-sm">{error || '이미지 없음'}</span>
</div>
);
}
return (
<Image
src={signedUrl}
alt={alt}
fill={fill}
sizes={sizes}
className={className}
/>
);
};
export default SignedImage;

View File

@@ -0,0 +1,132 @@
"use client";
import React, { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { signIn } from "next-auth/react";
const LoginForm = () => {
const [userId, setUserId] = useState("");
const [userPassword, setUserPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setIsLoading(true);
setError(null);
try {
const result = await signIn("credentials", {
userId,
userPassword,
redirect: false,
});
if (result?.error) {
setError("아이디 또는 비밀번호가 일치하지 않습니다.");
} else {
// 로그인 성공 시 홈으로 이동
router.push("/");
}
} catch {
setError("로그인에 실패했습니다.");
} finally {
setIsLoading(false);
}
};
return (
<div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-8">
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
</label>
<input
type="text"
value={userId}
onChange={(e) => setUserId(e.target.value)}
required
disabled={isLoading}
placeholder="아이디를 입력해주세요"
className="w-full h-12 px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-[#6b95c6] focus:border-transparent disabled:opacity-50 transition-all"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
</label>
<input
type="password"
value={userPassword}
onChange={(e) => setUserPassword(e.target.value)}
required
disabled={isLoading}
placeholder="비밀번호를 입력해주세요"
className="w-full h-12 px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-[#6b95c6] focus:border-transparent disabled:opacity-50 transition-all"
/>
</div>
{/* 에러 메시지 */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div className="flex items-center justify-between text-sm">
<Link
href="/signup"
className="text-gray-600 hover:text-[#6b95c6] hover:underline transition-colors font-medium"
>
</Link>
<div className="flex items-center space-x-2 text-gray-600">
<button
type="button"
className="hover:underline hover:text-[#6b95c6] transition-colors"
>
</button>
<span className="text-gray-300">|</span>
<button
type="button"
className="hover:underline hover:text-[#6b95c6] transition-colors"
>
</button>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full h-12 bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] hover:from-[#6b95c6] hover:to-[#5b85b6] text-white rounded-xl shadow-lg hover:shadow-xl hover:scale-[1.02] transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 flex items-center justify-center font-semibold"
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
...
</>
) : (
"로그인"
)}
</button>
{/* 홈으로 돌아가기 */}
<Link
href="/"
className="block text-center text-sm text-gray-600 hover:text-[#6b95c6] transition-colors"
>
</Link>
</form>
</div>
);
};
export default LoginForm;

View File

@@ -0,0 +1,288 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { signUp, type SignUpData } from "@/lib/services";
// 비밀번호 검증 함수 (영문/숫자 포함 8자 이상)
const validatePassword = (password: string): boolean => {
const hasLetter = /[A-Za-z]/.test(password);
const hasNumber = /\d/.test(password);
const isLongEnough = password.length >= 8;
return hasLetter && hasNumber && isLongEnough;
};
const SignUpForm = () => {
const {
register,
handleSubmit,
watch,
setError,
formState: { errors, isSubmitting },
} = useForm<SignUpData>({
mode: "onChange",
defaultValues: {
userId: "",
userPassword: "",
userCheckPassword: "",
userName: "",
userPhone: "",
authCode: "",
},
});
const password = watch("userPassword");
const [submitError, setSubmitError] = useState<string | null>(null);
const router = useRouter();
const onSubmit = async (data: SignUpData) => {
setSubmitError(null);
try {
await signUp(data);
// 회원가입 성공
alert("회원가입이 완료되었습니다. 로그인해주세요.");
router.push("/login");
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "회원가입에 실패했습니다.";
// 아이디 중복 에러
if (errorMessage.includes("아이디")) {
setError("userId", {
type: "manual",
message: errorMessage,
});
}
// 전화번호 중복 에러
else if (errorMessage.includes("전화번호")) {
setError("userPhone", {
type: "manual",
message: errorMessage,
});
}
// 기타 에러
else {
setSubmitError(errorMessage);
}
}
};
return (
<div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-8">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
{/* 아이디 */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
<span className="text-red-500">*</span>
</label>
<input
type="text"
{...register("userId", {
required: "아이디를 입력해주세요",
minLength: {
value: 4,
message: "아이디는 4자 이상이어야 합니다",
},
})}
disabled={isSubmitting}
placeholder="아이디를 입력해주세요"
className={`w-full h-12 px-4 py-3 bg-gray-50 border rounded-xl focus:outline-none focus:ring-2 focus:border-transparent disabled:opacity-50 transition-all ${
errors.userId
? "border-red-300 focus:ring-red-400"
: "border-gray-200 focus:ring-[#6b95c6]"
}`}
/>
{errors.userId && (
<p className="text-red-500 text-xs mt-1">{errors.userId.message}</p>
)}
</div>
{/* 비밀번호 */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
<span className="text-red-500">*</span>
</label>
<input
type="password"
{...register("userPassword", {
required: "비밀번호를 입력해주세요",
validate: {
validPassword: (value) =>
validatePassword(value) ||
"영문과 숫자를 포함하여 8자 이상 입력해주세요",
},
})}
disabled={isSubmitting}
placeholder="영문/숫자 포함 8자 이상"
className={`w-full h-12 px-4 py-3 bg-gray-50 border rounded-xl focus:outline-none focus:ring-2 focus:border-transparent disabled:opacity-50 transition-all ${
errors.userPassword
? "border-red-300 focus:ring-red-400"
: "border-gray-200 focus:ring-[#6b95c6]"
}`}
/>
{errors.userPassword && (
<p className="text-red-500 text-xs mt-1">
{errors.userPassword.message}
</p>
)}
</div>
{/* 비밀번호 확인 */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
<span className="text-red-500">*</span>
</label>
<input
type="password"
{...register("userCheckPassword", {
required: "비밀번호를 다시 입력해주세요",
validate: {
matchPassword: (value) =>
value === password || "비밀번호가 일치하지 않습니다",
},
})}
disabled={isSubmitting}
placeholder="비밀번호를 다시 입력해주세요"
className={`w-full h-12 px-4 py-3 bg-gray-50 border rounded-xl focus:outline-none focus:ring-2 focus:border-transparent disabled:opacity-50 transition-all ${
errors.userCheckPassword
? "border-red-300 focus:ring-red-400"
: "border-gray-200 focus:ring-[#6b95c6]"
}`}
/>
{errors.userCheckPassword && (
<p className="text-red-500 text-xs mt-1">
{errors.userCheckPassword.message}
</p>
)}
</div>
{/* 이름 */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
<span className="text-red-500">*</span>
</label>
<input
type="text"
{...register("userName", {
required: "이름을 입력해주세요",
minLength: {
value: 2,
message: "이름은 2자 이상이어야 합니다",
},
})}
disabled={isSubmitting}
placeholder="이름을 입력해주세요"
className={`w-full h-12 px-4 py-3 bg-gray-50 border rounded-xl focus:outline-none focus:ring-2 focus:border-transparent disabled:opacity-50 transition-all ${
errors.userName
? "border-red-300 focus:ring-red-400"
: "border-gray-200 focus:ring-[#6b95c6]"
}`}
/>
{errors.userName && (
<p className="text-red-500 text-xs mt-1">{errors.userName.message}</p>
)}
</div>
{/* 전화번호 */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
<span className="text-red-500">*</span>
</label>
<input
type="tel"
{...register("userPhone", {
required: "전화번호를 입력해주세요",
pattern: {
value: /^01[0-9][0-9]{7,8}$/,
message: "하이픈(-) 없이 숫자만 입력해주세요 (예: 01012345678)",
},
})}
disabled={isSubmitting}
placeholder="01012345678"
className={`w-full h-12 px-4 py-3 bg-gray-50 border rounded-xl focus:outline-none focus:ring-2 focus:border-transparent disabled:opacity-50 transition-all ${
errors.userPhone
? "border-red-300 focus:ring-red-400"
: "border-gray-200 focus:ring-[#6b95c6]"
}`}
/>
{errors.userPhone && (
<p className="text-red-500 text-xs mt-1">
{errors.userPhone.message}
</p>
)}
</div>
{/* 승인번호 */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
<span className="text-red-500">*</span>
</label>
<input
type="text"
{...register("authCode", {
required: "승인번호를 입력해주세요",
})}
disabled={isSubmitting}
className={`w-full h-12 px-4 py-3 bg-gray-50 border rounded-xl focus:outline-none focus:ring-2 focus:border-transparent disabled:opacity-50 transition-all ${
errors.authCode
? "border-red-300 focus:ring-red-400"
: "border-gray-200 focus:ring-[#6b95c6]"
}`}
/>
{errors.authCode && (
<p className="text-red-500 text-xs mt-1">
{errors.authCode.message}
</p>
)}
</div>
{/* 제출 에러 메시지 */}
{submitError && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{submitError}
</div>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full h-12 bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] hover:from-[#6b95c6] hover:to-[#5b85b6] text-white rounded-xl shadow-lg hover:shadow-xl hover:scale-[1.02] transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 flex items-center justify-center font-semibold mt-6"
>
{isSubmitting ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
...
</>
) : (
"회원가입"
)}
</button>
{/* 로그인 페이지로 */}
<div className="text-center text-sm">
<span className="text-gray-600"> ? </span>
<Link
href="/login"
className="text-[#6b95c6] hover:text-[#5b85b6] font-semibold hover:underline transition-colors"
>
</Link>
</div>
{/* 홈으로 돌아가기 */}
<Link
href="/"
className="block text-center text-sm text-gray-600 hover:text-[#6b95c6] transition-colors"
>
</Link>
</form>
</div>
);
};
export default SignUpForm;

View File

@@ -0,0 +1,72 @@
import { MapPin, Youtube } from 'lucide-react';
import Link from 'next/link';
export default function Contact() {
return (
<section id="contact" className="py-16 smalltablet:py-20 pc:py-24 bg-white">
<div className="relative max-w-7xl mx-auto px-4 smalltablet:px-6 pc:px-8">
{/* vertical dividers (pc+) */}
<div className="hidden pc:block">
<div
className="absolute top-0 bottom-0 bg-gray-200"
style={{ left: "33.3333%", width: 1 }}
/>
<div
className="absolute top-0 bottom-0 bg-gray-200"
style={{ left: "66.6666%", width: 1 }}
/>
{/* red accent bars near top */}
<div
className="absolute top-6 bg-[#6b95c6]"
style={{ left: "33.3333%", width: 3, height: 70, transform: "translateX(-1px)" }}
/>
<div
className="absolute top-6 bg-[#6b95c6]"
style={{ left: "66.6666%", width: 3, height: 70, transform: "translateX(-1px)" }}
/>
</div>
<div className="grid pc:grid-cols-3 gap-10 smalltablet:gap-12 pc:gap-0 items-start">
{/* Left: Title & description */}
<div className="pr-0 pc:pr-12">
<h2 className="text-3xl smalltablet:text-4xl pc:text-5xl font-black tracking-tight text-gray-900 mb-6 smalltablet:mb-8">
Contact Us
</h2>
<p className="text-base smalltablet:text-lg pc:text-xl leading-7 smalltablet:leading-8 text-gray-500 max-w-[680px]">
.
<br className="hidden smalltablet:block" />
.
</p>
</div>
{/* Middle: 오시는 길 */}
<div className="text-center">
<Link href="/directions" className="w-full rounded-xl p-6 smalltablet:p-8 pc:p-10 flex flex-col items-center transition-all duration-300 hover:-translate-y-1 cursor-pointer">
<div className="w-24 h-24 smalltablet:w-28 smalltablet:h-28 rounded-full bg-gray-100 flex items-center justify-center mb-6 smalltablet:mb-8 transition-colors duration-300 hover:bg-[#6b95c6] group">
<MapPin className="w-9 h-9 smalltablet:w-10 smalltablet:h-10 text-gray-700 group-hover:text-white transition-colors duration-300" strokeWidth={2} />
</div>
<h3 className="text-2xl smalltablet:text-3xl font-extrabold text-gray-900 mb-3 smalltablet:mb-4"> </h3>
<p className="text-gray-500 text-base smalltablet:text-lg leading-7 smalltablet:leading-8">
95 32 <br /> 3 / 4
</p>
</Link>
</div>
{/* Right: 유튜브 소개 */}
<div className="text-center">
<Link href="https://www.youtube.com/@Disciples2015" target="_blank" rel="noopener noreferrer" className="w-full rounded-xl p-6 smalltablet:p-8 pc:p-10 flex flex-col items-center transition-all duration-300 hover:-translate-y-1 cursor-pointer">
<div className="w-24 h-24 smalltablet:w-28 smalltablet:h-28 rounded-full bg-gray-100 flex items-center justify-center mb-6 smalltablet:mb-8 transition-colors duration-300 hover:bg-[#6b95c6] group">
<Youtube className="w-9 h-9 smalltablet:w-10 smalltablet:h-10 text-gray-700 group-hover:text-white transition-colors duration-300" strokeWidth={2} />
</div>
<h3 className="text-2xl smalltablet:text-3xl font-extrabold text-gray-900 mb-3 smalltablet:mb-4"> </h3>
<p className="text-gray-500 text-base smalltablet:text-lg leading-7 smalltablet:leading-8">
<br />
</p>
</Link>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,56 @@
export default function FAQ() {
const faqs = [
{
question: "처음 교회를 방문하려는데 어떻게 해야 하나요?",
answer: "주일 오전 10시 30분 예배에 참석하시면 됩니다. 안내 데스크에서 새가족 등록을 도와드리며, 환영 선물도 준비되어 있습니다. 편안한 복장으로 오셔도 괜찮습니다."
},
{
question: "주차 시설이 있나요?",
answer: "네, 교회 지하에 주차장이 있습니다. 주일 예배 시간에는 주차 안내 봉사자가 도와드립니다. 만차인 경우 인근 공영주차장을 이용하실 수 있습니다."
},
{
question: "어린이를 위한 프로그램이 있나요?",
answer: "유아부(0-3세), 유치부(4-7세), 유년부(초등 1-3학년), 소년부(초등 4-6학년)로 나뉘어 연령별 맞춤 교육을 진행하고 있습니다. 전문 교사가 안전하게 돌봐드립니다."
},
{
question: "새신자 교육은 어떻게 진행되나요?",
answer: "매월 첫째 주 토요일 오후 2시에 새신자 교육이 진행됩니다. 총 4주 과정으로 기독교의 기본 교리와 우리 교회를 소개합니다. 수료 후 정식 등록 절차를 안내해 드립니다."
},
{
question: "소그룹 모임에 참여하고 싶어요.",
answer: "연령대별, 지역별로 다양한 소그룹이 운영되고 있습니다. 예배 후 안내 데스크나 담당 교역자에게 문의하시면 적합한 소그룹을 연결해 드립니다."
},
{
question: "헌금은 어떻게 하나요?",
answer: "현금, 계좌이체, 카드 결제 모두 가능합니다. 예배 시간에 헌금함에 넣으시거나, 교회 계좌로 이체하실 수 있습니다. 온라인 헌금 시스템도 운영 중입니다."
},
];
return (
<section className="py-16 smalltablet:py-20 pc:py-24 bg-white">
<div className="max-w-7xl mx-auto px-4 smalltablet:px-6 pc:px-8">
<div className="text-center mb-12 smalltablet:mb-16">
<h2 className="text-3xl smalltablet:text-4xl pc:text-5xl font-black text-gray-900 mb-3 smalltablet:mb-4 tracking-tight">FAQ</h2>
<p className="text-gray-600 text-sm smalltablet:text-base"> </p>
</div>
{/* FAQ 리스트 */}
<div className="grid grid-cols-1 smalltablet:grid-cols-2 gap-4 smalltablet:gap-6">
{faqs.map((faq, index) => (
<div key={index} className="p-4 space-y-4 smalltablet:p-6 rounded-2xl border border-gray-200 bg-white">
<div className="flex items-center gap-3 smalltablet:gap-4 mb-4">
<div className="w-9 h-9 smalltablet:w-10 smalltablet:h-10 bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] rounded-full flex items-center justify-center shrink-0 text-white font-bold text-sm smalltablet:text-base">Q</div>
<h3 className="flex-1 text-base smalltablet:text-lg pc:text-xl font-bold text-gray-900 leading-snug">{faq.question}</h3>
</div>
<div className="flex items-start gap-3 smalltablet:gap-4">
<div className="w-9 h-9 smalltablet:w-10 smalltablet:h-10 bg-linear-to-br from-[#b8d5f0] to-[#a8c5e0] rounded-full flex items-center justify-center shrink-0 text-white font-bold text-sm smalltablet:text-base">A</div>
<p className="text-gray-700 text-sm smalltablet:text-base leading-relaxed">{faq.answer}</p>
</div>
</div>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,144 @@
"use client";
import { useState, useRef } from "react";
import Image from "next/image";
import heroImage1 from "@/public/home/hero/image.webp";
import { ArrowUp } from "lucide-react";
export default function Hero() {
const [currentSlide, setCurrentSlide] = useState(0); // 비디오로 시작
const videoRef = useRef<HTMLVideoElement>(null);
const slide = {
image: heroImage1
};
return (
<section className="relative h-screen overflow-hidden pt-safe">
{/* 배경 슬라이드 */}
<div className="absolute inset-0">
{/* 비디오 배경 */}
<div
className={`absolute inset-0 transition-opacity duration-1000 ${
currentSlide === 0 ? 'opacity-100' : 'opacity-0'
}`}
>
<video
ref={videoRef}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full h-full object-cover"
autoPlay
loop
muted
playsInline
preload="auto"
poster="/home/hero/image2.webp"
style={{ pointerEvents: 'none' }}
>
<source src="/home/hero/video1.webm" type="video/webm" />
</video>
<div className="absolute inset-0 bg-linear-to-b from-slate-900/40 via-slate-900/60 to-slate-900/80" />
</div>
{/* 이미지 배경 */}
<div
className={`absolute inset-0 transition-opacity duration-1000 ${
currentSlide === 1 ? 'opacity-100' : 'opacity-0'
}`}
>
<Image
src={slide.image}
alt="Hero background"
fill
priority
placeholder="blur"
className="object-cover scale-105"
sizes="100vw"
/>
<div className="absolute inset-0 bg-linear-to-b from-slate-900/40 via-slate-900/60 to-slate-900/80" />
</div>
</div>
{/* 메인 컨텐츠 */}
<div className="relative h-full flex flex-col">
<div className="flex-1 flex items-center justify-center relative">
<div className="max-w-7xl mx-auto px-4 smalltablet:px-6 pc:px-8 w-full">
{/* Welcome Home 텍스트 */}
<div className="flex flex-col items-center justify-center text-center space-y-4">
<h1
className="text-6xl smalltablet:text-7xl pc:text-9xl font-bold tracking-wide text-white
animate-[fade-in-up_1s_ease-out,float_3s_ease-in-out_infinite_1s]"
style={{
textShadow: '0 4px 20px rgba(0, 0, 0, 0.5), 0 0 40px rgba(255, 255, 255, 0.1)'
}}
>
Welcome Home!
</h1>
</div>
</div>
</div>
{/* 슬라이드 인디케이터 */}
<div className="absolute bottom-20 smalltablet:bottom-24 left-1/2 -translate-x-1/2 flex gap-3 z-10">
{[0, 1].map((index) => (
<button
key={index}
onClick={() => setCurrentSlide(index)}
className={`transition-all duration-300 rounded-full ${
currentSlide === index
? 'w-10 h-3 bg-white'
: 'w-3 h-3 bg-white/40 hover:bg-white/60'
}`}
aria-label={`슬라이드 ${index + 1}로 이동`}
/>
))}
</div>
{/* 하단 메뉴 아이콘 그리드 */}
{/* <div className="hidden smalltablet:block pb-8 smalltablet:pb-12 pc:pb-16">
<div className="max-w-7xl mx-auto px-4 smalltablet:px-6 pc:px-8 w-full">
<div className="grid grid-cols-3 smalltablet:grid pc:grid-cols-6 gap-4 smalltablet:gap-6 max-w-6xl mx-auto">
{menuItems.map((item, index) => {
const IconComponent = item.icon;
return (
<Link
key={index}
href={item.href}
className="group flex flex-col items-center justify-center p-6 smalltablet:p-8 bg-white/10 backdrop-blur-sm border border-white/20 rounded-lg hover:bg-white/20 transition-all duration-300 hover:scale-105 hover:border-white/40"
>
<div className="text-white mb-3 smalltablet:mb-4 group-hover:scale-110 transition-transform duration-300">
<IconComponent className="w-12 h-12 smalltablet:w-14 smalltablet:h-14" strokeWidth={1.5} />
</div>
<span className="text-white text-sm smalltablet:text-base font-medium text-center">
{item.title}
</span>
</Link>
);
})}
</div>
</div>
</div> */}
</div>
{/* TOP 버튼 - 현대적인 디자인 */}
<button
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
className="fixed cursor-pointer bottom-6 right-6 smalltablet:bottom-8 smalltablet:right-8
bg-linear-to-br from-[#6b95c6] to-[#5a7fb0] hover:from-[#7aa5d6] hover:to-[#6b95c6]
text-white
p-3 smalltablet:p-4 rounded-full
shadow-lg hover:shadow-2xl
transition-all duration-300
z-50
border-2 border-white/20
hover:scale-110 hover:-translate-y-1
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-[#6b95c6]
active:scale-95
group"
aria-label="맨 위로 이동"
>
<ArrowUp className="w-5 h-5 smalltablet:w-6 smalltablet:h-6 group-hover:translate-y-[-2px] transition-transform" strokeWidth={2.5} />
</button>
</section>
);
}

View File

@@ -0,0 +1,77 @@
import Image from "next/image";
import Link from "next/link";
import introImage1 from "@/public/home/intro/church1.webp";
import introImage3 from "@/public/home/intro/pray.webp";
export default function Intro() {
const items = [
{
image: introImage1,
title: "주일 설교",
subtitle: "Sunday Sermon",
category: "sermon",
},
{
image: introImage3,
title: "금요 성령집회",
subtitle: "Friday Meeting",
category: "friday",
}
];
return (
<section id="intro" className="py-16 smalltablet:py-20 pc:py-24 bg-gray-50">
<div className="max-w-7xl mx-auto px-4 smalltablet:px-6 pc:px-8">
<div className="flex flex-col smalltablet:flex-row justify-between smalltablet:items-end gap-6 smalltablet:gap-4 mb-12 smalltablet:mb-16">
<div>
<h2 className="text-3xl smalltablet:text-4xl pc:text-5xl font-black text-gray-800 mb-2 tracking-tight">Worship services.</h2>
<p className="text-gray-600 text-lg smalltablet:text-xl font-semibold"> </p>
</div>
</div>
<div className="grid grid-cols-2 smalltablet:grid-cols-2 pc:grid-cols-2 gap-3 smalltablet:gap-4 pc:gap-8 smalltablet:max-w-2xl smalltablet:mx-auto pc:max-w-7xl">
{items.map((item, index) => (
<Link
key={index}
href={`/worship?category=${item.category}`}
className="group hover:cursor-pointer rounded-2xl smalltablet:rounded-3xl p-8 smalltablet:p-10 relative overflow-hidden hover:shadow-xl transition-shadow col-span-2 aspect-2/1 pc:col-span-1 pc:aspect-[16/9] block"
>
{/* 배경 이미지 레이어 */}
<Image
src={item.image}
alt={item.title}
fill
placeholder="blur"
className="object-cover transition-transform duration-500 group-hover:scale-110"
sizes="(max-width: 768px) 100vw, 50vw"
/>
{/* 검은색 오버레이 */}
<div className="absolute inset-0 z-10 bg-black opacity-50 group-hover:opacity-20 transition-opacity"/>
{/* 컨텐츠 */}
<div className="absolute inset-0 z-20 flex flex-col justify-center items-center">
{/* 텍스트 */}
<div className="flex flex-col justify-center items-center w-full">
<h3 className="text-4xl smalltablet:text-5xl pc:text-5xl font-extrabold text-white mb-0 smalltablet:mb-3 text-center smalltablet:text-left">
{item.title}
</h3>
<h4 className="smalltablet:block text-xl smalltablet:text-2xl font-bold text-white mb-3 smalltablet:mb-3 pc:mb-6">
{item.subtitle}
</h4>
</div>
</div>
{/* 오른쪽 아래 화살표 */}
<div className="hidden smalltablet:block absolute bottom-5 right-5 smalltablet:bottom-6 smalltablet:right-6 z-20">
<svg className="w-6 h-6 smalltablet:w-8 smalltablet:h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</div>
</Link>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,59 @@
export default function Ministries() {
const sections = [
{
title: "담임목사님을 소개합니다",
description: "이용희 담임목사님의 인사말과 \n 4가지의 핵심 가치를 소개합니다",
buttonText: "PASTOR"
},
{
title: "시설 안내",
description: "본관과 교육관의 시설 현황을 \n 안내합니다.",
buttonText: "FACILITY"
},
{
title: "조직 구성 안내",
description: "대전제일교회의 부서와 조직을 \n 안내합니다.",
buttonText: "ORGANIZATION"
},
{
title: "찾아오시는길 안내",
description: "교회 주변의 약도와 \n 대중교통 노선을 안내 합니다.",
buttonText: "CONTACT US"
}
];
return (
<section
className="py-12 smalltablet:py-16 pc:py-20 bg-gray-50"
>
<div className="max-w-7xl mx-auto px-4 smalltablet:px-6 pc:px-8">
<h2 className="text-3xl smalltablet:text-4xl pc:text-5xl font-black text-gray-900 mb-8 smalltablet:mb-10 pc:mb-12 text-end">More.</h2>
<div className="grid grid-cols-1 smalltablet:grid-cols-2 gap-4 smalltablet:gap-6 pc:gap-8">
{sections.map((section, index) => (
<div
key={index}
className="flex flex-row smalltablet:flex-col pc:flex-row items-center smalltablet:items-start pc:items-center justify-between smalltablet:justify-start pc:justify-between gap-4 smalltablet:gap-4 pc:gap-8 bg-[#e8f2fa] rounded-xl smalltablet:rounded-2xl p-4 smalltablet:p-6 pc:p-8"
>
{/* 왼쪽/위: 텍스트 콘텐츠 */}
<div className="flex-1 smalltablet:w-full pc:flex-1">
<h3 className="text-lg smalltablet:text-xl pc:text-2xl font-bold text-gray-900 mb-2 smalltablet:mb-2.5 pc:mb-3">
{section.title}
</h3>
<p className="text-sm smalltablet:text-base pc:text-lg text-gray-600 leading-relaxed whitespace-pre-line">
{section.description}
</p>
</div>
{/* 오른쪽/아래: 버튼 */}
<button className="shrink-0 smalltablet:w-full smalltablet:mt-2 pc:w-auto pc:mt-0 bg-[#7ba5d6] hover:bg-[#6b95c6] text-white font-semibold uppercase px-4 smalltablet:px-8 pc:px-10 py-2.5 smalltablet:py-3.5 pc:py-4 rounded-xl smalltablet:rounded-2xl text-xs smalltablet:text-base pc:text-lg transition-colors duration-200 shadow-sm hover:shadow-md whitespace-nowrap">
{section.buttonText}
</button>
</div>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,211 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
interface Announcement {
id: number;
title: string;
content: string;
createdAt: string;
}
interface GalleryPost {
id: number;
title: string;
createdAt: string;
thumbnailUrl?: string;
}
export default function NewsAndGalleryClient() {
const [newsItems, setNewsItems] = useState<Announcement[]>([]);
const [galleryPosts, setGalleryPosts] = useState<GalleryPost[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchData() {
try {
const [announcementsRes, galleryRes] = await Promise.all([
fetch('/api/announcements?limit=6'),
fetch('/api/gallery?limit=4'),
]);
const [announcementsData, galleryData] = await Promise.all([
announcementsRes.json(),
galleryRes.json(),
]);
setNewsItems(announcementsData.data || []);
// 갤러리 썸네일 URL 가져오기
const galleryWithUrls = await Promise.all(
(galleryData.data || []).map(async (post: GalleryPost & { images?: Array<{ fileKey: string }> }) => {
if (post.images?.[0]?.fileKey) {
const urlRes = await fetch('/api/files/download-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileKey: post.images[0].fileKey }),
});
const urlData = await urlRes.json();
return { ...post, thumbnailUrl: urlData.data?.downloadUrl };
}
return post;
})
);
setGalleryPosts(galleryWithUrls);
} catch (error) {
console.error('Failed to fetch data:', error);
} finally {
setLoading(false);
}
}
fetchData();
}, []);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const year = date.getFullYear();
return { day, date: `${year}.${month}` };
};
return (
<section className="py-16 smalltablet:py-20 pc:py-24 bg-gray-50">
<div className="max-w-7xl mx-auto px-4 smalltablet:px-6 pc:px-8">
<div className="grid pc:grid-cols-2 gap-10 smalltablet:gap-12">
{/* 왼쪽: 소식 */}
<div className="overflow-hidden">
<div className="flex justify-between items-center gap-3 smalltablet:gap-4 pb-3 smalltablet:pb-4 mb-8 smalltablet:mb-10 border-b border-gray-200">
<div className="flex-1 min-w-0">
<h2 className="text-3xl smalltablet:text-4xl pc:text-5xl font-black text-gray-900 mb-1 smalltablet:mb-2 tracking-tight">News</h2>
<p className="text-gray-600 text-sm smalltablet:text-base"></p>
</div>
<Link
href="/announcements"
aria-label="더보기"
className="w-12 h-12 smalltablet:w-14 smalltablet:h-14 shrink-0 flex-none rounded-xl bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] hover:from-[#6b95c6] hover:to-[#5f89bc] text-white flex items-center justify-center transition-colors shadow-sm border border-[#6b95c6]"
>
<svg className="w-4 h-4 smalltablet:w-5 smalltablet:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
<div className="space-y-3">
{loading ? (
Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="bg-white p-4 smalltablet:p-5 rounded-xl border border-gray-200 animate-pulse">
<div className="flex items-start gap-3 smalltablet:gap-4">
<div className="shrink-0 bg-gray-200 w-14 h-14 smalltablet:w-16 smalltablet:h-16 rounded-lg" />
<div className="flex-1 space-y-2">
<div className="h-5 bg-gray-200 rounded w-3/4" />
<div className="h-4 bg-gray-200 rounded w-full" />
</div>
</div>
</div>
))
) : newsItems.length > 0 ? (
newsItems.slice(0, 6).map((item, index) => {
const { day, date } = formatDate(item.createdAt);
return (
<Link
key={item.id}
href={`/announcements/${item.id}`}
className={`bg-white hover:bg-gray-50 p-4 smalltablet:p-5 rounded-xl transition-all duration-200 cursor-pointer border border-gray-200 group block ${index >= 4 ? 'hidden smalltablet:block' : ''}`}
>
<div className="flex items-start gap-3 smalltablet:gap-4">
<div className="shrink-0 bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] text-white w-14 h-14 smalltablet:w-16 smalltablet:h-16 rounded-lg flex flex-col items-center justify-center">
<div className="text-xl smalltablet:text-2xl font-bold">{day}</div>
<div className="text-xs mt-0.5 smalltablet:mt-1">{date}</div>
</div>
<div className="flex-1">
<h3 className="text-base smalltablet:text-lg font-bold text-gray-900 mb-1 smalltablet:mb-2 group-hover:text-[#6b95c6] transition-colors">
{item.title}
</h3>
<p className="text-xs smalltablet:text-sm text-gray-600 leading-relaxed line-clamp-2">
{item.content}
</p>
</div>
</div>
</Link>
);
})
) : (
<div className="text-center py-8 text-gray-500"> .</div>
)}
</div>
</div>
{/* 오른쪽: 갤러리 */}
<div className="overflow-hidden">
<div className="flex justify-between items-center gap-3 smalltablet:gap-4 pb-3 smalltablet:pb-4 mb-8 smalltablet:mb-10 border-b border-gray-200">
<div className="flex-1 min-w-0">
<h2 className="text-3xl smalltablet:text-4xl pc:text-5xl font-black text-gray-900 mb-1 smalltablet:mb-2 tracking-tight">Photos</h2>
<p className="text-gray-600 text-sm smalltablet:text-base"></p>
</div>
<Link
href="/gallery"
aria-label="더보기"
className="w-12 h-12 smalltablet:w-14 smalltablet:h-14 shrink-0 flex-none rounded-xl bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] hover:from-[#6b95c6] hover:to-[#5f89bc] text-white flex items-center justify-center transition-colors shadow-sm border border-[#6b95c6]"
>
<svg className="w-4 h-4 smalltablet:w-5 smalltablet:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
<div className="grid grid-cols-2 gap-3 smalltablet:mx-auto smalltablet:grid-cols-3 smalltablet:gap-4 pc:grid-cols-2">
{loading ? (
Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="aspect-square bg-gray-200 rounded-xl animate-pulse" />
))
) : galleryPosts.length > 0 ? (
galleryPosts.map((post) => {
const { date } = formatDate(post.createdAt);
return (
<Link
key={post.id}
href={`/gallery/${post.id}`}
className="group relative bg-white rounded-xl overflow-hidden shadow-md hover:shadow-xl transition-all duration-300 hover:-translate-y-1 cursor-pointer border border-gray-200 block"
>
<div className="aspect-square bg-gray-100 relative overflow-hidden">
{post.thumbnailUrl ? (
<Image
src={post.thumbnailUrl}
alt={post.title}
fill
className="object-cover"
sizes="(max-width: 768px) 50vw, 25vw"
unoptimized
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-200 text-gray-400">
No Image
</div>
)}
</div>
<div className="absolute inset-0 bg-linear-to-t from-black/70 to-transparent transition-opacity flex items-end">
<div className="p-2.5 smalltablet:p-3 text-white w-full">
<h3 className="text-xs smalltablet:text-sm font-bold mb-0.5 smalltablet:mb-1">
{post.title}
</h3>
<p className="text-xs text-gray-200">{date}</p>
</div>
</div>
</Link>
);
})
) : (
<div className="col-span-2 text-center py-8 text-gray-500"> .</div>
)}
</div>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,117 @@
"use client";
import { useState } from "react";
export default function ServiceTimes() {
const [currentSlide, setCurrentSlide] = useState(0);
const programs = [
{
number: "01",
fraction: "/ 06",
title: "정신건강검진 프로그램",
description: "편하게 방문하시면 전문의와 상담을 통해 필요한 검사 프로그램을 안내해드립니다.",
icon: (
<svg className="w-24 h-24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
),
bgColor: "bg-linear-to-br from-[#7ba5d6] to-[#6b95c6]"
},
{
number: "02",
fraction: "/ 06",
title: "성인/청소년 종합심리검사",
description: "현재 겪고 있는 다양한 심리적 어려움과 그 원인을 다각하고 전문적인 도구를 사용하여 파악하고 이를 객관할 수 있는 검사입니다.",
icon: (
<svg className="w-24 h-24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
bgColor: "bg-linear-to-br from-[#a8c5e0] to-[#98b5d0]"
}
];
return (
<section id="exam" className="py-16 smalltablet:py-20 pc:py-24 bg-gray-50">
<div className="max-w-7xl mx-auto px-4 smalltablet:px-6 pc:px-8">
<div className="grid pc:grid-cols-2 gap-8 smalltablet:gap-12 pc:gap-16 items-center">
{/* 왼쪽 텍스트 */}
<div>
<h2 className="text-3xl smalltablet:text-4xl pc:text-5xl font-black text-gray-800 mb-6 smalltablet:mb-8 tracking-tight">
.
</h2>
<p className="text-gray-600 text-base smalltablet:text-lg leading-relaxed mb-8 smalltablet:mb-12">
, , ,<br className="hidden pc:block" />
, , <br className="hidden pc:block" />
.
</p>
<button className="bg-linear-to-r from-[#7ba5d6] to-[#6b95c6] hover:from-[#6b95c6] hover:to-[#5b85b6] text-white px-6 py-2.5 smalltablet:px-8 smalltablet:py-3 text-sm smalltablet:text-base flex items-center gap-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#6b95c6] active:scale-[0.98] rounded-lg">
VIEW MORE
<svg className="w-4 h-4 smalltablet:w-5 smalltablet:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</button>
</div>
{/* 오른쪽 슬라이더 */}
<div className="relative">
<div className="flex gap-6 overflow-hidden">
{programs.map((program, index) => (
<div
key={index}
className={`shrink-0 w-full ${program.bgColor} rounded-2xl smalltablet:rounded-3xl p-8 smalltablet:p-10 pc:p-12 relative transition-all ${
currentSlide === index ? 'opacity-100' : 'opacity-0 absolute inset-0'
}`}
>
<div className="flex flex-col h-full">
<div className="text-white opacity-70 mb-4 smalltablet:mb-6">
<svg className="w-16 h-16 smalltablet:w-20 smalltablet:h-20 pc:w-24 pc:h-24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{program.icon.props.children}
</svg>
</div>
<h3 className="text-2xl smalltablet:text-3xl font-bold text-white mb-4 smalltablet:mb-6">
{program.title}
</h3>
<p className="text-white/90 text-sm smalltablet:text-base leading-relaxed mb-6 smalltablet:mb-8 grow">
{program.description}
</p>
<div className="flex items-end justify-between">
<div className="text-white/40 text-6xl smalltablet:text-7xl pc:text-8xl font-bold">
{program.number}
</div>
<div className="text-white text-base smalltablet:text-lg">
{program.fraction}
</div>
</div>
</div>
</div>
))}
</div>
{/* 슬라이더 컨트롤 */}
<div className="flex gap-3 smalltablet:gap-4 mt-6 smalltablet:mt-8">
<button
onClick={() => setCurrentSlide(Math.max(0, currentSlide - 1))}
className="text-gray-400 hover:text-gray-600 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#6b95c6] active:scale-[0.98] rounded"
>
<svg className="w-8 h-8 smalltablet:w-10 smalltablet:h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
onClick={() => setCurrentSlide(Math.min(programs.length - 1, currentSlide + 1))}
className="text-gray-400 hover:text-gray-600 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#6b95c6] active:scale-[0.98] rounded"
>
<svg className="w-8 h-8 smalltablet:w-10 smalltablet:h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,82 @@
import Image from "next/image";
import circlesImage from "@/public/home/welcome/circles.webp";
export default function Welcome() {
const features = [
{
title: "제자들 교회에 처음 오셨나요?",
description: "“너희는 위로하라 내 백성을 위로하라”(사40:1) \n 제자들교회는 닫힌 숨이 열리는 진정한 위로의 공동체입니다. ",
color:"#a9c6e1"
},
{
title: "말씀으로 살아가는 교회",
description: "“너희가 내 말에 거하면 참으로 내 제자가 되고”(요8:31) \n 제자들교회는 말씀을 묵상함으로 하나님의 창조적인 역사를 \n 경험해 가는 공동체입니다. ",
color:"#94b7d6"
},
{
title: "서로 사랑하는 교회",
description: "“너희가 서로 사랑하면 이로써 모든 사람이 너희가 내 제자인 줄 알리라”(요13:35) \n 제자들교회는 서로의 외로움을 채우며 마음껏 섬겨가는 사랑의 공동체입니다. ",
color:"#88aad2"
},
{
title: "복음 전도와 선교를 위해 존재하는 교회",
description: "“너희가 열매를 많이 맺으면 내 아버지께서 영광을 받으실 것이요 \n 너희는 내 제자가 되리라”(요15:8) \n 제자들교회는 한 영혼을 살리는 복음전도와 선교에 \n 집중하는 교회입니다. ",
color:"#6d96c5"
}
];
return (
<section className="py-16 smalltablet:py-20 pc:py-24 bg-white">
<div className="max-w-7xl mx-auto px-4 smalltablet:px-6 pc:px-8">
{/* 제목 */}
<div className="flex flex-col items-end justify-end mb-12 smalltablet:mb-16">
<h2 className="text-3xl smalltablet:text-4xl pc:text-5xl font-black text-end text-gray-900 mb-3 smalltablet:mb-4">
Welcome!
</h2>
<p className="text-sm smalltablet:text-base pc:text-lg text-gray-700 leading-relaxed whitespace-pre-line text-end">
!
</p>
</div>
{/* 메인 컨텐츠 */}
<div className="flex flex-row items-center justify-center gap-4 smalltablet:gap-6 pc:gap-0">
{/* 왼쪽: 교회 아이콘 */}
<div className="w-1/3 smalltablet:w-2/5 pc:w-1/2 flex justify-center shrink-0">
<div className="relative w-24 h-24 smalltablet:w-48 smalltablet:h-48 pc:w-[450px] pc:h-[450px]">
{/* 원형 이미지 */}
<Image
src={circlesImage}
alt="청주에덴교회"
fill
className="object-contain drop-shadow-2xl"
placeholder="blur"
/>
</div>
</div>
{/* 오른쪽: 특징 리스트 */}
<div className="pc:w-1/2 space-y-4 smalltablet:space-y-6 pc:space-y-8">
{features.map((feature, index) => (
<div
key={index}
className="border-l-4 pl-4 smalltablet:pl-6 pc:pl-6"
style={{ borderColor: feature.color }}
>
<h3
className="text-sm smalltablet:text-xl pc:text-2xl font-bold mb-1 smalltablet:mb-2 pc:mb-2 leading-tight"
style={{ color: feature.color }}
>
{feature.title}
</h3>
<p className="text-xs hidden smalltablet:block smalltablet:text-sm pc:text-base text-gray-700 leading-relaxed whitespace-pre-line">
{feature.description}
</p>
</div>
))}
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,11 @@
import { SessionProvider as NextAuthSessionProvider } from "next-auth/react";
import { ReactNode } from "react";
export default function SessionProvider({ children }: { children: ReactNode }) {
return (
<NextAuthSessionProvider refetchInterval={0} refetchOnWindowFocus={true}>
{children}
</NextAuthSessionProvider>
);
}

View File

@@ -0,0 +1,178 @@
import React from 'react'
interface JsonLdProps {
data: Record<string, unknown>
}
export function JsonLd({ data }: JsonLdProps) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
)
}
// 교회 조직 구조화 데이터
export function OrganizationJsonLd() {
const organizationData = {
'@context': 'https://schema.org',
'@type': 'Church',
name: '제자들교회',
alternateName: '인천 제자들교회',
url: 'https://www.disciples-church.com',
logo: 'https://www.disciples-church.com/logo.webp',
description: '인천 제자들교회 - 성경적 제자도를 실천하는 교회',
address: {
'@type': 'PostalAddress',
addressLocality: '인천광역시',
addressCountry: 'KR',
},
contactPoint: {
'@type': 'ContactPoint',
contactType: 'customer service',
availableLanguage: 'Korean',
},
sameAs: [
// 소셜 미디어 링크가 있다면 여기에 추가
// 'https://www.facebook.com/jaejadle',
// 'https://www.instagram.com/jaejadle',
// 'https://www.youtube.com/@jaejadle',
],
}
return <JsonLd data={organizationData} />
}
// 웹사이트 구조화 데이터
export function WebSiteJsonLd() {
const websiteData = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: '제자들교회',
alternateName: '인천 제자들교회',
url: 'https://www.disciples-church.com',
description: '인천 제자들교회 - 성경적 제자도를 실천하는 교회',
inLanguage: 'ko-KR',
publisher: {
'@type': 'Church',
name: '제자들교회',
url: 'https://www.disciples-church.com',
logo: {
'@type': 'ImageObject',
url: 'https://www.disciples-church.com/logo.webp',
},
},
potentialAction: {
'@type': 'SearchAction',
target: {
'@type': 'EntryPoint',
urlTemplate: 'https://www.disciples-church.com/search?q={search_term_string}',
},
'query-input': 'required name=search_term_string',
},
}
return <JsonLd data={websiteData} />
}
// 빵가루 네비게이션 구조화 데이터
interface BreadcrumbItem {
name: string
url: string
}
export function BreadcrumbJsonLd({ items }: { items: BreadcrumbItem[] }) {
const breadcrumbData = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: item.url,
})),
}
return <JsonLd data={breadcrumbData} />
}
// 예배 이벤트 구조화 데이터
interface ServiceEvent {
name: string
startDate: string
endDate: string
description?: string
location?: string
}
export function ServiceEventJsonLd({ event }: { event: ServiceEvent }) {
const eventData = {
'@context': 'https://schema.org',
'@type': 'Event',
name: event.name,
startDate: event.startDate,
endDate: event.endDate,
description: event.description,
location: {
'@type': 'Place',
name: event.location || '제자들교회',
address: {
'@type': 'PostalAddress',
addressLocality: '인천광역시',
addressCountry: 'KR',
},
},
organizer: {
'@type': 'Church',
name: '제자들교회',
url: 'https://www.disciples-church.com',
},
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
eventStatus: 'https://schema.org/EventScheduled',
}
return <JsonLd data={eventData} />
}
// 기사/블로그 포스트 구조화 데이터
interface ArticleData {
headline: string
description: string
datePublished: string
dateModified?: string
author?: string
image?: string
url: string
}
export function ArticleJsonLd({ article }: { article: ArticleData }) {
const articleData = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: article.headline,
description: article.description,
datePublished: article.datePublished,
dateModified: article.dateModified || article.datePublished,
author: {
'@type': 'Organization',
name: article.author || '제자들교회',
},
publisher: {
'@type': 'Organization',
name: '제자들교회',
logo: {
'@type': 'ImageObject',
url: 'https://www.disciples-church.com/logo.webp',
},
},
image: article.image,
mainEntityOfPage: {
'@type': 'WebPage',
'@id': article.url,
},
}
return <JsonLd data={articleData} />
}

View File

@@ -0,0 +1,77 @@
/**
* 동적 메타 태그 컴포넌트
*
* 사용 예시:
* <MetaTags
* title="페이지 제목"
* description="페이지 설명"
* keywords={['키워드1', '키워드2']}
* image="/images/og-image.jpg"
* url="/about"
* />
*/
import { Metadata } from 'next';
interface MetaTagsProps {
title: string;
description: string;
keywords?: string[];
image?: string;
url: string;
type?: 'website' | 'article';
author?: string;
publishedTime?: string;
modifiedTime?: string;
}
export function generateMetadata({
title,
description,
keywords = [],
image,
url,
type = 'website',
author,
}: MetaTagsProps): Metadata {
const baseUrl = 'https://www.disciples-church.com';
const fullUrl = `${baseUrl}${url}`;
const ogImage = image ? `${baseUrl}${image}` : `${baseUrl}/opengraph-image.jpg`;
const metadata: Metadata = {
title,
description,
keywords: [...keywords, '제자들교회', '인천', '교회'],
authors: author ? [{ name: author }] : [{ name: '제자들교회' }],
openGraph: {
title,
description,
url: fullUrl,
type,
images: [
{
url: ogImage,
width: 1200,
height: 630,
alt: title,
},
],
siteName: '제자들교회',
locale: 'ko_KR',
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [ogImage],
},
alternates: {
canonical: fullUrl,
},
};
return metadata;
}
export default generateMetadata;

View File

@@ -0,0 +1,81 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { signOut, useSession } from "next-auth/react";
const AuthButton = ({
shouldShowScrolled = false,
onLinkClick
}: {
shouldShowScrolled: boolean;
onLinkClick?: () => void;
}) => {
const { data: session, status } = useSession();
const router = useRouter();
const handleLogout = async () => {
try {
await signOut({ redirect: false });
router.push("/");
router.refresh();
} catch (error) {
console.error("Logout error:", error);
}
};
if (status === "loading") {
return (
<div className="flex items-center">
<div className="h-9 w-18 bg-gray-400 animate-pulse rounded-lg"/>
</div>
);
}
if (session?.user) {
return (
<div className="flex items-center gap-3">
{/* 구분선 */}
<div className={`h-6 w-px transition-colors ${
shouldShowScrolled
? "bg-gray-300"
: "bg-white/30 pc:group-hover:bg-gray-300"
}`} />
<span className={`text-sm transition-colors ${
shouldShowScrolled
? "text-gray-600"
: "text-white/90 pc:group-hover:text-gray-600"
}`}>
{session.user.name}
</span>
<button
onClick={handleLogout}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
shouldShowScrolled
? "text-gray-700 hover:text-gray-900 bg-gray-200 hover:bg-gray-300"
: "text-white hover:text-gray-200 bg-white/10 hover:bg-white/20 pc:group-hover:text-gray-700 pc:group-hover:bg-gray-200 pc:group-hover:hover:text-gray-900 pc:group-hover:hover:bg-gray-300"
}`}
>
</button>
</div>
);
}
return (
<div className="flex items-center">
<Link
href="/login"
onClick={onLinkClick}
className="px-4 py-2 w-18 text-center text-sm font-medium text-white bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] hover:from-[#6b95c6] hover:to-[#5b85b6] rounded-lg shadow-md hover:shadow-lg transition-all"
>
</Link>
</div>
);
};
export default AuthButton;

View File

@@ -0,0 +1,52 @@
import Link from 'next/link';
import { Youtube } from 'lucide-react';
import Image from 'next/image';
import cafeIcon from '@/public/footer/cafe.webp';
export default function Footer() {
return (
<footer className="bg-slate-900 border-t border-slate-800">
<div className="mx-auto px-6 py-8">
<div className="flex flex-col gap-4">
{/* 하단 정보 */}
<div className="space-y-2 text-sm text-slate-400">
<p>
담임목사 : 김경한 | 주소 : 인천광역시 95 32 3 / 4
</p>
<Link href="/login" className="text-slate-500">
<p>
COPYRIGHT © 2026 DISCIPLES CHURCH. All rights reserved.
</p>
</Link>
</div>
{/* 아이콘들 */}
<div className="flex items-center gap-4">
<Link
href="https://www.youtube.com/@Disciples2015"
className="text-slate-100 hover:text-white transition-colors cursor-pointer flex flex-col items-center gap-1"
aria-label="YouTube"
>
<Youtube className="w-6 h-6" />
<span className='text-xs'></span>
</Link>
<Link
href="https://cafe.naver.com/discipling"
className="text-slate-100 hover:text-white transition-colors cursor-pointer flex flex-col items-center gap-1"
aria-label="Naver Cafe"
>
<Image
src={cafeIcon}
alt="Naver Cafe"
width={24}
height={24}
placeholder="blur"
/>
<span className='text-xs'> </span>
</Link>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,243 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import Image from "next/image";
import { usePathname, useRouter } from "next/navigation";
import { ChevronDown } from "lucide-react";
import iconBlack from "@/public/icon_black.webp";
import iconWhite from "@/public/icon_white.webp";
// import AuthButton from "@/components/widgets/AuthButton";
import tabs from "@/const/tabs";
export default function Header() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
const [hoveredTab, setHoveredTab] = useState<number | null>(null);
const [expandedTabs, setExpandedTabs] = useState<Set<number>>(new Set());
const pathname = usePathname();
const router = useRouter();
// 로그인/회원가입 페이지인지 확인
const isAuthPage = pathname === "/login" || pathname === "/signup";
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 0);
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
// 로그인/회원가입 페이지 또는 모바일 메뉴가 열렸을 때 항상 스크롤된 상태로 표시
// hover는 CSS로만 처리 (데스크톱에서만 작동)
const shouldShowScrolled = isAuthPage || isScrolled || isMenuOpen || hoveredTab !== null;
// 모바일 메뉴에서 탭 확장/축소 토글
const toggleTab = (index: number) => {
setExpandedTabs((prev) => {
const newSet = new Set(prev);
if (newSet.has(index)) {
newSet.delete(index);
} else {
newSet.add(index);
}
return newSet;
});
};
return (
<header
className={`group pr-4 fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
shouldShowScrolled || isMenuOpen
? "bg-white shadow-md"
: "bg-transparent pc:hover:bg-white pc:hover:shadow-md"
}`}
>
<nav className="max-w-[1400px] mx-auto">
<div className="flex justify-between items-stretch h-[56px] pc:h-[70px] relative z-10">
{/* 로고 */}
<div className="shrink-0">
<Link href="/" className="flex items-center gap-3 group h-full px-6">
{/* 아이콘 - 모바일은 작게, 데스크톱은 크게 */}
<div className="relative w-8 h-8 pc:w-10 pc:h-10">
{/* 흰색 아이콘 */}
<Image
src={iconWhite}
alt="제자들교회 로고"
width={40}
height={40}
className={`absolute inset-0 w-full h-full transition-opacity duration-300 ${
shouldShowScrolled ? "opacity-0" : "opacity-100 pc:group-hover:opacity-0"
}`}
placeholder="blur"
priority
/>
{/* 검은색 아이콘 */}
<Image
src={iconBlack}
alt="제자들교회 로고"
width={40}
height={40}
className={`absolute inset-0 w-full h-full transition-opacity duration-300 ${
shouldShowScrolled ? "opacity-100" : "opacity-0 pc:group-hover:opacity-100"
}`}
placeholder="blur"
priority
/>
</div>
<div className={shouldShowScrolled || isMenuOpen ? "text-black pc:hover:text-black" : "text-white pc:group-hover:text-black"}>
<div className="text-xl pc:text-2xl font-bold tracking-wide"></div>
{/* 데스크톱: 영어 이름 표시 */}
<div className="hidden pc:block text-xs opacity-90">DISCIPLES CHURCH</div>
</div>
</Link>
</div>
{/* 데스크톱 네비게이션 */}
<div className="hidden pc:flex">
{tabs.map((tab, index) => (
<div
key={tab.label}
className="relative flex items-stretch"
onMouseEnter={() => setHoveredTab(index)}
onMouseLeave={() => setHoveredTab(null)}
>
<Link
href={tab.href || tab.submenu[0]?.href || "#"}
className={`${shouldShowScrolled ? "text-black hover:text-black" : "text-white/90 pc:group-hover:text-black/90"} font-medium transition-colors flex items-center px-6`}
>
{tab.label}
</Link>
{/* 말풍선 스타일 드롭다운 */}
{tab.submenu.length > 1 && (
<div
className={`absolute top-full left-1/2 -translate-x-1/2 pt-4 transition-all duration-300 ease-in-out ${
hoveredTab === index
? "opacity-100 translate-y-0 pointer-events-auto"
: "opacity-0 -translate-y-2 pointer-events-none"
}`}
>
{/* 말풍선 본체 */}
<div className="bg-gray-500 text-white rounded-3xl shadow-xl overflow-visible min-w-[180px] relative">
{/* 말풍선 꼬리 (위쪽 삼각형) - 박스 안쪽 최상단에 위치 */}
<div className="absolute left-1/2 -translate-x-1/2 -top-3 w-0 h-0 border-l-14 border-l-transparent border-r-14 border-r-transparent border-b-14 border-b-gray-500"/>
{tab.submenu.map((item) => (
<Link
key={item.href}
href={item.href}
className="block px-6 py-3 hover:bg-white/10 transition-colors text-center border-b border-gray-600 last:border-b-0"
>
{item.label}
</Link>
))}
</div>
</div>
)}
</div>
))}
{/* <AuthButton shouldShowScrolled={shouldShowScrolled} /> */}
</div>
{/* 햄버거 메뉴 */}
<div className="pc:hidden flex items-center">
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className={`${shouldShowScrolled || isMenuOpen ? "text-black" : "text-white pc:group-hover:text-black"} flex items-center px-6 transition-colors duration-300`}
>
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d={isMenuOpen ? "M6 18L18 6M6 6l12 12" : "M4 6h16M4 12h16M4 18h16"}
className="transition-all duration-300"
/>
</svg>
</button>
</div>
</div>
{/* 모바일 메뉴 */}
{isMenuOpen && (
<div className="pc:hidden fixed inset-0 z-30">
{/* 배경 오버레이 */}
<div
className="absolute inset-0 transition-opacity duration-300"
onClick={() => setIsMenuOpen(false)}
/>
{/* 모달 메뉴 */}
<div className="absolute top-[56px] left-0 right-0 border-b border-gray-300 bg-white max-h-[calc(100vh-56px)] overflow-y-auto pt-4 px-6 pb-6 transition-all z-30 animate-fade-in-fast">
<div className="space-y-1">
{tabs.map((tab, index) => {
const isExpanded = expandedTabs.has(index);
const firstSubmenuHref = tab.submenu[0]?.href || "#";
const handleMainTabClick = () => {
if (tab.submenu.length > 1) {
// 서브메뉴가 있는 경우 토글
toggleTab(index);
} else {
// 서브메뉴가 없는 경우 바로 이동
const targetHref = tab.href || firstSubmenuHref;
if (targetHref !== "#") {
router.push(targetHref);
setIsMenuOpen(false);
}
}
};
return (
<div key={tab.label} className="border-b border-gray-100 last:border-b-0">
{/* 메인 탭 */}
<div
className="w-full flex justify-between items-center font-semibold py-3 text-lg cursor-pointer"
onClick={handleMainTabClick}
>
<span className="flex-1 text-left py-2">
{tab.label}
</span>
{tab.submenu.length > 1 && (
<span className="ml-2 p-2">
<ChevronDown
className={`w-5 h-5 transition-transform duration-200 ${
isExpanded ? "rotate-180" : ""
}`}
/>
</span>
)}
</div>
{/* 서브 탭들 */}
{isExpanded && (
<div className="ml-4 space-y-1 pb-2 animate-fade-in-fast">
{tab.submenu.map((item) => (
<Link
key={item.href}
href={item.href}
className="block py-3 text-base text-gray-700 hover:bg-gray-50 rounded-lg px-3"
onClick={() => setIsMenuOpen(false)}
>
{item.label}
</Link>
))}
</div>
)}
</div>
);
})}
</div>
{/* <div className="mt-4 text-center pb-6">
<AuthButton shouldShowScrolled={true} onLinkClick={() => setIsMenuOpen(false)} />
</div> */}
</div>
</div>
)}
</nav>
</header>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import React from "react";
import Link from "next/link";
import Image from "next/image";
import { usePathname } from "next/navigation";
import { getTabInfo } from "@/lib/tabs";
const SubNavbar: React.FC = () => {
const pathname = usePathname();
const tabInfo = getTabInfo(pathname);
if (!tabInfo) return null;
return (
<div>
{/* 이미지 안 (Navbar 제외) */}
<div className="relative w-full h-[200px] smalltablet:h-[300px] pc:h-[500px]">
{/* 백그라운드 이미지 */}
{tabInfo.image && (
<Image
src={tabInfo.image}
alt="Banner Image"
fill
style={{ objectFit: "cover" }}
// placeholder="blur"
/>
)}
{/* 검은색 오버레이 */}
<div className="absolute inset-0 bg-black opacity-50" />
{/* 이미지 한가운데 제목 */}
<div className="font-bold flex items-center justify-center absolute inset-0 text-white text-4xl smalltablet:text-6xl pc:text-7xl">
{tabInfo.title}
</div>
{/* 이미지 안쪽 중 가장 아래에 있는 탭 바 */}
<div className="backdrop-blur-sm absolute bottom-0 left-0 right-0 items-center justify-center hidden smalltablet:flex">
{tabInfo.tab.submenu.map((item, subIndex) => (
<Link key={subIndex} href={item.href}>
<div
className={`px-10 pc:px-20 py-4 text-base pc:text-xl font-semibold transition-all duration-300
${item.href === pathname ? "text-gray-700 bg-white" : "text-white hover:bg-gray-50 hover:bg-opacity-10 hover:text-gray-700"}`}
>
{item.label}
</div>
</Link>
))}
</div>
</div>
{/* 이미지 밖 */}
<div className="font-bold text-center px-4 smalltablet:px-8 pc:px-12 mt-6 smalltablet:mt-10 pc:mt-12">
{/* 영어 제목 */}
<div className="text-blue-500 text-sm smalltablet:text-base pc:text-lg">
{tabInfo.subtitleEnglish}
</div>
{/* 한글 제목 */}
<div className="text-4xl smalltablet:text-4xl pc:text-6xl">
{tabInfo.subtitle}
</div>
{/* 설명 */}
{tabInfo.description && (
<div className="text-gray-600 text-base smalltablet:text-lg pc:text-xl font-normal mt-4 smalltablet:mt-6">
{tabInfo.description}
</div>
)}
</div>
</div>
);
};
export default SubNavbar;