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:
186
nextjs/components/FileUpload.tsx
Normal file
186
nextjs/components/FileUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
286
nextjs/components/ImageUpload.tsx
Normal file
286
nextjs/components/ImageUpload.tsx
Normal 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;
|
||||
}
|
||||
87
nextjs/components/Pagination.tsx
Normal file
87
nextjs/components/Pagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
nextjs/components/SignedImage.tsx
Normal file
70
nextjs/components/SignedImage.tsx
Normal 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;
|
||||
|
||||
132
nextjs/components/auth/LoginForm.tsx
Normal file
132
nextjs/components/auth/LoginForm.tsx
Normal 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;
|
||||
|
||||
288
nextjs/components/auth/SignUpForm.tsx
Normal file
288
nextjs/components/auth/SignUpForm.tsx
Normal 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;
|
||||
|
||||
72
nextjs/components/landing/Contact.tsx
Normal file
72
nextjs/components/landing/Contact.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
nextjs/components/landing/FAQ.tsx
Normal file
56
nextjs/components/landing/FAQ.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
144
nextjs/components/landing/Hero.tsx
Normal file
144
nextjs/components/landing/Hero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
nextjs/components/landing/Intro.tsx
Normal file
77
nextjs/components/landing/Intro.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
59
nextjs/components/landing/Ministries.tsx
Normal file
59
nextjs/components/landing/Ministries.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
211
nextjs/components/landing/NewsAndGallery.tsx
Normal file
211
nextjs/components/landing/NewsAndGallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
nextjs/components/landing/ServiceTimes.tsx
Normal file
117
nextjs/components/landing/ServiceTimes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
82
nextjs/components/landing/Welcome.tsx
Normal file
82
nextjs/components/landing/Welcome.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
11
nextjs/components/providers/SessionProvider.tsx
Normal file
11
nextjs/components/providers/SessionProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
178
nextjs/components/seo/JsonLd.tsx
Normal file
178
nextjs/components/seo/JsonLd.tsx
Normal 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} />
|
||||
}
|
||||
|
||||
77
nextjs/components/seo/MetaTags.tsx
Normal file
77
nextjs/components/seo/MetaTags.tsx
Normal 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;
|
||||
|
||||
81
nextjs/components/widgets/AuthButton.tsx
Normal file
81
nextjs/components/widgets/AuthButton.tsx
Normal 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;
|
||||
|
||||
52
nextjs/components/widgets/Footer.tsx
Normal file
52
nextjs/components/widgets/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
243
nextjs/components/widgets/Header.tsx
Normal file
243
nextjs/components/widgets/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
76
nextjs/components/widgets/SubNavbar.tsx
Normal file
76
nextjs/components/widgets/SubNavbar.tsx
Normal 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;
|
||||
|
||||
Reference in New Issue
Block a user