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

@@ -2,7 +2,7 @@ name: Build Docker Image
on:
push:
branches: [main]
branches: [main, develop]
tags:
- 'v*'
workflow_dispatch:
@@ -13,15 +13,11 @@ env:
jobs:
build-and-push:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
permissions:
contents: write
contents: read
packages: write
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
image-digest: ${{ steps.build.outputs.digest }}
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -34,7 +30,7 @@ jobs:
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
password: ${{ secrets.CR_PAT }}
- name: Lowercase repository name
id: lowercase
@@ -48,110 +44,26 @@ jobs:
images: ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.repo }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix={{branch}}-sha-,format=long
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
id: build
uses: docker/build-push-action@v5
with:
context: ./services/nextjs
file: ./deploy/docker/Dockerfile.prod
context: ./nextjs
file: ./Dockerfile
push: true
platforms: linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Extract SHA tag
id: extract-tag
run: |
# Extract the SHA-based tag from the tags list
TAGS="${{ steps.meta.outputs.tags }}"
echo "All tags:"
echo "$TAGS"
echo "---"
# Get commit SHA (full 40 characters)
COMMIT_SHA="${{ github.sha }}"
# Method 1: Extract the full SHA tag from docker/metadata-action output
# docker/metadata-action creates: main-sha-<full-40-char-sha>
SHA_TAG=$(echo "$TAGS" | grep -oE 'main-sha-[a-f0-9]{40}' | head -n 1)
# Method 2: If not found, try to extract any main-sha- tag (fallback)
if [ -z "$SHA_TAG" ]; then
SHA_TAG=$(echo "$TAGS" | grep -oE 'main-sha-[a-f0-9]+' | head -n 1)
if [ -n "$SHA_TAG" ]; then
echo "⚠️ Found SHA tag (may not be full 40 chars): $SHA_TAG"
fi
fi
# Method 3: Fallback to commit SHA directly (construct the tag)
if [ -z "$SHA_TAG" ]; then
SHA_TAG="main-sha-$COMMIT_SHA"
echo "⚠️ Could not extract from tags, using commit SHA: $SHA_TAG"
fi
if [ -z "$SHA_TAG" ]; then
echo "❌ ERROR: Failed to extract SHA tag"
exit 1
fi
echo "sha-tag=$SHA_TAG" >> $GITHUB_OUTPUT
echo "✅ Extracted SHA tag: $SHA_TAG"
- name: Update kustomization with new image tag
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
# Validate that SHA_TAG is not empty
SHA_TAG="${{ steps.extract-tag.outputs.sha-tag }}"
if [ -z "$SHA_TAG" ]; then
echo "❌ ERROR: SHA_TAG is empty, cannot update kustomization"
exit 1
fi
echo "📝 Updating kustomization.yaml with tag: $SHA_TAG"
# Update kustomization.yaml with new image tag
# Handle both cases: newTag: (with value) and newTag: (empty)
sed -i.bak "s|newTag:.*|newTag: $SHA_TAG|" deploy/k8s/overlays/prod/kustomization.yaml
# Verify the update was successful
if grep -q "newTag: $SHA_TAG" deploy/k8s/overlays/prod/kustomization.yaml; then
echo "✅ Successfully updated kustomization.yaml"
rm -f deploy/k8s/overlays/prod/kustomization.yaml.bak
else
echo "❌ ERROR: Failed to update kustomization.yaml"
cat deploy/k8s/overlays/prod/kustomization.yaml
exit 1
fi
# Commit and push if there are changes
if git diff --quiet; then
echo "No changes to commit"
else
git add deploy/k8s/overlays/prod/kustomization.yaml
git commit -m "Update image to $SHA_TAG"
git push
echo "✅ Kustomization updated with new image tag: $SHA_TAG"
fi
build-args: |
NEXT_PUBLIC_KAKAO_MAP_KEY=${{ secrets.NEXT_PUBLIC_KAKAO_MAP_KEY }}
- name: Display image information
run: |
echo "Image built and pushed successfully!"
echo "📦 Image tags:"
echo "Image built and pushed successfully!"
echo "Image tags:"
echo "${{ steps.meta.outputs.tags }}"
echo "🔖 SHA tag: ${{ steps.extract-tag.outputs.sha-tag }}"
echo "🔖 Digest: ${{ steps.build.outputs.digest }}"
echo ""
echo "🚀 Kustomization updated with new image tag"
echo " ArgoCD will automatically detect and deploy this new image"
echo " Monitor deployment at your ArgoCD dashboard"

View File

@@ -19,24 +19,25 @@ jobs:
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: services/nextjs/package-lock.json
cache-dependency-path: nextjs/package-lock.json
- name: Install dependencies
working-directory: services/nextjs
working-directory: nextjs
run: npm ci
- name: Run ESLint
working-directory: services/nextjs
working-directory: nextjs
run: npm run lint
- name: Build Next.js application
working-directory: services/nextjs
working-directory: nextjs
run: npm run build
env:
NEXT_TELEMETRY_DISABLED: 1
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- name: Check build output
working-directory: services/nextjs
working-directory: nextjs
run: |
if [ ! -d ".next" ]; then
echo "Build failed: .next directory not found"

20
.gitignore vendored
View File

@@ -1,8 +1,7 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
services/nextjs/node_modules
nextjs/node_modules
.pnp
.pnp.*
.yarn/*
@@ -15,12 +14,11 @@ services/nextjs/node_modules
coverage
# next.js
services/nextjs/.next/
.next/
/out/
nextjs/.next/
nextjs/out/
# production
/build
nextjs/build
# misc
.DS_Store
@@ -41,13 +39,13 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
nextjs/tsconfig.tsbuildinfo
nextjs/next-env.d.ts
# prisma
/prisma/dev.db
/prisma/dev.db-journal
/prisma/*.db
/prisma/*.db-journal
nextjs/prisma/client
nextjs/prisma/*.db
nextjs/prisma/*.db-journal
# IDE
.vscode

View File

@@ -23,6 +23,9 @@ ARG DATABASE_URL="mysql://build:build@localhost:3306/build"
RUN npx prisma generate
# Build the application
# Next.js NEXT_PUBLIC_* variables must be available at build time
ARG NEXT_PUBLIC_KAKAO_MAP_KEY
ENV NEXT_PUBLIC_KAKAO_MAP_KEY=$NEXT_PUBLIC_KAKAO_MAP_KEY
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

View File

@@ -1,42 +0,0 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: jaejadle
namespace: argocd
annotations:
argocd-image-updater.argoproj.io/image-list: jaejadle=ghcr.io/mayne0213/jaejadle
argocd-image-updater.argoproj.io/jaejadle.update-strategy: latest
argocd-image-updater.argoproj.io/write-back-method: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: https://github.com/Mayne0213/jaejadle.git
targetRevision: main
path: deploy/k8s/overlays/prod
destination:
server: https://kubernetes.default.svc
namespace: jaejadle
syncPolicy:
automated:
prune: true # 매니페스트에서 제거된 리소스 자동 삭제
selfHeal: true # 클러스터에서 수동 변경 시 자동 복구
allowEmpty: false
syncOptions:
- CreateNamespace=true # namespace가 없으면 자동 생성
- PrunePropagationPolicy=foreground
- PruneLast=true
retry:
limit: 5
backoff:
duration: 5s
factor: 2
maxDuration: 3m
revisionHistoryLimit: 10

View File

@@ -1,29 +0,0 @@
# trunk-ignore-all(checkov/CKV_DOCKER_3)
FROM node:20-alpine AS base
# Install dependencies for development
RUN apk add --no-cache libc6-compat curl
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
# Install all dependencies (including dev dependencies)
RUN npm ci
# Copy source code
COPY . .
# Generate Prisma Client
RUN npx prisma generate
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000/api/health || exit 1
# Default command (can be overridden in docker-compose)
CMD ["npm", "run", "dev"]

View File

@@ -1,64 +0,0 @@
services:
# Next.js Application (Development) - Using External Database
app:
image: jaejadle-app-dev
build:
context: ../../services/nextjs
dockerfile: ../../deploy/docker/Dockerfile.dev
container_name: jaejadle-app-dev
restart: unless-stopped
labels:
kompose.namespace: jaejadle-dev
ports:
- "3004:3000"
env_file:
- ../../.env
environment:
- NODE_ENV=development
networks:
- jaejadle-network-dev
volumes:
- ../../services/nextjs:/app
- /app/node_modules
- /app/.next
- app_logs_dev:/app/logs
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
command: >
sh -lc "npx prisma generate && npx prisma db push && npm run dev"
# Prisma Studio - Connects to External Database
prisma-studio:
image: jaejadle-app-dev
container_name: jaejadle-prisma-studio
restart: unless-stopped
labels:
kompose.namespace: jaejadle-dev
ports:
- "5557:5555"
env_file:
- ../../.env
environment:
- NODE_ENV=development
networks:
- jaejadle-network-dev
volumes:
- ../../services/nextjs:/app
- /app/node_modules
command: npx prisma studio --port 5555 --hostname 0.0.0.0
volumes:
# Named volumes for data persistence
app_logs_dev:
driver: local
networks:
jaejadle-network-dev:
driver: bridge
ipam:
config:
- subnet: 172.25.0.0/16

View File

@@ -1,39 +0,0 @@
services:
# Next.js Application - Using External Database
app:
image: jaejadle-app
build:
context: ../../services/nextjs
dockerfile: ../../deploy/docker/Dockerfile.prod
container_name: jaejadle-app
restart: unless-stopped
labels:
kompose.namespace: jaejadle
ports:
- 3004:3000
env_file:
- ../../.env
environment:
- NODE_ENV=production
networks:
- jaejadle-network
volumes:
- app_logs:/app/logs
healthcheck:
test: [CMD, curl, -f, http://localhost:3000/api/health]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
# Named volumes for data persistence
app_logs:
driver: local
networks:
jaejadle-network:
driver: bridge
ipam:
config:
- subnet: 172.24.0.0/16

View File

@@ -1,86 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: jaejadle-app
labels:
app: jaejadle-app
spec:
replicas: 1
selector:
matchLabels:
app: jaejadle-app
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
maxSurge: 1
template:
metadata:
labels:
app: jaejadle-app
spec:
containers:
- name: jaejadle-app
image: ghcr.io/mayne0213/jaejadle:latest
imagePullPolicy: Always
ports:
- containerPort: 3000
protocol: TCP
env:
- name: NODE_ENV
value: production
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: jaejadle-secret
key: jwt-secret
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: jaejadle-secret
key: database-url
- name: AWS_REGION
valueFrom:
secretKeyRef:
name: jaejadle-secret
key: aws-region
- name: AWS_S3_BUCKET_NAME
valueFrom:
secretKeyRef:
name: jaejadle-secret
key: aws-s3-bucket-name
- name: AWS_S3_BUCKET_URL
valueFrom:
secretKeyRef:
name: jaejadle-secret
key: aws-s3-bucket-url
- name: AWS_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: jaejadle-secret
key: aws-access-key-id
- name: AWS_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: jaejadle-secret
key: aws-secret-access-key
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "300m"
livenessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
restartPolicy: Always

View File

@@ -1,14 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
commonLabels:
app.kubernetes.io/name: jaejadle
app.kubernetes.io/component: web
images:
- name: ghcr.io/mayne0213/jaejadle
newTag: latest

View File

@@ -1,15 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: jaejadle-service
labels:
app: jaejadle-app
spec:
type: ClusterIP
ports:
- name: http
port: 80
targetPort: 3000
protocol: TCP
selector:
app: jaejadle-app

View File

@@ -1,19 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: jaejadle-app
labels:
environment: production
spec:
replicas: 1
template:
spec:
containers:
- name: jaejadle-app
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "300m"

View File

@@ -1,19 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: jaejadle
resources:
- ../../base
- resourcequota.yaml
commonLabels:
environment: production
# 이미지 태그 설정
images:
- name: ghcr.io/mayne0213/jaejadle
newTag: latest
patchesStrategicMerge:
- deployment-patch.yaml

View File

@@ -1,12 +0,0 @@
apiVersion: v1
kind: ResourceQuota
metadata:
name: jaejadle-quota
namespace: jaejadle
spec:
hard:
requests.memory: "512Mi"
requests.cpu: "300m"
limits.memory: "1Gi"
limits.cpu: "600m"
pods: "3"

View File

@@ -0,0 +1,23 @@
import React from "react";
import LoginForm from "@/components/auth/LoginForm";
export default function LoginPage() {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6 relative">
<div className="w-full max-w-md relative z-10">
{/* Header Section */}
<div className="text-center mb-8">
<h1 className="text-4xl text-gray-900 mb-2 font-black tracking-tight">
</h1>
<p className="text-gray-600 font-semibold">
</p>
</div>
<LoginForm />
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import React from "react";
import SignUpForm from "@/components/auth/SignUpForm";
export default function SignUpPage() {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6 relative">
<div className="w-full max-w-md relative z-10 py-12">
{/* Header Section */}
<div className="text-center mb-8">
<h1 className="text-4xl text-gray-900 mb-2 font-black tracking-tight">
</h1>
<p className="text-gray-600 font-semibold">
</p>
</div>
<SignUpForm />
{/* Bottom Text */}
<div className="text-center mt-6">
<p className="text-xs text-gray-500">
{" "}
<button className="text-[#6b95c6] hover:underline font-medium">
</button>
{" "}
<button className="text-[#6b95c6] hover:underline font-medium">
</button>
.
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { Metadata } from 'next';
export const metadata: Metadata = {
title: '오시는 길',
description: '제자들교회 오시는 길 안내입니다. 주소: 인천광역시 서구 고산후로 95번길 32 명진프라자 3층 본당 / 4층 교육관',
openGraph: {
title: '오시는 길 | 제자들교회',
description: '제자들교회 오시는 길 - 인천광역시 서구 고산후로 95번길 32',
},
};
export default function DirectionsLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@@ -0,0 +1,125 @@
'use client';
import { useState } from 'react';
import { MapPin } from 'lucide-react';
import { useKakaoLoader, Map, MapMarker, CustomOverlayMap } from 'react-kakao-maps-sdk';
// 제자들교회 좌표 (인천광역시 서구 고산후로 95번길 32)
const CHURCH_LOCATION = {
lat: 37.592754772,
lng: 126.695602263,
name: '제자들교회',
};
export default function DirectionsPage() {
const [isOpen, setIsOpen] = useState(true);
const kakaoMapKey = process.env.NEXT_PUBLIC_KAKAO_MAP_KEY || '';
const [loading, error] = useKakaoLoader({
appkey: kakaoMapKey,
});
return (
<div className="bg-white w-full">
<div className="max-w-7xl mx-auto space-y-8 smalltablet:space-y-12 px-4 py-6 smalltablet:py-10">
{/* 지도 영역 */}
<div className="w-full h-64 smalltablet:h-96 pc:h-[480px] rounded-xl overflow-hidden shadow-lg">
{loading ? (
<div className="w-full h-full flex items-center justify-center bg-gray-100 rounded-xl">
<p className="text-gray-500"> ...</p>
</div>
) : error ? (
<div className="w-full h-full flex items-center justify-center bg-gray-100 rounded-xl">
<p className="text-red-500"> .</p>
</div>
) : (
<Map
center={{ lat: CHURCH_LOCATION.lat, lng: CHURCH_LOCATION.lng }}
style={{ width: '100%', height: '100%' }}
level={2}
>
<MapMarker
position={{ lat: CHURCH_LOCATION.lat, lng: CHURCH_LOCATION.lng }}
clickable={true}
onClick={() => setIsOpen(!isOpen)}
/>
{isOpen && (
<CustomOverlayMap
position={{ lat: CHURCH_LOCATION.lat, lng: CHURCH_LOCATION.lng }}
yAnchor={2.3}
>
<div className="bg-white px-3 py-2 rounded-lg shadow-lg border border-gray-200">
<div className="text-sm font-bold text-gray-900">
{CHURCH_LOCATION.name}
</div>
</div>
</CustomOverlayMap>
)}
</Map>
)}
</div>
{/* 교회 정보 카드 */}
<div className="flex flex-col smalltablet:flex-row smalltablet:justify-between smalltablet:items-center gap-4 smalltablet:gap-0 pb-6 px-2 smalltablet:px-4 border-b border-gray-400">
<h2 className="text-2xl smalltablet:text-3xl pc:text-4xl font-bold text-gray-900"> - </h2>
<div className="flex flex-col smalltablet:flex-row gap-3 smalltablet:gap-8 pc:gap-12">
<div className="flex items-center gap-2 smalltablet:gap-3 text-sm smalltablet:text-base pc:text-xl font-bold">
<span className="inline-flex items-center justify-center w-6 h-6 smalltablet:w-8 smalltablet:h-8 rounded-full bg-blue-50 text-blue-600">
<MapPin className="w-4 h-4" />
</span>
<span className="text-gray-800">주소 : 인천광역시 95 32 3 / 4 </span>
</div>
</div>
</div>
{/* <div className="pb-8 space-y-8 md:space-y-10">
<div className="flex flex-col gap-4 md:gap-6 md:flex-row md:items-center">
<div className="flex flex-row md:flex-col justify-start md:justify-center items-center gap-3 md:w-32 lg:w-40 md:border-r md:border-gray-500 md:mr-4 pb-4 md:pb-0 border-b md:border-b-0 border-gray-300">
<Bus className="w-12 h-12 md:w-14 md:h-14 lg:w-16 lg:h-16 text-gray-700" />
<p className="text-sm md:text-base font-semibold text-gray-700">버스 이용 시</p>
</div>
<div className="space-y-2 md:space-y-3 text-gray-700 flex-1">
<p className="text-sm md:text-base lg:text-lg">제자들교회 앞 정류장 : 102, 550, 720, 마을3</p>
<p className="text-sm md:text-base lg:text-lg">
제자들교회 사거리 정류장 : 급행11, 간선21, 환승5
</p>
<p className="text-sm md:text-base lg:text-lg mb-4 md:mb-8">
중앙시장 환승센터 하차 후 도보 5분 &mdash; 교회까지 직진 후 우회전
</p>
<p className="text-blue-600 font-bold text-sm md:text-base">광역버스 이용 시</p>
<p className="text-gray-700 text-sm md:text-base">
인천종합터미널에서 8800번 광역버스를 타고 제자들교회 앞 정류장에서 하차하세요. 하차 후 교회까지 도보 3분입니다.
</p>
</div>
</div>
<div className="border-t border-gray-200" />
<div className="flex flex-col gap-4 md:gap-6 md:flex-row md:items-center">
<div className="flex flex-row md:flex-col justify-start md:justify-center items-center gap-3 md:w-32 lg:w-40 md:border-r md:border-gray-500 md:mr-4 pb-4 md:pb-0 border-b md:border-b-0 border-gray-300">
<Train className="w-12 h-12 md:w-14 md:h-14 lg:w-16 lg:h-16 text-gray-700" />
<p className="text-sm md:text-base font-semibold text-gray-700">지하철 이용 시</p>
</div>
<div className="space-y-2 md:space-y-3 text-gray-700 flex-1">
<p className="text-sm md:text-base lg:text-lg">인천 1호선 `제자들역` 3번 출구 도보 7분</p>
<p className="text-sm md:text-base lg:text-lg">
3번 출구로 나와 첫 번째 사거리에서 좌회전한 뒤 300m 직진하면 교회가 보입니다.
</p>
<p className="text-sm md:text-base lg:text-lg mb-4 md:mb-8">
공항철도 `신제자역` 하차 후 2번 출구 → 마을버스 3번 환승 → 제자들교회 앞 정류장 하차
</p>
<p className="text-blue-600 font-bold text-sm md:text-base">KTX 연계 이용 시</p>
<p className="text-gray-700 text-sm md:text-base">
광명역에서 공항철도 환승 후 `신제자역`까지 이동한 뒤, 2번 출구 마을버스 3번을 이용하면 약 40분 소요됩니다.
</p>
</div>
</div>
</div> */}
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { Metadata } from 'next';
import Image from 'next/image';
import person from '@/public/subpages/about/greetings/person.webp';
export const metadata: Metadata = {
title: '담임목사 인사말',
description: '제자들교회 담임목사 김경한 목사님의 인사말입니다. 세상이 줄 수 없는 놀라운 위로와 사랑을 온전히 누리며 전하는 공동체입니다.',
openGraph: {
title: '담임목사 인사말 | 제자들교회',
description: '제자들교회 담임목사 김경한 목사님의 인사말입니다.',
},
};
export default function GreetingPage() {
return (
<div className="w-full relative overflow-hidden">
<div className="max-w-7xl mx-auto px-4 pb-6 pt-10">
{/* 인사말 섹션 */}
<div className="grid pc:grid-cols-7 gap-8 items-start">
{/* 왼쪽: 인사말 (5/7) */}
<div className="space-y-6 text-gray-700 leading-relaxed pc:col-span-4 text-center pc:text-left">
<p className="text-2xl pc:text-3xl font-bold mb-2 smalltablet:mb-4 pc:mb-8">
<span className="text-[#6d96c5] block smalltablet:text-4xl pc:text-5xl">Welcome Home!</span>
</p>
<p className="text-sm pc:text-base">
{`제자들교회를 찾아주셔서 감사드립니다.
이 땅의 유일한 구주되신 예수님은
상처와 교만, 실패와 낙망으로 얼룩진
저희들의 인생에 찾아오셔서
영생과 함께 참된 기쁨과 소망을 주셨습니다.`}
</p>
<p className="text-sm pc:text-base">
{`광야와 같은 세상 속에서
때로는 숨쉬기조차 어려운 하루하루지만,
이곳에서 함께 만날 예수님은
저희들의 닫힌 숨을 다시 열어주시는
위로자가 되어 주실 겁니다.`}
</p>
<p className="text-sm pc:text-base">
{`제자들교회는 세상이 줄 수 없는
그 놀라운 위로와 사랑을
온전히 누리며 전하는 공동체 입니다.
이 놀라운 믿음의 여정을 함께 걸어가기를 소망합니다.`}
</p>
{/* 담임목사 이름 */}
<div className="mt-8 text-center pc:text-left">
<p className="text-xl pc:text-2xl font-semibold text-gray-800"> </p>
</div>
</div>
{/* 오른쪽: 이미지 (2/7) */}
<div className="pc:col-span-3">
<div className="relative h-[300px] pc:h-[400px] rounded-xl overflow-hidden">
<Image
src={person}
alt="담임목사"
fill
className="object-cover"
/>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { Metadata } from 'next';
import Image from 'next/image';
import leader1 from '@/public/subpages/about/leaders/1.webp';
export const metadata: Metadata = {
title: '교역자 및 직분자',
description: '제자들교회의 교역자와 직분자를 소개합니다. 담임목사, 부목사, 전도사, 장로들이 함께 섬기고 있습니다.',
openGraph: {
title: '교역자 및 직분자 | 제자들교회',
description: '제자들교회의 교역자와 직분자 소개',
},
};
import leader2 from '@/public/subpages/about/leaders/2.webp';
import leader3 from '@/public/subpages/about/leaders/3.webp';
import leader4 from '@/public/subpages/about/leaders/4.webp';
import leader5 from '@/public/subpages/about/leaders/5.webp';
import leader6 from '@/public/subpages/about/leaders/6.webp';
import leader7 from '@/public/subpages/about/leaders/7.webp';
import leader8 from '@/public/subpages/about/leaders/8.webp';
import leader9 from '@/public/subpages/about/leaders/9.webp';
const LEADER_CATEGORIES = [
{
title: '교역자',
color: '#6d96c5',
members: [
{ name: '김경한', title: '담임목사', image: leader1 },
{ name: '김종범', title: '부목사', image: leader2 },
{ name: '최하영', title: '부목사', image: leader3 },
{ name: '김윤영', title: '전도사', image: leader4 },
{ name: '설희보', title: '전도사', image: leader5 },
{ name: '서영리', title: '협력전도사', image: leader6 },
],
},
{
title: '장로',
color: '#94b7d6',
members: [
{ name: '김정태', title: '명예 장로', image: leader7 },
{ name: '안종웅', title: '장로', image: leader8 },
{ name: '김현종', title: '장로', image: leader9 },
],
},
];
export default function LeadersPage() {
return (
<div className="bg-white w-full">
<div className="py-8 smalltablet:py-12 pc:py-16 px-4 smalltablet:px-6 pc:px-8">
<div className="max-w-5xl mx-auto">
{LEADER_CATEGORIES.map((category, categoryIndex) => (
<div key={categoryIndex} className="mb-12 smalltablet:mb-16 pc:mb-20">
{/* 섹션 헤더 */}
<div className="text-center mb-8 smalltablet:mb-10 pc:mb-12">
<div
className={`w-12 smalltablet:w-14 pc:w-16 h-1 mx-auto mb-3 smalltablet:mb-4 bg-[${category.color}]`}
/>
<h2 className="text-2xl smalltablet:text-3xl pc:text-4xl font-bold text-gray-900">
{category.title}
</h2>
</div>
{/* 멤버 그리드 */}
<div className="grid grid-cols-2 smalltablet:grid-cols-3 gap-4 smalltablet:gap-6 pc:gap-8">
{category.members.map((member, memberIndex) => (
<div
key={memberIndex}
className="group bg-white rounded-xl smalltablet:rounded-2xl overflow-hidden border border-gray-300 hover:shadow-xl transition-all duration-300 hover:-translate-y-1"
>
{/* 프로필 이미지 */}
<div className="relative aspect-3/4 bg-gray-100 overflow-hidden">
<Image
src={member.image}
alt={member.name}
fill
className="object-cover group-hover:scale-105 transition-transform duration-500"
/>
{/* 그라데이션 오버레이 */}
<div className="absolute inset-0 bg-linear-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
</div>
{/* 정보 */}
<div className="p-3 smalltablet:p-4 pc:p-5 text-center">
<h3 className="text-lg smalltablet:text-xl pc:text-2xl font-bold text-gray-900 mb-1">
{member.name}
</h3>
<p
className="text-sm smalltablet:text-base pc:text-lg font-medium"
style={{ color: category.color }}
>
{member.title}
</p>
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,228 @@
import { Metadata } from 'next';
import React from 'react';
import Image from 'next/image';
import { Target, BookOpen, HandHeart, Sprout } from 'lucide-react';
import logo from '@/public/logo.webp';
export const metadata: Metadata = {
title: '교회 비전',
description: '제자들교회의 사훈과 사명을 소개합니다. 말씀 안에, 서로 사랑, 많은 열매를 맺는 성경적 제자도 공동체입니다.',
openGraph: {
title: '교회 비전 | 제자들교회',
description: '제자들교회의 사훈과 사명 - 말씀 안에, 서로 사랑, 많은 열매',
},
};
const SAHUN_DATA = [
{
number: 1,
title: '말씀 안에',
englishTitle: 'In the Word',
description: '하나님의 말씀을 삶의 중심에 두고\n성경적 가치관으로 살아가는\n믿음의 공동체',
Icon: BookOpen,
},
{
number: 2,
title: '서로 사랑',
englishTitle: 'Love One Another',
description: '그리스도의 사랑으로 서로를 섬기고\n하나됨을 이루어가는\n사랑의 공동체',
Icon: HandHeart,
},
{
number: 3,
title: '많은 열매',
englishTitle: 'Abundant Fruit',
description: '복음의 능력으로 영혼을 구원하고\n생명의 열매를 풍성히 맺는\n선교의 공동체',
Icon: Sprout,
},
];
const CHURCH_SINJO_LEFT = [
{
number: '01',
title: '말씀으로 살아가는 제자들교회',
englishTitle: 'Living by the Word',
subtitle: '하나님의 말씀으로',
Icon: BookOpen,
color: '#a9c6e1',
},
{
number: '03',
title: '복음전도와 선교를 위해 존재하는 제자들교회',
englishTitle: 'For Evangelism and Mission',
subtitle: '복음의 능력으로',
Icon: Target,
color: '#6d96c5',
},
];
const CHURCH_SINJO_RIGHT = [
{
number: '02',
title: '서로 사랑하는 제자들교회',
englishTitle: 'Loving One Another',
subtitle: '그리스도의 사랑으로',
Icon: HandHeart,
color: '#94b7d6',
},
{
number: '04',
title: '복음으로 변화되는 제자들교회',
englishTitle: 'Transformed by Gospel',
subtitle: '생명의 능력으로',
Icon: Sprout,
color: '#88aad2',
},
];
export default function VisionPage() {
return (
<div className="min-h-screen bg-white w-full">
<div className="py-8 smalltablet:py-12 pc:py-16 px-4 smalltablet:px-6 pc:px-8">
<div className="max-w-7xl mx-auto">
{/* 사훈 섹션 */}
<div className="mb-12 smalltablet:mb-16 pc:mb-20">
<div className="grid grid-cols-1 smalltablet:grid-cols-3 gap-6 smalltablet:gap-4 pc:gap-8">
{SAHUN_DATA.map((item, index, array) => (
<div key={index} className="relative">
<div className={`h-full flex flex-col items-center ${
index < array.length - 1 ? 'smalltablet:border-r-2 border-gray-200' : ''
}`}>
<div className="relative w-32 h-32 smalltablet:w-28 smalltablet:h-28 pc:w-40 pc:h-40 mb-4 smalltablet:mb-4 pc:mb-6">
<div className="w-full h-full bg-gray-100 rounded-full flex items-center justify-center text-gray-700">
<item.Icon className="w-16 h-16 smalltablet:w-14 smalltablet:h-14 pc:w-20 pc:h-20" strokeWidth={1} />
</div>
<div className="absolute -top-2 -left-2 w-10 h-10 smalltablet:w-9 smalltablet:h-9 pc:w-12 pc:h-12 bg-[#94b7d6] rounded-full flex items-center justify-center text-white font-bold text-lg smalltablet:text-base pc:text-xl shadow-lg">
{item.number}
</div>
</div>
<h3 className="text-xl smalltablet:text-lg pc:text-2xl font-bold text-gray-900 mb-1 text-center px-2">
{item.title}
</h3>
<p className="text-xs smalltablet:text-[10px] pc:text-sm text-gray-400 mb-2 smalltablet:mb-3 pc:mb-4 text-center uppercase tracking-wide">
{item.englishTitle}
</p>
<p className="text-sm smalltablet:text-xs pc:text-base text-gray-600 text-center whitespace-pre-line leading-relaxed px-4 smalltablet:px-2 pc:px-4">
{item.description}
</p>
</div>
</div>
))}
</div>
</div>
{/* 교회 사명 섹션 */}
<div className="mb-12 smalltablet:mb-16 pc:mb-20">
<div className="text-center mb-8 smalltablet:mb-10 pc:mb-12">
<div className="w-12 smalltablet:w-14 pc:w-16 h-1 bg-[#6d96c5] mx-auto mb-3 smalltablet:mb-4"></div>
<h2 className="text-2xl smalltablet:text-3xl pc:text-4xl font-bold text-gray-900"> </h2>
</div>
<div className="relative">
{/* 중앙 다이아몬드 */}
<div className="hidden pc:flex absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-40 h-40 pc:w-48 pc:h-48 bg-linear-to-br from-[#6d96c5] to-[#94b7d6] rotate-45 items-center justify-center z-10 rounded-2xl pc:rounded-3xl shadow-2xl">
<div className="-rotate-45 text-white text-center">
<div className="text-4xl pc:text-5xl font-black">FAITH</div>
</div>
</div>
{/* 항목들 */}
<div className="grid grid-cols-1 smalltablet:grid-cols-2 gap-4 smalltablet:gap-6">
{/* 왼쪽 항목들 */}
<div className="space-y-4 smalltablet:space-y-6">
{CHURCH_SINJO_LEFT.map((item, index) => (
<div key={index} className="bg-white border border-gray-200 rounded-xl smalltablet:rounded-2xl p-4 smalltablet:p-6 hover:shadow-lg transition-all duration-300 flex items-center gap-3 smalltablet:gap-4">
<div className="shrink-0" style={{ color: item.color }}>
<item.Icon className="w-10 h-10 smalltablet:w-12 smalltablet:h-12" strokeWidth={1.5} />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-base smalltablet:text-lg font-bold text-gray-900 mb-1 wrap-break-words">
{item.title}
</h3>
{item.subtitle && (
<p className="text-xs smalltablet:text-sm text-gray-500">{item.subtitle}</p>
)}
</div>
</div>
))}
</div>
{/* 오른쪽 항목들 */}
<div className="space-y-4 smalltablet:space-y-6">
{CHURCH_SINJO_RIGHT.map((item, index) => (
<div key={index} className="bg-white border border-gray-200 rounded-xl smalltablet:rounded-2xl p-4 smalltablet:p-6 hover:shadow-lg transition-all duration-300 flex items-center gap-3 smalltablet:gap-4 smalltablet:flex-row-reverse">
<div className="shrink-0" style={{ color: item.color }}>
<item.Icon className="w-10 h-10 smalltablet:w-12 smalltablet:h-12" strokeWidth={1.5} />
</div>
<div className="flex-1 smalltablet:text-right min-w-0">
<h3 className="text-base smalltablet:text-lg font-bold text-gray-900 mb-1 wrap-break-words">
{item.title}
</h3>
{item.subtitle && (
<p className="text-xs smalltablet:text-sm text-gray-500">{item.subtitle}</p>
)}
</div>
</div>
))}
</div>
</div>
</div>
</div>
{/* 교회 심볼 소개 섹션 */}
<div>
<div className="text-center mb-8 smalltablet:mb-10 pc:mb-12">
<div className="w-12 smalltablet:w-14 pc:w-16 h-1 bg-[#6d96c5] mx-auto mb-3 smalltablet:mb-4"/>
<h2 className="text-2xl smalltablet:text-3xl pc:text-4xl font-bold text-gray-900"> </h2>
</div>
<div className="bg-[#a9c6e1] rounded-2xl smalltablet:rounded-3xl p-6 smalltablet:p-8 pc:p-10">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col pc:flex-row gap-6 smalltablet:gap-8">
{/* 로고 이미지 */}
<div className="shrink-0 mx-auto pc:mx-0">
<div className="w-64 h-64 smalltablet:w-80 smalltablet:h-80 pc:w-96 pc:h-96 bg-white rounded-xl smalltablet:rounded-2xl shadow-xl flex items-center justify-center p-8 smalltablet:p-10 pc:p-12">
<Image
src={logo}
alt="제자들교회 심볼"
width={320}
height={320}
className="object-contain w-full h-full"
/>
</div>
</div>
{/* 설명 */}
<div className="flex-1 flex flex-col gap-3 smalltablet:gap-4">
<div className="bg-white rounded-lg smalltablet:rounded-xl p-3 smalltablet:p-4 shadow-md flex-1 flex items-center gap-3 smalltablet:gap-4">
<p className="text-sm smalltablet:text-base pc:text-lg text-gray-700 leading-relaxed">
<span className="font-semibold text-[#6d96c5]"></span>
, .
</p>
</div>
<div className="bg-white rounded-lg smalltablet:rounded-xl p-3 smalltablet:p-4 shadow-md flex-1 flex items-center gap-3 smalltablet:gap-4">
<p className="text-sm smalltablet:text-base pc:text-lg text-gray-700 leading-relaxed">
<span className="font-semibold text-[#88aad2]"> </span>
, .
</p>
</div>
<div className="bg-white rounded-lg smalltablet:rounded-xl p-3 smalltablet:p-4 shadow-md flex-1 flex items-center gap-3 smalltablet:gap-4">
<p className="text-sm smalltablet:text-base pc:text-lg text-gray-700 leading-relaxed">
<span className="font-semibold text-[#94b7d6]"> </span>
, .
</p>
</div>
<div className="bg-white rounded-lg smalltablet:rounded-xl p-3 smalltablet:p-4 shadow-md flex-1 flex items-center gap-3 smalltablet:gap-4">
<p className="text-sm smalltablet:text-base pc:text-lg text-gray-700 leading-relaxed">
<span className="font-semibold text-[#a9c6e1]"> </span>
, .
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,422 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import Image from 'next/image';
import { useParams } from 'next/navigation';
import { swapDiscipleVideos, type DiscipleVideo } from '@/lib/services';
import { useAuth } from '@/hooks';
import { extractYouTubeId, getYouTubeThumbnailUrl } from '@/lib/utils/youtube';
import { ArrowUp, ArrowDown } from 'lucide-react';
// Stage별 제목 매핑
const STAGE_TITLES: { [key: string]: string } = {
'new-family': '새가족반',
'basic': '기초양육반',
'disciple': '제자훈련반',
'evangelism': '전도훈련반',
'ministry': '사역훈련반',
};
// 제자훈련반 Step 목록
const DISCIPLE_STEPS = [
'1단계 - 십자가',
'2단계 - 영적전투',
'3단계 - 하나님 나라',
];
export default function SystemStagePage() {
const params = useParams();
const stage = params.stage as string;
const playerRef = useRef<HTMLDivElement | null>(null);
const stageTitle = STAGE_TITLES[stage] || '제자화 시스템';
const isDisciple = stage === 'disciple';
const [videos, setVideos] = useState<DiscipleVideo[]>([]);
const [selectedVideo, setSelectedVideo] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [newVideoUrl, setNewVideoUrl] = useState('');
const [addingStep, setAddingStep] = useState<string | null>(null);
const { user } = useAuth();
const loadVideos = useCallback(async () => {
try {
const response = await fetch(`/api/disciple-videos?stage=${stage}`);
if (!response.ok) throw new Error('Failed to fetch videos');
const result = await response.json();
const dbVideos: DiscipleVideo[] = result.data || [];
setVideos(dbVideos);
// 첫 번째 비디오 선택
if (dbVideos.length > 0) {
setSelectedVideo(dbVideos[0].videoUrl);
}
setIsLoading(false);
} catch (error) {
console.error('Error loading videos:', error);
setIsLoading(false);
}
}, [stage]);
useEffect(() => {
loadVideos();
}, [loadVideos]);
const handleDelete = async (video: DiscipleVideo, e: React.MouseEvent) => {
e.stopPropagation();
if (!user) {
alert('로그인이 필요합니다.');
return;
}
if (!confirm('정말 삭제하시겠습니까?')) return;
try {
const response = await fetch(`/api/disciple-videos?id=${video.id}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete video');
// 로컬 state 업데이트
setVideos(prev => prev.filter(v => v.id !== video.id));
} catch (error) {
console.error('Error deleting video:', error);
alert('영상 삭제에 실패했습니다.');
}
};
const handleAddVideo = (step: string | null = null) => {
if (!user) {
alert('로그인이 필요합니다.');
return;
}
setNewVideoUrl('');
setAddingStep(step);
setIsAddModalOpen(true);
};
const handleSaveNewVideo = async () => {
if (!newVideoUrl) {
alert('YouTube URL을 입력해주세요.');
return;
}
try {
const response = await fetch('/api/disciple-videos', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
stage,
step: addingStep,
videoUrl: newVideoUrl
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || error.message || 'Failed to add video');
}
const result = await response.json();
const newVideo = result.data;
// 로컬 state 업데이트
setVideos(prev => [...prev, newVideo]);
setIsAddModalOpen(false);
setNewVideoUrl('');
setAddingStep(null);
} catch (error) {
console.error('Error adding video:', error);
alert(error instanceof Error ? error.message : '영상 추가에 실패했습니다.');
}
};
const moveVideo = async (video: DiscipleVideo, direction: 'up' | 'down') => {
if (!user) {
alert('로그인이 필요합니다.');
return;
}
// 같은 step의 비디오들만 필터링
const stepVideos = videos.filter(v => v.step === video.step);
const videoIndex = stepVideos.findIndex(v => v.id === video.id);
if (videoIndex === -1) return;
// 이동할 새 인덱스 계산
const newIndex = direction === 'up' ? videoIndex - 1 : videoIndex + 1;
// 범위 체크
if (newIndex < 0 || newIndex >= stepVideos.length) return;
// 교환할 두 비디오의 ID
const video1Id = stepVideos[videoIndex].id;
const video2Id = stepVideos[newIndex].id;
try {
// 서버에 순서 변경 요청
const updatedStepVideos = await swapDiscipleVideos(video1Id, video2Id);
// 전체 videos에서 해당 step의 비디오들만 교체
setVideos(prev => {
const otherVideos = prev.filter(v => v.step !== video.step);
return [...otherVideos, ...updatedStepVideos];
});
} catch (error) {
console.error('Error swapping videos:', error);
alert('영상 순서 변경에 실패했습니다.');
}
};
// step별로 비디오 그룹화
const getVideosByStep = (step: string | null) => {
return videos
.filter(v => v.step === step)
.sort((a, b) => b.order - a.order);
};
if (isLoading) {
return (
<div className="min-h-screen bg-white w-full flex items-center justify-center">
<div className="text-xl text-gray-600"> ...</div>
</div>
);
}
// selectedVideo의 videoUrl에서 embed용 ID 추출
const embedVideoId = extractYouTubeId(selectedVideo);
const renderVideoGrid = (videoList: DiscipleVideo[], step: string | null) => (
<div className="grid grid-cols-2 smalltablet:grid-cols-2 pc:grid-cols-3 gap-4 smalltablet:gap-6">
{videoList.map((video) => {
const stepVideos = videos.filter(v => v.step === step).sort((a, b) => b.order - a.order);
const index = stepVideos.findIndex(v => v.id === video.id);
return (
<div
key={video.id}
className="group relative rounded-2xl overflow-hidden bg-white shadow-lg hover:shadow-2xl transition-shadow duration-300"
>
<div
onClick={() => {
setSelectedVideo(video.videoUrl);
setTimeout(() => {
if (playerRef.current) {
const elementTop = playerRef.current.getBoundingClientRect().top + window.pageYOffset;
const offset = 80;
window.scrollTo({ top: elementTop - offset, behavior: 'smooth' });
}
}, 100);
}}
className="relative aspect-video bg-linear-to-br from-gray-800 to-gray-900 overflow-hidden cursor-pointer"
>
<Image
src={getYouTubeThumbnailUrl(video.videoUrl)}
alt={stageTitle}
fill
className="object-cover pc:group-hover:scale-105 transition-transform duration-500"
unoptimized
/>
<div className="absolute inset-0 bg-black/0 pc:group-hover:bg-black/10 transition-colors duration-300" />
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-12 h-12 smalltablet:w-14 smalltablet:h-14 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center shadow-xl pc:group-hover:scale-110 pc:group-hover:bg-white transition-all duration-300">
<div className="w-0 h-0 border-l-12 smalltablet:border-l-14 border-l-gray-800 border-t-7 smalltablet:border-t-8 border-t-transparent border-b-7 smalltablet:border-b-8 border-b-transparent ml-1" />
</div>
</div>
</div>
{user && (
<div className="flex flex-col p-4 smalltablet:flex-row smalltablet:items-center smalltablet:justify-between smalltablet:gap-2 smalltablet:p-3 bg-linear-to-br from-slate-50 via-white to-slate-50 border-t border-gray-100">
<div className="grid grid-cols-3 gap-1.5 w-full smalltablet:flex smalltablet:gap-2 smalltablet:w-auto">
<button
onClick={(e) => {
e.stopPropagation();
moveVideo(video, 'up');
}}
disabled={index === 0}
className={`flex items-center justify-center rounded-lg min-h-[32px] smalltablet:rounded-xl smalltablet:min-w-[40px] smalltablet:min-h-[40px] transition-all font-medium text-white shadow-md active:scale-95 ${
index === 0
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'bg-[#88aad2] hover:bg-[#94b7d6] pc:hover:shadow-lg'
}`}
aria-label="위로 이동"
>
<ArrowUp className="cursor-pointer w-4 h-4 smalltablet:w-5 smalltablet:h-5" strokeWidth={2.5} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
moveVideo(video, 'down');
}}
disabled={index === stepVideos.length - 1}
className={`flex items-center justify-center rounded-lg min-h-[32px] smalltablet:rounded-xl smalltablet:min-w-[40px] smalltablet:min-h-[40px] transition-all font-medium text-white shadow-md active:scale-95 ${
index === stepVideos.length - 1
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'bg-[#94b7d6] hover:bg-[#a9c6e1] pc:hover:shadow-lg'
}`}
aria-label="아래로 이동"
>
<ArrowDown className="cursor-pointer w-4 h-4 smalltablet:w-5 smalltablet:h-5" strokeWidth={2.5} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete(video, e);
}}
className="cursor-pointer flex flex-col items-center justify-center gap-0.5 bg-red-400 hover:bg-red-500 pc:hover:shadow-lg text-white rounded-lg font-semibold text-xs shadow-md active:scale-95 transition-all min-h-[32px] smalltablet:rounded-xl smalltablet:min-w-[40px] smalltablet:min-h-[40px]"
aria-label="영상 삭제"
>
<span className="hidden smalltablet:inline"></span>
<span className="smalltablet:hidden">X</span>
</button>
</div>
</div>
)}
</div>
);
})}
</div>
);
return (
<div className="w-full">
<div className="py-4 smalltablet:py-8 px-3 smalltablet:px-6 pc:px-8 max-w-7xl mx-auto">
{/* Main YouTube Player */}
<div ref={playerRef} className="mb-6 smalltablet:mb-8">
<div className="aspect-video w-full bg-black rounded-md smalltablet:rounded-lg overflow-hidden shadow-lg">
{embedVideoId ? (
<iframe
width="100%"
height="100%"
src={`https://www.youtube.com/embed/${embedVideoId}`}
title={stageTitle}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="w-full h-full"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-white">
</div>
)}
</div>
</div>
{/* Video List Section */}
{isDisciple ? (
// 제자훈련반: Step별로 그룹화
<div className="space-y-8">
{DISCIPLE_STEPS.map((step) => {
const stepVideos = getVideosByStep(step);
return (
<div key={step} className="mb-8 smalltablet:mb-12">
<div className="flex flex-col smalltablet:flex-row smalltablet:items-center smalltablet:justify-between gap-3 smalltablet:gap-0 mb-4 smalltablet:mb-6 border-b-2 border-gray-200 pb-3">
<h3 className="text-xl smalltablet:text-2xl font-bold text-gray-800">
{step}
</h3>
{user && (
<button
onClick={() => handleAddVideo(step)}
className="bg-[#6d96c5] hover:bg-[#88aad2] text-white px-3 smalltablet:px-4 py-2 rounded-lg font-medium transition-colors flex items-center justify-center gap-2 text-sm smalltablet:text-base"
>
<span>+</span>
<span> </span>
</button>
)}
</div>
{stepVideos.length === 0 ? (
<div className="text-center py-8 text-gray-500">
.
</div>
) : (
renderVideoGrid(stepVideos, step)
)}
</div>
);
})}
</div>
) : (
// 다른 stage: 단순 목록
<div className="mb-8 smalltablet:mb-12">
<div className="flex flex-col smalltablet:flex-row smalltablet:items-center smalltablet:justify-between gap-3 smalltablet:gap-0 mb-4 smalltablet:mb-6 border-b-2 border-gray-200 pb-3">
<h3 className="text-xl smalltablet:text-2xl font-bold text-gray-800">
{stageTitle}
</h3>
{user && (
<button
onClick={() => handleAddVideo(null)}
className="bg-[#6d96c5] hover:bg-[#88aad2] text-white px-3 smalltablet:px-4 py-2 rounded-lg font-medium transition-colors flex items-center justify-center gap-2 text-sm smalltablet:text-base"
>
<span>+</span>
<span> </span>
</button>
)}
</div>
{getVideosByStep(null).length === 0 ? (
<div className="text-center py-12 text-gray-500">
.
</div>
) : (
renderVideoGrid(getVideosByStep(null), null)
)}
</div>
)}
</div>
{/* Add Modal */}
{isAddModalOpen && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg p-6 smalltablet:p-8 max-w-md w-full shadow-2xl">
<h3 className="text-xl smalltablet:text-2xl font-bold mb-4 smalltablet:mb-6 text-gray-800">
{addingStep && `(${addingStep})`}
</h3>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
YouTube URL
</label>
<input
type="text"
value={newVideoUrl}
onChange={(e) => setNewVideoUrl(e.target.value)}
placeholder="https://www.youtube.com/watch?v=..."
className="w-full px-3 smalltablet:px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent outline-none text-sm smalltablet:text-base"
/>
<p className="mt-2 text-xs text-gray-500">
예시: https://www.youtube.com/watch?v=A8xPDnTkNzI
</p>
</div>
<div className="flex flex-col-reverse smalltablet:flex-row gap-2 smalltablet:gap-3 smalltablet:justify-end">
<button
onClick={() => {
setIsAddModalOpen(false);
setNewVideoUrl('');
setAddingStep(null);
}}
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 rounded-lg font-medium transition-colors text-sm smalltablet:text-base"
>
</button>
<button
onClick={handleSaveNewVideo}
className="px-4 py-2 bg-[#6d96c5] hover:bg-[#88aad2] text-white rounded-lg font-medium transition-colors text-sm smalltablet:text-base"
>
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,161 @@
import { Metadata } from 'next';
import Image from 'next/image';
import Link from 'next/link';
import { ArrowRight } from 'lucide-react';
import system1 from '@/public/subpages/system/icon1.webp';
import system2 from '@/public/subpages/system/icon2.webp';
import system3 from '@/public/subpages/system/icon3.webp';
import system4 from '@/public/subpages/system/icon4.webp';
import system5 from '@/public/subpages/system/icon5.webp';
export const metadata: Metadata = {
title: '제자화 시스템',
description: '제자들교회의 체계적인 제자훈련 시스템입니다. 새가족반부터 사역훈련반까지 단계별 양육 과정을 통해 성숙한 제자로 성장합니다.',
openGraph: {
title: '제자화 시스템 | 제자들교회',
description: '제자들교회 제자훈련 시스템 - 정착부터 일꾼까지',
},
};
function DiscipleshipSystemPageContent() {
const stages = [
{
id: 'new-family',
title: '새가족반 - 정착',
subtitle: '5주 과정',
bgColor: 'bg-[#016ba4]',
textColor: 'text-white',
icon: <Image src={system1} alt="system1" width={100} height={100} />,
arrowColor: '#016ba4',
},
{
id: 'basic',
title: '기초양육반 - 기본',
subtitle: '12주 과정',
bgColor: 'bg-white',
textColor: 'text-[#4A9FD8]',
icon: <Image src={system2} alt="system2" width={100} height={100} />,
arrowColor: '#FFFFFF',
},
{
id: 'disciple',
title: '제자훈련반 - 성숙',
details: [
{ text: '복음키워드 1단계 - 십자가', period: '12주 과정' },
{ text: '복음키워드 2단계 - 영적전투', period: '12주 과정' },
{ text: '복음키워드 3단계 - 하나님 나라', period: '6주 과정' },
],
bgColor: 'bg-[#41cc93]',
textColor: 'text-white',
icon: <Image src={system3} alt="system3" width={100} height={100} />,
arrowColor: '#41cc93',
},
{
id: 'evangelism',
title: '전도훈련반 - 증인',
subtitle: '6주 과정',
bgColor: 'bg-white',
textColor: 'text-[#F08B7C]',
icon: <Image src={system4} alt="system4" width={100} height={100} />,
arrowColor: '#FFFFFF',
},
{
id: 'ministry',
title: '사역훈련반 - 일꾼',
subtitle: '12주 과정',
bgColor: 'bg-[#ed8775]',
textColor: 'text-white',
icon: <Image src={system5} alt="system5" width={100} height={100} />,
arrowColor: '#ed8775',
},
];
return (
<div className="bg-white w-full">
<div className="py-8">
{/* 시스템 플로우 */}
<div className="space-y-0 max-w-6xl mx-auto rounded-2xl overflow-hidden border border-gray-400 shadow-lg">
{stages.map((stage, index) => (
<Link
key={stage.id}
href={`/system/${stage.id}`}
className="relative block cursor-pointer group transition-all duration-300 hover:scale-[1.02] hover:shadow-2xl hover:z-10"
>
{/* 상단 화살표 (두 번째 섹션부터) */}
{index > 0 && (
<div className="absolute top-0 left-1/2 -translate-x-1/2 z-10 flex flex-col items-center">
{/* 화살표 몸통 */}
<div className="w-8 h-2 smalltablet:h-3" style={{ backgroundColor: stages[index - 1].arrowColor }} />
{/* 모바일 화살표 */}
<div
className="w-0 h-0 smalltablet:hidden border-l-24 border-l-transparent border-r-24 border-r-transparent border-t-30"
style={{ borderTopColor: stages[index - 1].arrowColor }}
>
</div>
{/* 중간 화면 화살표 */}
<div
className="w-0 h-0 hidden smalltablet:block pc:hidden border-l-32 border-l-transparent border-r-32 border-r-transparent border-t-40"
style={{ borderTopColor: stages[index - 1].arrowColor }}
>
</div>
{/* 데스크톱 화살표 */}
<div
className="w-0 h-0 hidden pc:block border-l-40 border-l-transparent border-r-40 border-r-transparent border-t-50"
style={{ borderTopColor: stages[index - 1].arrowColor }}
>
</div>
</div>
)}
{/* 단계 박스 */}
<div className={`${stage.bgColor} ${stage.textColor} py-6 smalltablet:py-8 px-4 smalltablet:px-6 pc:px-8 text-center relative w-full transition-all duration-300 group-hover:brightness-105`}>
{/* 클릭 가능 표시 - 오른쪽 상단 화살표 */}
<div className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div className={`${stage.textColor === 'text-white' ? 'bg-white/20' : 'bg-gray-900/10'} rounded-full p-2 backdrop-blur-sm`}>
<ArrowRight className={`w-5 h-5 smalltablet:w-6 smalltablet:h-6 ${stage.textColor}`} strokeWidth={2.5} />
</div>
</div>
<div className={`${index > 0 ? 'mt-[30px] pc:mt-[50px]' : ''} mb-3 smalltablet:mb-4 flex justify-center items-center transition-transform duration-300 group-hover:scale-110`}>
{stage.icon}
</div>
<h2 className="text-2xl smalltablet:text-3xl pc:text-4xl font-bold mb-3 transition-transform duration-300 group-hover:scale-105">
{stage.title}
</h2>
{stage.details ? (
<div className="flex flex-wrap justify-center gap-4 smalltablet:gap-6 pc:gap-8 text-base smalltablet:text-lg pc:text-xl">
{stage.details.map((detail, idx) => (
<div key={idx} className="text-center">
<p>{detail.text}</p>
<p className="text-sm smalltablet:text-base opacity-90">{detail.period}</p>
</div>
))}
</div>
) : (
<p className="text-base smalltablet:text-lg pc:text-xl opacity-90">
{stage.subtitle}
</p>
)}
{/* 클릭 안내 텍스트 */}
<div className="mt-4 smalltablet:mt-6 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<p className="text-xs smalltablet:text-sm font-medium opacity-80 flex items-center justify-center gap-2">
<span> </span>
<ArrowRight className="w-4 h-4" />
</p>
</div>
</div>
</Link>
))}
</div>
</div>
</div>
);
}
export default function DiscipleshipSystemPage() {
return <DiscipleshipSystemPageContent />;
}

View File

@@ -0,0 +1,44 @@
import { Metadata } from 'next';
import React from 'react';
import Image from 'next/image';
import missionMap from "@/public/subpages/mission/mission/missionMap.webp";
import missionMapKorea from "@/public/subpages/mission/mission/missionMapKorea.webp";
export const metadata: Metadata = {
title: '선교',
description: '제자들교회의 국내외 선교 사역을 소개합니다. 복음의 능력으로 땅 끝까지 그리스도의 증인이 되어 갑니다.',
openGraph: {
title: '선교 | 제자들교회',
description: '제자들교회 선교 사역 - 국내외 선교 현황',
},
};
export default function MissionPage() {
return (
<div className="w-full bg-white">
<div className="py-8 smalltablet:py-12 pc:py-16 px-4 smalltablet:px-6 pc:px-20">
<div className="max-w-7xl mx-auto space-y-8 smalltablet:space-y-12">
{/* 선교 지도 이미지 */}
<div className='w-full h-full relative items-center justify-center flex'>
<Image
src={missionMap}
alt='mission map'
sizes='100vw'
className='object-cover rounded-lg smalltablet:rounded-xl'
/>
</div>
{/* 선교 지도 이미지 */}
<div className='w-full h-full relative items-center justify-center flex'>
<Image
src={missionMapKorea}
alt='mission map'
sizes='100vw'
className='object-cover rounded-lg smalltablet:rounded-xl'
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,239 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import { useRouter, useParams } from "next/navigation";
import Image from "next/image";
import {
getAnnouncementById,
deleteAnnouncement,
getDownloadUrl,
type Announcement,
type AnnouncementFile,
} from "@/lib/services";
import { useAuth, useImageModal } from "@/hooks";
import { Download } from "lucide-react";
const IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "webp"];
export default function AnnouncementDetailPage() {
const [announcement, setAnnouncement] = useState<Announcement | null>(null);
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
const params = useParams();
const id = params.id as string;
const { user } = useAuth();
// 이미지 파일만 필터링 (API에서 signedUrl 포함)
const imageFiles = useMemo(() => {
if (!announcement?.files) return [];
return announcement.files.filter((file) => {
const ext = file.fileName.split(".").pop()?.toLowerCase();
return IMAGE_EXTENSIONS.includes(ext || "") && file.signedUrl;
}) as (AnnouncementFile & { signedUrl: string })[];
}, [announcement?.files]);
const { selectedIndex, isOpen, open, close, next, prev } = useImageModal(imageFiles.length);
useEffect(() => {
loadData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
const loadData = async () => {
try {
// 공지사항 상세 불러오기 (signedUrl 포함)
const announcementData = await getAnnouncementById(parseInt(id));
setAnnouncement(announcementData);
} catch {
alert("공지사항을 불러올 수 없습니다.");
router.push("/announcements");
} finally {
setIsLoading(false);
}
};
const handleDelete = async () => {
if (!announcement || !user) return;
if (announcement.authorId !== user.id) {
alert("삭제 권한이 없습니다.");
return;
}
if (!confirm("정말 삭제하시겠습니까?")) return;
try {
await deleteAnnouncement(announcement.id);
alert("삭제되었습니다.");
router.push("/announcements");
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "삭제에 실패했습니다.";
alert(errorMessage);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
};
const handleDownloadAll = async () => {
for (const img of imageFiles) {
try {
const downloadUrl = await getDownloadUrl(img.fileKey, img.fileName);
window.open(downloadUrl, '_blank');
} catch (error) {
console.error("Download failed:", error);
}
}
};
if (isLoading) {
return (
<div className="min-h-screen bg-white w-full flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
if (!announcement) {
return null;
}
const isAuthor = user && announcement.authorId === user.id;
return (
<div className="w-full">
<div className="py-6 smalltablet:py-12 px-4">
<div className="max-w-7xl p-4 smalltablet:p-6 pc:p-8 rounded-xl bg-gray-100 mx-auto">
{/* 헤더 */}
<div className="mb-6 smalltablet:mb-8">
<div className="flex items-center gap-2 smalltablet:gap-3 mb-3 smalltablet:mb-4">
{announcement.isImportant && (
<span className="px-2 smalltablet:px-3 py-0.5 smalltablet:py-1 rounded-full text-xs smalltablet:text-sm font-semibold bg-red-100 text-red-700">
</span>
)}
</div>
<h1 className="text-xl smalltablet:text-2xl pc:text-3xl font-bold text-gray-800 mb-3 smalltablet:mb-4 wrap-break-word">
{announcement.title}
</h1>
<div className="flex flex-col smalltablet:flex-row smalltablet:items-center smalltablet:justify-between text-xs smalltablet:text-sm text-gray-600 pb-3 smalltablet:pb-4 border-b gap-3 smalltablet:gap-0">
<div className="flex flex-wrap items-center gap-2 smalltablet:gap-4">
<span>: {announcement.author.userName}</span>
<span className="hidden smalltablet:inline"></span>
<span className="hidden smalltablet:inline text-xs smalltablet:text-sm">{formatDate(announcement.createdAt)}</span>
<span className="hidden smalltablet:inline"></span>
<span>: {announcement.viewCount}</span>
</div>
{isAuthor && (
<div className="flex gap-2">
<button
onClick={handleDelete}
className="px-3 smalltablet:px-4 py-1.5 smalltablet:py-2 text-xs smalltablet:text-sm text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors"
>
</button>
</div>
)}
</div>
</div>
{/* 이미지 갤러리 */}
{imageFiles.length > 0 && (
<div className="space-y-4 mb-6">
{imageFiles.map((img, index) => (
<div
key={img.id}
className="relative w-full bg-gray-200 rounded-lg overflow-hidden cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => open(index)}
>
<Image
src={img.signedUrl}
alt={`${announcement.title} - ${index + 1}`}
width={1200}
height={800}
className="w-full h-auto object-contain"
/>
</div>
))}
{/* 전체 다운로드 버튼 */}
<button
onClick={handleDownloadAll}
className="w-full px-4 py-3 bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] hover:from-[#6b95c6] hover:to-[#5b85b6] text-white rounded-lg shadow-md hover:shadow-lg transition-all font-semibold flex items-center justify-center gap-2 text-sm"
>
<Download className="w-4 h-4" />
<span> ({imageFiles.length})</span>
</button>
</div>
)}
{/* 이미지가 없는 경우 */}
{imageFiles.length === 0 && (
<div className="text-center text-gray-500 py-8">
.
</div>
)}
</div>
</div>
{/* 이미지 모달 */}
{isOpen && selectedIndex !== null && (
<div
className="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50"
onClick={close}
>
<button
className="absolute left-4 top-1/2 -translate-y-1/2 text-white text-4xl hover:text-gray-300 z-10"
onClick={(e) => {
e.stopPropagation();
prev();
}}
>
&lsaquo;
</button>
<div className="relative max-w-7xl max-h-[90vh] w-full h-full mx-16">
{imageFiles[selectedIndex]?.signedUrl && (
<Image
src={imageFiles[selectedIndex].signedUrl}
alt={`${announcement.title} - ${selectedIndex + 1}`}
fill
className="object-contain"
/>
)}
</div>
<button
className="absolute right-4 top-1/2 -translate-y-1/2 text-white text-4xl hover:text-gray-300 z-10"
onClick={(e) => {
e.stopPropagation();
next();
}}
>
&rsaquo;
</button>
<button
className="absolute top-4 right-4 text-white text-3xl hover:text-gray-300"
onClick={close}
>
&times;
</button>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white text-xs smalltablet:text-sm">
{selectedIndex + 1} / {imageFiles.length}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,180 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { createAnnouncement, uploadFile } from "@/lib/services";
import { useAuth } from "@/hooks";
import ImageUpload, { PendingImage } from "@/components/ImageUpload";
interface AnnouncementFormData {
title: string;
isImportant: boolean;
}
export default function CreateAnnouncementPage() {
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const router = useRouter();
const { user, isLoading } = useAuth();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<AnnouncementFormData>({
defaultValues: {
title: "",
isImportant: false,
},
});
// 로그인하지 않은 경우 리다이렉트
useEffect(() => {
if (!isLoading && !user) {
alert("로그인이 필요합니다.");
router.push("/login");
}
}, [isLoading, user, router]);
const onSubmit = async (data: AnnouncementFormData) => {
if (!user) return;
try {
// 이미지 업로드
let uploadedFiles: {
fileKey: string;
fileName: string;
fileSize: number;
mimeType: string;
}[] = [];
if (pendingImages.length > 0) {
const sortedImages = [...pendingImages].sort((a, b) => a.order - b.order);
const uploadPromises = sortedImages.map(async (img) => {
const result = await uploadFile(img.file, "/announcement");
return {
fileKey: result.fileKey,
fileName: img.file.name,
fileSize: img.file.size,
mimeType: img.file.type,
};
});
uploadedFiles = await Promise.all(uploadPromises);
}
await createAnnouncement({
...data,
content: "", // 내용 필드는 빈 문자열로 전송
authorId: user.id,
files: uploadedFiles.length > 0 ? uploadedFiles : undefined,
});
// 미리보기 URL 정리
pendingImages.forEach((img) => {
if (img.preview) URL.revokeObjectURL(img.preview);
});
alert("주보가 등록되었습니다.");
router.push("/announcements");
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : "주보 등록에 실패했습니다.";
alert(errorMessage);
}
};
if (isLoading) {
return (
<div className="min-h-screen bg-white w-full flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
if (!user) {
return null;
}
return (
<div className="min-h-screen bg-white w-full">
<div className="py-12 px-4">
<div className="max-w-4xl mx-auto">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 w-full">
{/* 제목 */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
<span className="text-red-500">*</span>
</label>
<input
type="text"
{...register("title", {
required: "제목을 입력해주세요",
minLength: {
value: 2,
message: "제목은 2자 이상이어야 합니다",
},
})}
disabled={isSubmitting}
placeholder="제목을 입력해주세요"
className={`w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:border-transparent disabled:opacity-50 transition-all ${
errors.title
? "border-red-300 focus:ring-red-400"
: "border-gray-300 focus:ring-blue-500"
}`}
/>
{errors.title && (
<p className="text-red-500 text-sm mt-1">
{errors.title.message}
</p>
)}
</div>
{/* 중요 공지 체크박스 */}
<div className="flex items-center">
<input
type="checkbox"
id="isImportant"
{...register("isImportant")}
disabled={isSubmitting}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
/>
<label
htmlFor="isImportant"
className="ml-2 text-sm font-medium text-gray-700"
>
</label>
</div>
{/* 이미지 업로드 */}
<ImageUpload
images={pendingImages}
onImagesChange={setPendingImages}
disabled={isSubmitting}
/>
{/* 버튼 */}
<div className="flex gap-4 pt-4">
<button
type="submit"
disabled={isSubmitting}
className="flex-1 px-6 py-3 bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] hover:from-[#6b95c6] hover:to-[#5b85b6] text-white rounded-lg shadow-md hover:shadow-lg transition-all font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? "등록 중..." : "등록하기"}
</button>
<button
type="button"
onClick={() => router.back()}
disabled={isSubmitting}
className="px-6 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors font-semibold disabled:opacity-50"
>
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { Metadata } from 'next';
export const metadata: Metadata = {
title: '주보',
description: '제자들교회의 주보와 공지사항을 확인하실 수 있습니다. 교회의 소식과 일정을 안내해드립니다.',
openGraph: {
title: '주보 | 제자들교회',
description: '제자들교회의 주보와 공지사항을 확인하실 수 있습니다.',
},
};
export default function AnnouncementsLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@@ -0,0 +1,197 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { getAnnouncements, type Announcement } from "@/lib/services";
import { useAuth, usePagination } from "@/hooks";
import { FileTextIcon } from "lucide-react";
import Pagination from "@/components/Pagination";
export default function AnnouncementsPage() {
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
const [isLoading, setIsLoading] = useState(true);
const { user } = useAuth();
const { currentPage, totalPages, setCurrentPage, setTotalPages } = usePagination();
useEffect(() => {
loadData(currentPage);
}, [currentPage]);
const loadData = async (page: number) => {
setIsLoading(true);
try {
// 공지사항 목록 불러오기
const announcementsResponse = await getAnnouncements(page, 10);
setAnnouncements(announcementsResponse.data);
setTotalPages(announcementsResponse.pagination.totalPages);
} catch (error) {
console.error("Failed to load announcements:", error);
} finally {
setIsLoading(false);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
};
if (isLoading) {
return (
<div className="space-y-8 w-full flex flex-col items-center">
<div className="max-w-7xl px-4 m-4 smalltablet:m-8 w-full">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
{/* 테이블 헤더 스켈레톤 - 데스크톱 */}
<div className="hidden smalltablet:grid smalltablet:grid-cols-12 bg-gray-50 border-b border-gray-200 text-sm font-medium text-gray-700">
<div className="col-span-1 px-6 py-4 text-center"></div>
<div className="col-span-6 px-6 py-4"></div>
<div className="col-span-2 px-6 py-4 text-center"></div>
<div className="col-span-2 px-6 py-4 text-center"></div>
<div className="col-span-1 px-6 py-4 text-center"></div>
</div>
{/* 테이블 바디 스켈레톤 */}
<div className="divide-y divide-gray-200">
{[...Array(5)].map((_, index) => (
<div
key={index}
className="grid grid-cols-1 smalltablet:grid-cols-12 animate-pulse"
>
{/* 모바일 뷰 스켈레톤 */}
<div className="smalltablet:hidden px-6 py-4">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
<div className="flex items-center justify-between">
<div className="h-3 bg-gray-200 rounded w-20"></div>
<div className="h-3 bg-gray-200 rounded w-24"></div>
</div>
</div>
{/* 데스크톱 뷰 스켈레톤 */}
<div className="hidden smalltablet:block smalltablet:col-span-1 px-6 py-4">
<div className="h-4 bg-gray-200 rounded w-8 mx-auto"></div>
</div>
<div className="hidden smalltablet:block smalltablet:col-span-6 px-6 py-4">
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
</div>
<div className="hidden smalltablet:block smalltablet:col-span-2 px-6 py-4">
<div className="h-4 bg-gray-200 rounded w-16 mx-auto"></div>
</div>
<div className="hidden smalltablet:block smalltablet:col-span-2 px-6 py-4">
<div className="h-4 bg-gray-200 rounded w-20 mx-auto"></div>
</div>
<div className="hidden smalltablet:block smalltablet:col-span-1 px-6 py-4">
<div className="h-4 bg-gray-200 rounded w-8 mx-auto"></div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
return (
<div className="space-y-8 w-full flex flex-col items-center">
<div className="max-w-7xl px-4 m-4 smalltablet:m-8 w-full">
{/* 공지 작성 버튼 */}
{user && (
<div className="flex justify-end mb-4">
<Link
href="/announcements/create"
className="px-6 py-2.5 bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] hover:from-[#6b95c6] hover:to-[#5b85b6] text-white rounded-lg shadow-md hover:shadow-lg transition-all font-medium text-sm"
>
</Link>
</div>
)}
{/* 테이블 */}
{announcements.length === 0 ? (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 text-center py-20 flex items-center justify-center flex-col">
<FileTextIcon className="mx-auto h-16 w-16 text-gray-300 mb-4" />
<p className="text-gray-500 text-lg">
.
</p>
</div>
) : (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
{/* 테이블 헤더 - 데스크톱 */}
<div className="hidden smalltablet:grid smalltablet:grid-cols-12 bg-gray-50 border-b border-gray-200 text-sm font-medium text-gray-700">
<div className="col-span-1 px-6 py-4 text-center"></div>
<div className="col-span-6 px-6 py-4"></div>
<div className="col-span-2 px-6 py-4 text-center"></div>
<div className="col-span-2 px-6 py-4 text-center"></div>
<div className="col-span-1 px-6 py-4 text-center"></div>
</div>
{/* 테이블 바디 */}
<div className="divide-y divide-gray-200">
{announcements.map((item, index) => (
<Link
key={item.id}
href={`/announcements/${item.id}`}
className="grid grid-cols-1 smalltablet:grid-cols-12 hover:bg-gray-50 transition-colors"
>
{/* 모바일 뷰 */}
<div className="smalltablet:hidden px-6 py-4">
<div className="flex items-center gap-2 mb-2">
{item.isImportant && (
<span className="px-2 py-0.5 bg-orange-100 text-orange-600 text-xs font-bold rounded">
</span>
)}
</div>
<h3 className="font-medium text-gray-900 mb-2">{item.title}</h3>
<div className="flex items-center justify-between text-sm text-gray-500">
<span>{item.author.userName}</span>
<span>{formatDate(item.createdAt)}</span>
</div>
</div>
{/* 데스크톱 뷰 */}
<div className="hidden smalltablet:block smalltablet:col-span-1 px-6 py-4 text-center text-sm text-gray-600">
{announcements.length - index}
</div>
<div className="hidden smalltablet:block smalltablet:col-span-6 px-6 py-4">
<div className="flex items-center gap-2">
{item.isImportant && (
<span className="px-2 py-0.5 bg-orange-100 text-orange-600 text-xs font-bold rounded">
</span>
)}
<span className="text-gray-900 font-medium hover:text-blue-600 transition-colors">
{item.title}
</span>
</div>
</div>
<div className="hidden smalltablet:block smalltablet:col-span-2 px-6 py-4 text-center text-sm text-gray-600">
{item.author.userName}
</div>
<div className="hidden smalltablet:flex smalltablet:col-span-2 px-6 py-4 justify-center text-center text-sm text-gray-600">
{formatDate(item.createdAt)}
</div>
<div className="hidden smalltablet:block smalltablet:col-span-1 px-6 py-4 text-center text-sm text-gray-600">
{item.viewCount}
</div>
</Link>
))}
</div>
</div>
)}
{/* Pagination */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,204 @@
'use client';
import { useState, useEffect, useCallback, use } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { ClipLoader } from 'react-spinners';
import { GalleryPost, getGalleryPost, deleteGalleryPost, getSortedGalleryContent, type GalleryContentItem } from '@/lib/services';
import { useAuth, useImageModal } from '@/hooks';
export default function GalleryDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const router = useRouter();
const [post, setPost] = useState<GalleryPost | null>(null);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState(false);
const { user } = useAuth();
const sortedImages = post?.images.sort((a, b) => a.order - b.order) || [];
const { selectedIndex, isOpen, open, close, next, prev } = useImageModal(sortedImages.length);
const loadData = useCallback(async () => {
setLoading(true);
try {
const data = await getGalleryPost(parseInt(id, 10));
setPost(data);
} catch (error) {
console.error('Failed to fetch post:', error);
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
loadData();
}, [loadData]);
const handleDelete = async () => {
if (!confirm('정말 삭제하시겠습니까?')) return;
setDeleting(true);
try {
await deleteGalleryPost(parseInt(id, 10));
router.push('/gallery');
} catch (error) {
console.error('Failed to delete post:', error);
alert('삭제에 실패했습니다.');
setDeleting(false);
}
};
if (loading) {
return (
<div className="min-h-screen bg-white w-full flex items-center justify-center">
<ClipLoader color="#7ba5d6" size={50} />
</div>
);
}
if (!post) {
return (
<div className="min-h-screen bg-white w-full">
<div className="py-12 px-4">
<div className="max-w-7xl mx-auto text-center">
<p className="text-gray-500"> .</p>
<Link href="/gallery" className="text-blue-500 mt-4 inline-block">
</Link>
</div>
</div>
</div>
);
}
return (
<div className="w-full">
<div className="py-12 px-4">
<div className="max-w-7xl bg-gray-100 rounded-xl p-8 mx-auto">
{/* 헤더 */}
<div className="mb-8 pb-6 border-b border-gray-200">
<div className="flex justify-between items-start">
<div>
<h1 className="text-xl smalltablet:text-2xl pc:text-3xl font-bold text-gray-800">{post.title}</h1>
<p className="text-xs smalltablet:text-sm text-gray-500 mt-1">
{new Date(post.createdAt).toLocaleDateString('ko-KR')}
</p>
</div>
{user && (
<button
onClick={handleDelete}
disabled={deleting}
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors disabled:opacity-50"
>
{deleting ? '삭제 중...' : '삭제'}
</button>
)}
</div>
</div>
{/* 콘텐츠 (이미지 + 텍스트 블록) */}
<div className="space-y-4">
{(() => {
const sortedContent = getSortedGalleryContent(post);
return sortedContent.map((item: GalleryContentItem, idx: number) => {
if (item.type === 'image') {
const imageId = item.data.id;
const aspectRatio = item.data.aspectRatio;
return (
<div
key={`image-${imageId}`}
className="relative w-full bg-gray-200 rounded-lg overflow-hidden cursor-pointer hover:opacity-90 transition-opacity"
style={{
aspectRatio: aspectRatio ? `${aspectRatio}` : 'auto',
minHeight: aspectRatio ? 'auto' : '200px',
}}
onClick={() => {
// 전체 이미지 배열에서의 인덱스 찾기
const actualIndex = sortedImages.findIndex(img => img.id === item.data.id);
open(actualIndex);
}}
>
{item.data.displayUrl && (
<Image
src={item.data.displayUrl}
alt={`${post.title} - ${idx + 1}`}
fill
className="object-contain"
/>
)}
</div>
);
} else {
return (
<div
key={`text-${item.data.id}`}
className="p-4 rounded-lg text-sm smalltablet:text-base pc:text-xl text-gray-700 whitespace-pre-wrap"
>
{item.data.content}
</div>
);
}
});
})()}
</div>
{/* 이미지 모달 */}
{isOpen && selectedIndex !== null && (
<div
className="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50"
onClick={close}
>
<button
className="absolute left-4 top-1/2 -translate-y-1/2 text-white text-4xl hover:text-gray-300 z-10"
onClick={(e) => {
e.stopPropagation();
prev();
}}
>
&lsaquo;
</button>
<div className="relative max-w-4xl max-h-[90vh] w-full h-full mx-16">
{sortedImages[selectedIndex]?.displayUrl && (
<Image
src={sortedImages[selectedIndex].displayUrl}
alt={`${post.title} - ${selectedIndex + 1}`}
fill
className="object-contain"
/>
)}
</div>
<button
className="absolute right-4 top-1/2 -translate-y-1/2 text-white text-4xl hover:text-gray-300 z-10"
onClick={(e) => {
e.stopPropagation();
next();
}}
>
&rsaquo;
</button>
<button
className="absolute top-4 right-4 text-white text-3xl hover:text-gray-300"
onClick={close}
>
&times;
</button>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white text-xs smalltablet:text-sm">
{selectedIndex + 1} / {sortedImages.length}
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { Metadata } from 'next';
export const metadata: Metadata = {
title: '갤러리',
description: '제자들교회의 다양한 활동과 행사 사진을 보실 수 있습니다. 교회 공동체의 아름다운 순간들을 함께 나눕니다.',
openGraph: {
title: '갤러리 | 제자들교회',
description: '제자들교회 갤러리 - 교회 활동 및 행사 사진',
},
};
export default function GalleryLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@@ -0,0 +1,114 @@
'use client';
import { useState, useEffect } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { GalleryPost, getGalleryPosts } from '@/lib/services';
import { useAuth, usePagination } from '@/hooks';
import Pagination from '@/components/Pagination';
import { FileTextIcon } from 'lucide-react';
export default function GalleryPage() {
const [posts, setPosts] = useState<GalleryPost[]>([]);
const [loading, setLoading] = useState(true);
const { user } = useAuth();
const { currentPage, totalPages, setCurrentPage, setTotalPages } = usePagination();
useEffect(() => {
fetchPosts(currentPage);
}, [currentPage]);
const fetchPosts = async (page: number) => {
setLoading(true);
try {
const result = await getGalleryPosts(page, 9);
setPosts(result.data);
setTotalPages(result.pagination.totalPages);
} catch (error) {
console.error('Failed to fetch posts:', error);
} finally {
setLoading(false);
}
};
return (
<div className="space-y-8 w-full">
<div className="py-12 px-4">
<div className="max-w-7xl mx-auto">
{/* 글쓰기 버튼 */}
{user && (
<div className="flex justify-end mb-4">
<Link
href="/gallery/write"
className="px-4 smalltablet:px-6 py-2 smalltablet:py-2.5 bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] hover:from-[#6b95c6] hover:to-[#5b85b6] text-white rounded-lg shadow-md hover:shadow-lg transition-all font-medium text-xs smalltablet:text-sm"
>
</Link>
</div>
)}
{/* 갤러리 그리드 */}
{loading ? (
<div className="grid grid-cols-2 smalltablet:grid-cols-3 gap-4">
{Array.from({ length: 9 }).map((_, idx) => (
<div key={idx} className="bg-gray-200 rounded-lg overflow-hidden animate-pulse">
<div className="aspect-4/3" />
<div className="p-4">
<div className="h-5 bg-gray-300 rounded w-3/4 mb-2" />
<div className="h-4 bg-gray-300 rounded w-1/2" />
</div>
</div>
))}
</div>
) : posts.length === 0 ? (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 text-center py-20 flex items-center justify-center flex-col">
<FileTextIcon className="mx-auto h-12 w-12 smalltablet:h-16 smalltablet:w-16 text-gray-300 mb-4" />
<p className="text-gray-500 text-base smalltablet:text-lg">
.
</p>
</div>
) : (
<div className="grid grid-cols-2 smalltablet:grid-cols-3 gap-4">
{posts.map((post) => (
<Link
key={post.id}
href={`/gallery/${post.id}`}
className="bg-white rounded-lg overflow-hidden shadow-md hover:shadow-lg transition-shadow"
>
<div className="relative aspect-4/3 bg-gray-100">
{post.thumbnailUrl && (
<Image
src={post.thumbnailUrl}
alt={post.title}
fill
className="object-cover"
/>
)}
{post.images.length > 1 && (
<div className="absolute top-2 right-2 bg-black bg-opacity-60 text-white text-[10px] smalltablet:text-xs px-2 py-1 rounded">
+{post.images.length - 1}
</div>
)}
</div>
<div className="p-4">
<h3 className="text-sm smalltablet:text-base font-semibold text-gray-800 truncate">{post.title}</h3>
<p className="text-xs smalltablet:text-sm text-gray-500 mt-1">
{new Date(post.createdAt).toLocaleDateString('ko-KR')}
</p>
</div>
</Link>
))}
</div>
)}
{/* Pagination */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,327 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { uploadGalleryFiles, createGalleryPost, calculateImageAspectRatio } from "@/lib/services";
import { X, ArrowUp, ArrowDown, Plus } from "lucide-react";
import { extractImagesFromClipboard } from "@/components/ImageUpload";
type ContentItem =
| { type: "image"; id: string; file: File; preview: string; order: number }
| { type: "text"; id: string; content: string; order: number };
export default function GalleryWritePage() {
const router = useRouter();
const [title, setTitle] = useState("");
const [items, setItems] = useState<ContentItem[]>([]);
const [submitting, setSubmitting] = useState(false);
const addImages = useCallback((files: File[]) => {
setItems((prevItems) => {
const newItems: ContentItem[] = files.map((file, index) => ({
type: "image",
id: `img-${Date.now()}-${Math.random()}`,
file,
preview: URL.createObjectURL(file),
order: prevItems.length + index,
}));
return [...prevItems, ...newItems];
});
}, []);
// 클립보드 붙여넣기 핸들러
useEffect(() => {
const handlePaste = (e: ClipboardEvent) => {
if (submitting) return;
const imageFiles = extractImagesFromClipboard(e);
if (imageFiles.length > 0) {
e.preventDefault();
addImages(imageFiles);
}
};
document.addEventListener("paste", handlePaste);
return () => document.removeEventListener("paste", handlePaste);
}, [submitting, addImages]);
const addTextBlock = () => {
const newItem: ContentItem = {
type: "text",
id: `text-${Date.now()}-${Math.random()}`,
content: "",
order: items.length,
};
setItems([...items, newItem]);
};
const removeItem = (id: string) => {
const newItems = items.filter((item) => item.id !== id);
// order 재정렬
const reorderedItems = newItems.map((item, index) => ({
...item,
order: index,
}));
setItems(reorderedItems);
};
const moveItem = (id: string, direction: "up" | "down") => {
const index = items.findIndex((item) => item.id === id);
if (index === -1) return;
const newIndex = direction === "up" ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= items.length) return;
const newItems = [...items];
[newItems[index], newItems[newIndex]] = [newItems[newIndex], newItems[index]];
// order 재정렬
const reorderedItems = newItems.map((item, idx) => ({
...item,
order: idx,
}));
setItems(reorderedItems);
};
const updateTextContent = (id: string, content: string) => {
setItems(
items.map((item) =>
item.id === id && item.type === "text" ? { ...item, content } : item
)
);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) {
alert("제목을 입력해주세요.");
return;
}
const imageItems = items.filter((item) => item.type === "image");
if (imageItems.length === 0) {
alert("최소 1개 이상의 이미지를 업로드해주세요.");
return;
}
setSubmitting(true);
try {
// 이미지 파일 업로드
const imageFiles = imageItems.map((item) =>
item.type === "image" ? item.file : null
).filter((f): f is File => f !== null);
const fileKeys = await uploadGalleryFiles(imageFiles);
// 이미지 비율 계산
const imageAspectRatios = await Promise.all(
imageFiles.map((file) => calculateImageAspectRatio(file))
);
// fileKeys와 비율을 이미지 아이템에 매핑
let fileKeyIndex = 0;
const itemsWithFileKeys = items.map((item) => {
if (item.type === "image") {
return {
type: "image" as const,
fileKey: fileKeys[fileKeyIndex],
order: item.order,
aspectRatio: imageAspectRatios[fileKeyIndex++],
};
} else {
return {
type: "text" as const,
content: item.content,
order: item.order,
};
}
});
// 갤러리 포스트 생성
await createGalleryPost({
title: title.trim(),
content: "",
items: itemsWithFileKeys,
});
// 미리보기 URL 정리
items.forEach((item) => {
if (item.type === "image" && item.preview) {
URL.revokeObjectURL(item.preview);
}
});
router.push("/gallery");
} catch (error) {
console.error("Submit failed:", error);
alert("등록에 실패했습니다.");
} finally {
setSubmitting(false);
}
};
return (
<div className="w-full">
<div className="py-12 px-4">
<div className="max-w-4xl mx-auto">
<form onSubmit={handleSubmit} className="space-y-6">
{/* 제목 */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={submitting}
placeholder="제목을 입력해주세요"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 transition-all"
/>
</div>
{/* 콘텐츠 목록 */}
<div className="space-y-4">
{items.length === 0 ? (
<div className="text-center py-12 text-gray-500 border-2 border-dashed border-gray-300 rounded-lg">
<p className="mb-2"> </p>
<p className="text-sm text-blue-600 font-medium">
Ctrl+V로
</p>
</div>
) : (
items
.sort((a, b) => a.order - b.order)
.map((item, index) => (
<div
key={item.id}
className="border border-gray-300 rounded-lg p-4 bg-white"
>
<div className="flex items-start gap-4">
<span className="text-sm text-gray-500 font-medium pt-2 min-w-[24px]">
{index + 1}
</span>
<div className="flex-1">
{item.type === "image" ? (
<div className="relative w-full bg-gray-100 rounded-lg overflow-hidden">
<Image
src={item.preview}
alt="미리보기"
width={1200}
height={800}
className="w-full h-auto object-contain"
/>
</div>
) : (
<textarea
value={item.content}
onChange={(e) =>
updateTextContent(item.id, e.target.value)
}
disabled={submitting}
placeholder="텍스트를 입력하세요"
rows={4}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 transition-all resize-none"
/>
)}
</div>
<div className="flex flex-col gap-1 pt-2">
<button
type="button"
onClick={() => moveItem(item.id, "up")}
disabled={index === 0 || submitting}
className="p-1 text-gray-600 hover:text-gray-900 disabled:opacity-30 disabled:cursor-not-allowed"
title="위로 이동"
>
<ArrowUp className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => moveItem(item.id, "down")}
disabled={index === items.length - 1 || submitting}
className="p-1 text-gray-600 hover:text-gray-900 disabled:opacity-30 disabled:cursor-not-allowed"
title="아래로 이동"
>
<ArrowDown className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => removeItem(item.id)}
disabled={submitting}
className="p-1 text-red-600 hover:text-red-800 disabled:opacity-50"
title="삭제"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
</div>
))
)}
</div>
{/* 콘텐츠 추가 버튼 */}
<div className="flex gap-2">
<button
type="button"
onClick={() => {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.multiple = true;
input.onchange = (e) => {
const files = Array.from(
(e.target as HTMLInputElement).files || []
);
if (files.length > 0) {
addImages(files);
}
// 같은 파일을 다시 선택해도 이벤트가 발생하도록 value 초기화
input.value = '';
};
input.click();
}}
disabled={submitting}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors disabled:opacity-50 flex items-center gap-2"
>
<Plus className="w-4 h-4" />
</button>
<button
type="button"
onClick={addTextBlock}
disabled={submitting}
className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors disabled:opacity-50 flex items-center gap-2"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* 버튼 */}
<div className="flex gap-4 pt-4">
<button
type="submit"
disabled={submitting}
className="flex-1 px-6 py-3 bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] hover:from-[#6b95c6] hover:to-[#5b85b6] text-white rounded-lg shadow-md hover:shadow-lg transition-all font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? "등록 중..." : "등록하기"}
</button>
<button
type="button"
onClick={() => router.back()}
disabled={submitting}
className="px-6 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors font-semibold disabled:opacity-50"
>
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,182 @@
import { Metadata } from 'next';
import Image from 'next/image';
import { Clock, MapPin } from 'lucide-react';
import youth from '@/public/subpages/generation/youth.webp';
export const metadata: Metadata = {
title: '다음세대',
description: '제자들교회 다음세대 부서를 소개합니다. 유치부, 유초등부, 중고등부, 청년부가 함께 신앙으로 성장하고 있습니다.',
openGraph: {
title: '다음세대 | 제자들교회',
description: '제자들교회 다음세대 - 유치부, 유초등부, 중고등부, 청년부',
},
};
import elementary from '@/public/subpages/generation/elementary.webp';
import highschool from '@/public/subpages/generation/highschool.webp';
import adult from '@/public/subpages/generation/adult.webp';
const DEPARTMENTS = [
{
title: '유치부',
color: '#6d96c5',
image: youth,
worshipTime: '11:00-12:00(주일)',
worshipPlace: '4층 해피키즈',
},
{
title: '유초등부',
color: '#88aad2',
image: elementary,
worshipTime: '11:00-12:00(주일)',
worshipPlace: '4층 해피키즈',
ageRange: '6세 ~ 12세',
},
{
title: '중고등부',
color: '#94b7d6',
image: highschool,
worshipTime: '11:00-12:00(주일)',
worshipPlace: '4층 교육관',
ageRange: '13세 ~ 18세',
},
{
title: '청년부',
color: '#88aad2',
image: adult,
worshipTime: '16:00-17:00(토요일)',
worshipPlace: '4층 교육관',
},
];
export default function GenerationPage() {
return (
<div className="bg-white w-full">
<div className="pt-8 smalltablet:pt-12 pc:pt-16 px-4 smalltablet:px-6 pc:px-8">
<div className="max-w-5xl mx-auto">
{DEPARTMENTS.map((department, index) => (
<div key={index} className="mb-8 smalltablet:mb-16 pc:mb-20 p-8 rounded-2xl bg-gray-100 border border-gray-300">
{/* 섹션 헤더 */}
<div className="text-center mb-8 smalltablet:mb-10 pc:mb-12">
<div
className="w-12 smalltablet:w-14 pc:w-16 h-1 mx-auto mb-3 smalltablet:mb-4"
style={{ backgroundColor: department.color }}
/>
<h2 className="text-2xl smalltablet:text-3xl pc:text-4xl font-bold text-gray-900">
{department.title}
</h2>
</div>
{/* 부서 소개 섹션 - 왼쪽 이미지, 오른쪽 설명 */}
<div className="flex flex-col smalltablet:flex-row items-center justify-center gap-6 smalltablet:gap-8 pc:gap-12">
{/* 왼쪽: 이미지 (3/7) */}
<div className="w-full smalltablet:w-[42.857%] smalltablet:flex-[3]">
<div className="group relative aspect-video rounded-2xl overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-500">
<Image
src={department.image}
alt={department.title}
fill
className="object-cover group-hover:scale-105 transition-transform duration-700"
/>
{/* 이미지 오버레이 */}
<div className="absolute inset-0 bg-linear-to-t from-black/30 via-transparent to-transparent" />
</div>
</div>
{/* 오른쪽: 설명 (4/7) */}
<div className="w-full smalltablet:w-[57.143%] smalltablet:flex-[4]">
<div className="space-y-6 smalltablet:space-y-8 pc:space-y-10">
{/* 예배 정보 */}
{/* 모바일: 한 줄에 배치 */}
<div className="flex items-center gap-3 smalltablet:hidden justify-center">
{/* 예배 시간 */}
<div className="flex items-center gap-2">
<div
className="shrink-0 w-5 h-5 rounded-full flex items-center justify-center"
style={{ backgroundColor: `${department.color}20` }}
>
<Clock
className="w-3 h-3"
style={{ color: department.color }}
/>
</div>
<p className="text-base text-gray-700 font-medium items-center">
{department.worshipTime}
</p>
</div>
{/* 구분선 */}
<div className="w-px h-4 bg-gray-300" />
{/* 예배 장소 */}
<div className="flex items-center gap-2">
<div
className="shrink-0 w-5 h-5 rounded-full flex items-center justify-center"
style={{ backgroundColor: `${department.color}20` }}
>
<MapPin
className="w-3 h-3"
style={{ color: department.color }}
/>
</div>
<p className="text-base text-gray-700 font-medium">
{department.worshipPlace}
</p>
</div>
</div>
{/* 태블릿 이상: 세로로 배치 */}
<div className="hidden smalltablet:block space-y-5 pc:space-y-6">
{/* 예배 시간 */}
<div className="flex items-start gap-4 pc:gap-5 group/item">
{/* 시계 아이콘 */}
<div
className="shrink-0 w-7 h-7 pc:w-9 pc:h-9 rounded-full flex items-center justify-center mt-0.5 pc:mt-1 transition-transform group-hover/item:scale-110"
style={{ backgroundColor: `${department.color}20` }}
>
<Clock
className="w-4 h-4 pc:w-5 pc:h-5"
style={{ color: department.color }}
/>
</div>
{/* 예배 시간 텍스트 */}
<div className="flex-1 items-center">
<p className="text-base pc:text-lg text-gray-500 mb-1 pc:mb-1.5"> </p>
<p className="text-lg pc:text-2xl text-gray-700 leading-relaxed font-medium">
{department.worshipTime}
</p>
</div>
</div>
{/* 예배 장소 */}
<div className="flex items-start gap-4 pc:gap-5 group/item">
{/* 위치 아이콘 */}
<div
className="shrink-0 w-7 h-7 pc:w-9 pc:h-9 rounded-full flex items-center justify-center mt-0.5 pc:mt-1 transition-transform group-hover/item:scale-110"
style={{ backgroundColor: `${department.color}20` }}
>
<MapPin
className="w-4 h-4 pc:w-5 pc:h-5"
style={{ color: department.color }}
/>
</div>
{/* 예배 장소 텍스트 */}
<div className="flex-1">
<p className="text-base pc:text-lg text-gray-500 mb-1 pc:mb-1.5"> </p>
<p className="text-lg pc:text-2xl text-gray-700 leading-relaxed font-medium">
{department.worshipPlace}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { Metadata } from 'next';
export const metadata: Metadata = {
title: '예배 영상',
description: '제자들교회의 주일 설교와 금요 성령집회 영상을 시청하실 수 있습니다. 말씀을 통한 은혜를 경험하세요.',
openGraph: {
title: '예배 영상 | 제자들교회',
description: '제자들교회의 주일 설교와 금요 성령집회 영상을 시청하실 수 있습니다.',
},
};
export default function WorshipLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@@ -0,0 +1,465 @@
'use client';
import React, { useState, useEffect, useCallback, useRef, Suspense } from 'react';
import Image from 'next/image';
import { useSearchParams } from 'next/navigation';
import { swapWorshipVideos } from '@/lib/services';
import { useAuth } from '@/hooks';
import { extractYouTubeId, getYouTubeThumbnailUrl } from '@/lib/utils/youtube';
import { ArrowUp, ArrowDown } from 'lucide-react';
interface VideoItem {
id: number;
videoUrl: string;
category: string;
order: number;
createdAt: string;
}
interface Category {
id: string;
title: string;
videos: VideoItem[];
}
function WorshipPageContent() {
const searchParams = useSearchParams();
const categoryRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
const playerRef = useRef<HTMLDivElement | null>(null);
const [categories, setCategories] = useState<Category[]>([
{ id: 'sermon', title: '주일 설교', videos: [] },
{ id: 'friday', title: '금요 성령집회', videos: [] },
]);
const [selectedVideo, setSelectedVideo] = useState<{ videoUrl: string; title: string }>({
videoUrl: 'https://www.youtube.com/watch?v=A8xPDnTkNzI',
title: '',
});
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [addingCategory, setAddingCategory] = useState<string>('');
const [newVideoUrl, setNewVideoUrl] = useState('');
const [isLoading, setIsLoading] = useState(true);
const { user } = useAuth();
useEffect(() => {
loadVideos();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadVideos = async () => {
try {
const response = await fetch('/api/worship');
if (!response.ok) throw new Error('Failed to fetch videos');
const result = await response.json();
const dbVideos: VideoItem[] = result.data || [];
// 카테고리별로 그룹화 (order 필드로 내림차순 정렬 - 높은 order가 앞으로)
const newCategories: Category[] = [
{
id: 'sermon',
title: '주일 설교',
videos: dbVideos
.filter(v => v.category === 'sermon')
.sort((a, b) => b.order - a.order)
},
{
id: 'friday',
title: '금요 성령집회',
videos: dbVideos
.filter(v => v.category === 'friday')
.sort((a, b) => b.order - a.order)
},
];
setCategories(newCategories);
// URL 쿼리 파라미터에서 category 확인
const categoryParam = searchParams?.get('category');
// category 파라미터가 있으면 해당 카테고리의 첫 번째 비디오 선택
if (categoryParam) {
const targetCategory = newCategories.find(cat => cat.id === categoryParam);
if (targetCategory && targetCategory.videos.length > 0) {
setSelectedVideo({
videoUrl: targetCategory.videos[0].videoUrl,
title: targetCategory.title,
});
// 카테고리로 스크롤 (약간의 지연을 두어 DOM이 업데이트된 후 스크롤)
setTimeout(() => {
const targetElement = categoryRefs.current[categoryParam];
if (targetElement) {
targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 100);
} else {
// 해당 카테고리에 비디오가 없으면 첫 번째 비디오 선택
const firstCategoryWithVideo = newCategories.find(cat => cat.videos.length > 0);
if (firstCategoryWithVideo && firstCategoryWithVideo.videos[0]) {
setSelectedVideo({
videoUrl: firstCategoryWithVideo.videos[0].videoUrl,
title: firstCategoryWithVideo.title,
});
}
}
} else {
// category 파라미터가 없으면 첫 번째 비디오 선택
const firstCategoryWithVideo = newCategories.find(cat => cat.videos.length > 0);
if (firstCategoryWithVideo && firstCategoryWithVideo.videos[0]) {
setSelectedVideo({
videoUrl: firstCategoryWithVideo.videos[0].videoUrl,
title: firstCategoryWithVideo.title,
});
}
}
setIsLoading(false);
} catch (error) {
console.error('Error loading videos:', error);
setIsLoading(false);
}
};
const handleDelete = async (video: VideoItem, e: React.MouseEvent) => {
e.stopPropagation();
if (!user) {
alert('로그인이 필요합니다.');
return;
}
if (!confirm('정말 삭제하시겠습니까?')) return;
try {
const response = await fetch(`/api/worship?id=${video.id}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete video');
// 로컬 state 업데이트
setCategories(prev => prev.map(category => {
if (category.id === video.category) {
return {
...category,
videos: category.videos.filter(v => v.id !== video.id)
};
}
return category;
}));
} catch (error) {
console.error('Error deleting video:', error);
alert('영상 삭제에 실패했습니다.');
}
};
const handleAddVideo = (categoryId: string) => {
if (!user) {
alert('로그인이 필요합니다.');
return;
}
setAddingCategory(categoryId);
setNewVideoUrl('');
setIsAddModalOpen(true);
};
const handleSaveNewVideo = async () => {
if (!addingCategory || !newVideoUrl) {
alert('YouTube URL을 입력해주세요.');
return;
}
try {
const response = await fetch('/api/worship', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
category: addingCategory,
videoUrl: newVideoUrl
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || error.message || 'Failed to add video');
}
const result = await response.json();
const newVideo = result.data;
// 로컬 state 업데이트 - 새 영상이 가장 앞으로 가도록 전체 목록을 다시 정렬
setCategories(prev => prev.map(category => {
if (category.id === addingCategory) {
const updatedVideos = [...category.videos, newVideo];
// order 기준 내림차순 정렬 (높은 order가 앞으로)
return {
...category,
videos: updatedVideos.sort((a, b) => b.order - a.order)
};
}
return category;
}));
setIsAddModalOpen(false);
setAddingCategory('');
setNewVideoUrl('');
} catch (error) {
console.error('Error adding video:', error);
alert(error instanceof Error ? error.message : '영상 추가에 실패했습니다.');
}
};
const moveVideo = async (categoryId: string, videoId: number, direction: 'up' | 'down') => {
if (!user) {
alert('로그인이 필요합니다.');
return;
}
const category = categories.find(cat => cat.id === categoryId);
if (!category) return;
const videoIndex = category.videos.findIndex(v => v.id === videoId);
if (videoIndex === -1) return;
// 이동할 새 인덱스 계산
const newIndex = direction === 'up' ? videoIndex - 1 : videoIndex + 1;
// 범위 체크
if (newIndex < 0 || newIndex >= category.videos.length) return;
// 교환할 두 비디오의 ID
const video1Id = category.videos[videoIndex].id;
const video2Id = category.videos[newIndex].id;
// 낙관적 업데이트 (UI 즉시 반영)
const newVideos = [...category.videos];
[newVideos[videoIndex], newVideos[newIndex]] = [newVideos[newIndex], newVideos[videoIndex]];
setCategories(prev => prev.map(cat =>
cat.id === categoryId ? { ...cat, videos: newVideos } : cat
));
try {
// 서버에 순서 변경 요청 (두 비디오만 교환)
const updatedVideos = await swapWorshipVideos(video1Id, video2Id);
// 서버 응답으로 상태 업데이트 (order 값이 정확히 반영됨)
setCategories(prev => prev.map(cat =>
cat.id === categoryId ? { ...cat, videos: updatedVideos } : cat
));
} catch (error) {
console.error('Error swapping videos:', error);
// 실패 시 원래 상태로 롤백
setCategories(prev => prev.map(cat =>
cat.id === categoryId ? { ...cat, videos: category.videos } : cat
));
alert('영상 순서 변경에 실패했습니다.');
}
};
if (isLoading) {
return (
<div className="min-h-screen bg-white w-full flex items-center justify-center">
<div className="text-xl text-gray-600"> ...</div>
</div>
);
}
// selectedVideo의 videoUrl에서 embed용 ID 추출
const embedVideoId = extractYouTubeId(selectedVideo.videoUrl);
return (
<div className="w-full">
<div className="py-4 smalltablet:py-8 px-3 smalltablet:px-6 pc:px-8 max-w-7xl mx-auto">
{/* Main YouTube Player */}
<div ref={playerRef} className="mb-6 smalltablet:mb-8">
<div className="aspect-video w-full bg-black rounded-md smalltablet:rounded-lg overflow-hidden shadow-lg">
<iframe
width="100%"
height="100%"
src={`https://www.youtube.com/embed/${embedVideoId}`}
title={selectedVideo.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="w-full h-full"
/>
</div>
</div>
{/* Video Categories */}
{categories.map((category) => (
<div
key={category.id}
ref={(el) => { categoryRefs.current[category.id] = el; }}
className="mb-8 smalltablet:mb-12"
>
<div className="flex flex-col smalltablet:flex-row smalltablet:items-center smalltablet:justify-between gap-3 smalltablet:gap-0 mb-4 smalltablet:mb-6 border-b-2 border-gray-200 pb-3">
<h3 className="text-xl smalltablet:text-2xl font-bold text-gray-800">
{category.title}
</h3>
{user && (
<button
onClick={() => handleAddVideo(category.id)}
className="bg-[#6d96c5] hover:bg-[#88aad2] text-white px-3 smalltablet:px-4 py-2 rounded-lg font-medium transition-colors flex items-center justify-center gap-2 text-sm smalltablet:text-base"
>
<span>+</span>
<span> </span>
</button>
)}
</div>
<div className="grid grid-cols-2 smalltablet:grid-cols-2 pc:grid-cols-3 gap-4 smalltablet:gap-6">
{category.videos.map((video, index) => (
<div
key={video.id}
className="group relative rounded-2xl overflow-hidden bg-white shadow-lg hover:shadow-2xl transition-shadow duration-300"
>
<div
onClick={() => {
setSelectedVideo({ videoUrl: video.videoUrl, title: category.title });
// 상단 재생 영역으로 스크롤 (약간의 여백 추가)
setTimeout(() => {
if (playerRef.current) {
const elementTop = playerRef.current.getBoundingClientRect().top + window.pageYOffset;
const offset = 80; // 상단에서 80px 위로
window.scrollTo({ top: elementTop - offset, behavior: 'smooth' });
}
}, 100);
}}
className="relative aspect-video bg-linear-to-br from-gray-800 to-gray-900 overflow-hidden cursor-pointer"
>
<Image
src={getYouTubeThumbnailUrl(video.videoUrl)}
alt={category.title}
fill
className="object-cover pc:group-hover:scale-105 transition-transform duration-500"
unoptimized
/>
{/* 재생 오버레이 */}
<div className="absolute inset-0 bg-black/0 pc:group-hover:bg-black/10 transition-colors duration-300" />
{/* 재생 아이콘 */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-12 h-12 smalltablet:w-14 smalltablet:h-14 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center shadow-xl pc:group-hover:scale-110 pc:group-hover:bg-white transition-all duration-300">
<div className="w-0 h-0 border-l-12 smalltablet:border-l-14 border-l-gray-800 border-t-7 smalltablet:border-t-8 border-t-transparent border-b-7 smalltablet:border-b-8 border-b-transparent ml-1" />
</div>
</div>
</div>
{/* Action Buttons - 로그인한 사용자만 표시 */}
{user && (
<div className="flex flex-col p-4 smalltablet:flex-row smalltablet:items-center smalltablet:justify-between smalltablet:gap-2 smalltablet:p-3 bg-linear-to-br from-slate-50 via-white to-slate-50 border-t border-gray-100">
<div className="grid grid-cols-3 gap-1.5 w-full smalltablet:flex smalltablet:gap-2 smalltablet:w-auto">
<button
onClick={(e) => {
e.stopPropagation();
moveVideo(category.id, video.id, 'up');
}}
disabled={index === 0}
className={`flex items-center justify-center rounded-lg min-h-[32px] smalltablet:rounded-xl smalltablet:min-w-[40px] smalltablet:min-h-[40px] transition-all font-medium text-white shadow-md active:scale-95 ${
index === 0
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'bg-[#88aad2] hover:bg-[#94b7d6] pc:hover:shadow-lg'
}`}
aria-label="위로 이동"
>
<ArrowUp className="cursor-pointer w-4 h-4 smalltablet:w-5 smalltablet:h-5" strokeWidth={2.5} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
moveVideo(category.id, video.id, 'down');
}}
disabled={index === category.videos.length - 1}
className={`flex items-center justify-center rounded-lg min-h-[32px] smalltablet:rounded-xl smalltablet:min-w-[40px] smalltablet:min-h-[40px] transition-all font-medium text-white shadow-md active:scale-95 ${
index === category.videos.length - 1
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'bg-[#94b7d6] hover:bg-[#a9c6e1] pc:hover:shadow-lg'
}`}
aria-label="아래로 이동"
>
<ArrowDown className="cursor-pointer w-4 h-4 smalltablet:w-5 smalltablet:h-5" strokeWidth={2.5} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete(video, e);
}}
className="cursor-pointer flex flex-col items-center justify-center gap-0.5 bg-red-400 hover:bg-red-500 pc:hover:shadow-lg text-white rounded-lg font-semibold text-xs shadow-md active:scale-95 transition-all min-h-[32px] smalltablet:rounded-xl smalltablet:min-w-[40px] smalltablet:min-h-[40px]"
aria-label="영상 삭제"
>
<span className="hidden smalltablet:inline"></span>
<span className="smalltablet:hidden">X</span>
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
{/* Add Modal */}
{isAddModalOpen && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg p-6 smalltablet:p-8 max-w-md w-full shadow-2xl">
<h3 className="text-xl smalltablet:text-2xl font-bold mb-4 smalltablet:mb-6 text-gray-800"> </h3>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
YouTube URL
</label>
<input
type="text"
value={newVideoUrl}
onChange={(e) => setNewVideoUrl(e.target.value)}
placeholder="https://www.youtube.com/watch?v=..."
className="w-full px-3 smalltablet:px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent outline-none text-sm smalltablet:text-base"
/>
<p className="mt-2 text-xs text-gray-500">
예시: https://www.youtube.com/watch?v=A8xPDnTkNzI
</p>
</div>
<div className="flex flex-col-reverse smalltablet:flex-row gap-2 smalltablet:gap-3 smalltablet:justify-end">
<button
onClick={() => {
setIsAddModalOpen(false);
setAddingCategory('');
setNewVideoUrl('');
}}
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 rounded-lg font-medium transition-colors text-sm smalltablet:text-base"
>
</button>
<button
onClick={handleSaveNewVideo}
className="px-4 py-2 bg-[#6d96c5] hover:bg-[#88aad2] text-white rounded-lg font-medium transition-colors text-sm smalltablet:text-base"
>
</button>
</div>
</div>
</div>
)}
</div>
);
}
export default function WorshipPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-white w-full flex items-center justify-center">
<div className="text-xl text-gray-600"> ...</div>
</div>
}>
<WorshipPageContent />
</Suspense>
);
}

View File

@@ -0,0 +1,20 @@
import React from "react";
import SubNavbar from "@/components/widgets/SubNavbar";
export default function SubPagesLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<div>
<SubNavbar />
<div className="flex items-center justify-center">
<div className="w-full flex items-center justify-center">
{children}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
import { s3Client, S3_CONFIG } from '@/const';
import { generateSignedUrl } from '@/lib/s3';
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp'];
// GET: 특정 공지사항 조회
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id: idParam } = await params;
const id = parseInt(idParam);
if (isNaN(id)) {
return NextResponse.json(
{ success: false, message: '유효하지 않은 ID입니다.' },
{ status: 400 }
);
}
const announcement = await prisma.announcement.update({
where: { id },
data: { viewCount: { increment: 1 } },
include: {
author: {
select: {
id: true,
userId: true,
userName: true,
},
},
files: true,
},
});
// 이미지 파일에 signedUrl 추가
const filesWithUrls = await Promise.all(
(announcement.files || []).map(async (file) => {
const ext = file.fileName.split('.').pop()?.toLowerCase();
const isImage = IMAGE_EXTENSIONS.includes(ext || '');
return {
...file,
signedUrl: isImage ? await generateSignedUrl(file.fileKey) : undefined,
};
})
);
return NextResponse.json({
success: true,
data: {
...announcement,
files: filesWithUrls,
},
});
} catch (err) {
console.error('Get announcement error:', err);
const errorMessage = err instanceof Error ? err.message : '공지사항 조회에 실패했습니다.';
return NextResponse.json(
{ success: false, message: errorMessage },
{ status: 500 }
);
}
}
// DELETE: 공지사항 삭제
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id: idParam } = await params;
const id = parseInt(idParam);
if (isNaN(id)) {
return NextResponse.json(
{ success: false, message: '유효하지 않은 ID입니다.' },
{ status: 400 }
);
}
// 1. 공지사항 및 첨부파일 조회
const announcement = await prisma.announcement.findUnique({
where: { id },
include: {
files: true,
},
});
if (!announcement) {
return NextResponse.json(
{ success: false, message: '공지사항을 찾을 수 없습니다.' },
{ status: 404 }
);
}
// 2. S3에서 첨부파일 삭제
if (announcement.files && announcement.files.length > 0) {
await Promise.all(
announcement.files.map((file) =>
s3Client.send(
new DeleteObjectCommand({
Bucket: S3_CONFIG.BUCKET_NAME,
Key: file.fileKey,
})
)
)
);
}
// 3. DB에서 삭제 (files는 onDelete: Cascade로 자동 삭제됨)
await prisma.announcement.delete({
where: { id },
});
return NextResponse.json({
success: true,
message: '삭제되었습니다.',
});
} catch (err) {
console.error('Delete announcement error:', err);
const errorMessage = err instanceof Error ? err.message : '삭제에 실패했습니다.';
return NextResponse.json(
{ success: false, message: errorMessage },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { getPaginationParams, createPaginatedResponse } from '@/lib/utils';
// GET: 공지사항 조회 (pagination 지원)
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const { page, limit, skip } = getPaginationParams(searchParams, 10);
const [announcements, total] = await Promise.all([
prisma.announcement.findMany({
include: {
author: {
select: {
id: true,
userId: true,
userName: true,
},
},
},
orderBy: { createdAt: 'desc' },
skip,
take: limit,
}),
prisma.announcement.count(),
]);
return NextResponse.json({
success: true,
...createPaginatedResponse(announcements, total, page, limit),
});
} catch (err) {
console.error('Get announcements error:', err);
const errorMessage = err instanceof Error ? err.message : '공지사항 조회에 실패했습니다.';
return NextResponse.json(
{ success: false, error: errorMessage },
{ status: 500 }
);
}
}
// POST: 새로운 공지사항 생성
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { title, content, isImportant, authorId, files } = body;
if (!title || !authorId) {
return NextResponse.json(
{ success: false, message: '필수 필드가 누락되었습니다.' },
{ status: 400 }
);
}
const announcement = await prisma.announcement.create({
data: {
title,
content: content || "",
isImportant: isImportant || false,
authorId,
files: files && files.length > 0 ? {
create: files.map((file: { fileKey: string; fileName: string; fileSize?: number; mimeType?: string }) => ({
fileKey: file.fileKey,
fileName: file.fileName,
fileSize: file.fileSize,
mimeType: file.mimeType,
}))
} : undefined,
},
include: {
author: {
select: {
id: true,
userId: true,
userName: true,
},
},
files: true,
},
});
return NextResponse.json({
success: true,
data: announcement,
}, { status: 201 });
} catch (err) {
console.error('Create announcement error:', err);
const errorMessage = err instanceof Error ? err.message : '공지사항 생성에 실패했습니다.';
return NextResponse.json(
{ success: false, message: errorMessage },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,4 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

View File

@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
export async function GET() {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json(
{ success: false, message: "로그인 필요", user: null },
{ status: 401 }
);
}
return NextResponse.json({
success: true,
data: {
id: parseInt(session.user.id),
userId: session.user.userId,
userName: session.user.name,
},
});
} catch (error) {
console.error("Get user error:", error);
return NextResponse.json(
{ success: false, message: "서버 오류가 발생했습니다.", user: null },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,95 @@
import { NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
export async function POST(req: Request) {
try {
const {
userId,
userPassword,
userCheckPassword,
userName,
userPhone,
authCode,
} = await req.json();
// 유효성 검사
if (!userId || !userPassword || !userCheckPassword || !userName || !userPhone || !authCode) {
return NextResponse.json(
{ success: false, message: "필수 정보를 모두 입력해주세요." },
{ status: 400 }
);
}
// 승인번호 검사
if (authCode !== process.env.CODE) {
return NextResponse.json(
{ success: false, message: "승인번호가 올바르지 않습니다." },
{ status: 400 }
);
}
// 비밀번호 확인
if (userPassword !== userCheckPassword) {
return NextResponse.json(
{ success: false, message: "비밀번호가 일치하지 않습니다." },
{ status: 400 }
);
}
// 중복 체크: 아이디
const existingUser = await prisma.user.findUnique({
where: { userId },
});
if (existingUser) {
return NextResponse.json(
{ success: false, message: "이미 사용 중인 아이디입니다." },
{ status: 409 }
);
}
// 중복 체크: 전화번호
const existingPhone = await prisma.user.findUnique({
where: { userPhone },
});
if (existingPhone) {
return NextResponse.json(
{ success: false, message: "이미 등록된 전화번호입니다." },
{ status: 409 }
);
}
// 비밀번호 해싱
const hashedPassword = await bcrypt.hash(userPassword, 10);
// 새로운 유저 생성
const newUser = await prisma.user.create({
data: {
userId,
userPassword: hashedPassword,
userName,
userPhone,
},
});
return NextResponse.json(
{
success: true,
data: {
userId: newUser.userId,
userName: newUser.userName,
},
},
{ status: 201 }
);
} catch (err) {
console.error("Signup error:", err);
return NextResponse.json(
{ success: false, message: "서버 오류가 발생했습니다." },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// PUT: 영상 순서 변경 (두 비디오의 order만 교환)
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
const { videoId1, videoId2 } = body;
// 입력 검증
if (!videoId1 || !videoId2) {
return NextResponse.json(
{ success: false, message: "두 개의 비디오 ID가 필요합니다." },
{ status: 400 }
);
}
if (videoId1 === videoId2) {
return NextResponse.json(
{ success: false, message: "동일한 비디오는 교환할 수 없습니다." },
{ status: 400 }
);
}
// 두 비디오 조회
const [video1, video2] = await Promise.all([
prisma.discipleVideo.findUnique({ where: { id: Number(videoId1) } }),
prisma.discipleVideo.findUnique({ where: { id: Number(videoId2) } }),
]);
if (!video1 || !video2) {
return NextResponse.json(
{ success: false, message: "비디오를 찾을 수 없습니다." },
{ status: 404 }
);
}
// 같은 stage/step인지 확인
if (video1.stage !== video2.stage || video1.step !== video2.step) {
return NextResponse.json(
{ success: false, message: "같은 stage/step의 비디오만 교환할 수 있습니다." },
{ status: 400 }
);
}
await prisma.$transaction([
prisma.discipleVideo.update({
where: { id: video1.id },
data: { order: -1 }, // 임시 음수 값 (unique constraint 회피)
}),
prisma.discipleVideo.update({
where: { id: video2.id },
data: { order: video1.order },
}),
prisma.discipleVideo.update({
where: { id: video1.id },
data: { order: video2.order },
}),
]);
// 업데이트된 stage/step의 모든 비디오 반환
const updatedVideos = await prisma.discipleVideo.findMany({
where: { stage: video1.stage, step: video1.step },
orderBy: { order: 'desc' },
});
return NextResponse.json({
success: true,
data: updatedVideos,
});
} catch (error) {
console.error("Error reordering disciple videos:", error);
const errorMessage = error instanceof Error ? error.message : "제자훈련 영상 순서 변경에 실패했습니다.";
return NextResponse.json(
{ success: false, message: errorMessage },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,148 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { isValidYouTubeUrl, getYouTubeThumbnailUrl, getYouTubeEmbedUrl } from "@/lib/utils/youtube";
// GET: 모든 제자훈련 영상 또는 특정 stage 영상 가져오기
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const stage = searchParams.get('stage');
if (stage) {
const videos = await prisma.discipleVideo.findMany({
where: { stage },
orderBy: [
{ step: 'asc' },
{ order: 'desc' },
],
});
// 썸네일 및 embed URL 추가
const videosWithUrls = videos.map(video => ({
...video,
thumbnailUrl: getYouTubeThumbnailUrl(video.videoUrl),
embedUrl: getYouTubeEmbedUrl(video.videoUrl),
}));
return NextResponse.json({
success: true,
data: videosWithUrls,
});
}
const videos = await prisma.discipleVideo.findMany({
orderBy: [
{ stage: 'asc' },
{ step: 'asc' },
{ order: 'desc' },
],
});
// 썸네일 및 embed URL 추가
const videosWithUrls = videos.map(video => ({
...video,
thumbnailUrl: getYouTubeThumbnailUrl(video.videoUrl),
embedUrl: getYouTubeEmbedUrl(video.videoUrl),
}));
return NextResponse.json({
success: true,
data: videosWithUrls,
});
} catch (error) {
console.error("Error fetching disciple videos:", error);
const errorMessage = error instanceof Error ? error.message : "제자훈련 영상 조회에 실패했습니다.";
return NextResponse.json(
{ success: false, error: errorMessage },
{ status: 500 }
);
}
}
// POST: 새 영상 추가
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { stage, step, videoUrl } = body;
if (!stage || typeof stage !== 'string') {
return NextResponse.json(
{ success: false, message: "stage가 유효하지 않습니다." },
{ status: 400 }
);
}
if (!videoUrl || typeof videoUrl !== 'string') {
return NextResponse.json(
{ success: false, message: "영상 URL이 유효하지 않습니다." },
{ status: 400 }
);
}
// YouTube URL 유효성 검사
if (!isValidYouTubeUrl(videoUrl)) {
return NextResponse.json(
{ success: false, message: "유효한 YouTube URL이 아닙니다." },
{ status: 400 }
);
}
// stage/step 내 기존 영상 확인하여 최고 order 값 가져오기
const existingVideo = await prisma.discipleVideo.findFirst({
where: { stage, step: step || null },
orderBy: { order: 'desc' },
});
// 새 영상은 현재 최고 order + 1로 설정 (맨 앞에 추가)
const maxOrder = existingVideo?.order ?? 0;
const newOrder = maxOrder + 1;
const newVideo = await prisma.discipleVideo.create({
data: {
stage,
step: step || null,
videoUrl,
order: newOrder,
},
});
return NextResponse.json({
success: true,
data: newVideo,
}, { status: 201 });
} catch (error) {
console.error("Error creating disciple video:", error);
const errorMessage = error instanceof Error ? error.message : "제자훈련 영상 생성에 실패했습니다.";
return NextResponse.json(
{ success: false, message: errorMessage },
{ status: 500 }
);
}
}
// DELETE: 영상 삭제
export async function DELETE(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const id = searchParams.get('id');
if (!id || isNaN(Number(id))) {
return NextResponse.json(
{ success: false, message: "영상 ID가 유효하지 않습니다." },
{ status: 400 }
);
}
await prisma.discipleVideo.delete({
where: { id: Number(id) },
});
return NextResponse.json({
success: true,
message: "영상이 삭제되었습니다."
});
} catch (error) {
console.error("Error deleting disciple video:", error);
const errorMessage = error instanceof Error ? error.message : "제자훈련 영상 삭제에 실패했습니다.";
return NextResponse.json(
{ success: false, message: errorMessage },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import { generateSignedUrl } from '@/lib/s3';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { fileKey, fileName } = body;
if (!fileKey) {
return NextResponse.json(
{ success: false, message: 'fileKey가 필요합니다.' },
{ status: 400 }
);
}
const downloadUrl = await generateSignedUrl(fileKey, { fileName });
return NextResponse.json({
success: true,
data: {
downloadUrl,
},
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '다운로드 URL 생성에 실패했습니다.';
return NextResponse.json(
{ success: false, message: errorMessage },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server';
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { S3_CONFIG, s3Client } from '@/const';
import mime from 'mime-types';
// 파일 업로드 URL 생성
const generateUploadUrl = async (
fileName: string,
fileType: string,
folder?: string
): Promise<{ uploadUrl: string; fileKey: string }> => {
try {
const fileKey = folder ? `Jaejadle/${folder}/${Date.now()}-${fileName}` : `Jaejadle/${Date.now()}-${fileName}`;
const command = new PutObjectCommand({
Bucket: S3_CONFIG.BUCKET_NAME,
Key: fileKey,
ContentType: fileType,
});
const uploadUrl = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
return {
uploadUrl,
fileKey,
};
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '알 수 없는 오류';
throw new Error(`업로드 URL 생성에 실패했습니다: ${errorMessage}`);
}
};
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { fileName, fileType } = body;
let { folder = 'uploads' } = body;
// folder에서 시작/끝 슬래시 제거
if (folder) {
folder = folder.replace(/^\/+|\/+$/g, '');
}
if (!fileName) {
return NextResponse.json(
{ success: false, message: 'fileName이 필요합니다.' },
{ status: 400 }
);
}
// fileType이 없으면 파일 확장자로부터 MIME type 추론
const contentType = fileType || mime.lookup(fileName) || 'application/octet-stream';
const { uploadUrl, fileKey } = await generateUploadUrl(fileName, contentType, folder);
return NextResponse.json({
success: true,
data: {
uploadUrl,
fileKey,
},
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '업로드 URL 생성에 실패했습니다.';
return NextResponse.json(
{ success: false, message: errorMessage },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,158 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
import { s3Client, S3_CONFIG } from '@/const';
import { generateSignedUrl } from '@/lib/s3';
interface GalleryImage {
id: number;
fileKey: string;
postId: number;
order: number;
aspectRatio: number | null;
createdAt: Date;
updatedAt: Date;
}
interface GalleryTextBlock {
id: number;
postId: number;
content: string;
order: number;
createdAt: Date;
updatedAt: Date;
}
type ContentItem =
| { type: 'image'; data: GalleryImage & { displayUrl: string } }
| { type: 'text'; data: GalleryTextBlock };
// GET: 갤러리 포스트 상세 조회
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const postId = parseInt(id, 10);
if (isNaN(postId)) {
return NextResponse.json(
{ success: false, message: '유효하지 않은 ID입니다.' },
{ status: 400 }
);
}
const post = await prisma.galleryPost.findUnique({
where: { id: postId },
include: {
images: {
orderBy: { order: 'asc' },
},
textBlocks: {
orderBy: { order: 'asc' },
},
},
});
if (!post) {
return NextResponse.json(
{ success: false, message: '갤러리를 찾을 수 없습니다.' },
{ status: 404 }
);
}
// 이미지에 displayUrl 추가
const imagesWithUrls = await Promise.all(
post.images.map(async (img) => ({
...img,
displayUrl: await generateSignedUrl(img.fileKey),
}))
);
// 이미지와 텍스트 블록을 order 순서로 정렬하여 반환
const sortedContent: ContentItem[] = [
...imagesWithUrls.map((img) => ({ type: 'image' as const, data: img })),
...(post.textBlocks || []).map((text) => ({ type: 'text' as const, data: text })),
].sort((a, b) => a.data.order - b.data.order);
return NextResponse.json({
success: true,
data: {
...post,
images: imagesWithUrls,
sortedContent,
},
});
} catch (err) {
console.error('Get gallery post error:', err);
const errorMessage = err instanceof Error ? err.message : '갤러리 조회에 실패했습니다.';
return NextResponse.json(
{ success: false, error: errorMessage },
{ status: 500 }
);
}
}
// DELETE: 갤러리 포스트 삭제
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const postId = parseInt(id, 10);
if (isNaN(postId)) {
return NextResponse.json(
{ success: false, message: '유효하지 않은 ID입니다.' },
{ status: 400 }
);
}
// 1. 포스트와 이미지 정보 먼저 조회
const post = await prisma.galleryPost.findUnique({
where: { id: postId },
include: {
images: true,
textBlocks: true,
},
});
if (!post) {
return NextResponse.json(
{ success: false, message: '갤러리를 찾을 수 없습니다.' },
{ status: 404 }
);
}
// 2. S3에서 이미지 삭제
await Promise.all(
post.images.map((image: { fileKey: string }) =>
s3Client.send(
new DeleteObjectCommand({
Bucket: S3_CONFIG.BUCKET_NAME,
Key: image.fileKey,
})
)
)
);
// 3. DB에서 포스트 삭제 (cascade로 이미지도 삭제됨)
await prisma.galleryPost.delete({
where: { id: postId },
});
return NextResponse.json({
success: true,
message: '갤러리가 삭제되었습니다.',
});
} catch (err) {
console.error('Delete gallery post error:', err);
const errorMessage = err instanceof Error ? err.message : '갤러리 삭제에 실패했습니다.';
return NextResponse.json(
{ success: false, message: errorMessage },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,135 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { getPaginationParams, createPaginatedResponse } from '@/lib/utils';
import { generateSignedUrl } from '@/lib/s3';
interface GalleryItem {
type: 'image' | 'text';
fileKey?: string;
content?: string;
order: number;
aspectRatio?: number | null;
}
// GET: 갤러리 포스트 목록 조회 (pagination 지원)
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const { page, limit, skip } = getPaginationParams(searchParams, 12);
const [posts, total] = await Promise.all([
prisma.galleryPost.findMany({
orderBy: { createdAt: 'desc' },
skip,
take: limit,
include: {
images: {
take: 1,
orderBy: { order: 'asc' },
},
},
}),
prisma.galleryPost.count(),
]);
// 썸네일 URL 생성
const postsWithThumbnails = await Promise.all(
posts.map(async (post) => ({
...post,
thumbnailUrl: post.images[0]
? await generateSignedUrl(post.images[0].fileKey)
: undefined,
}))
);
return NextResponse.json({
success: true,
...createPaginatedResponse(postsWithThumbnails, total, page, limit),
});
} catch (err) {
console.error('Get gallery posts error:', err);
const errorMessage = err instanceof Error ? err.message : '갤러리 조회에 실패했습니다.';
return NextResponse.json(
{ success: false, error: errorMessage },
{ status: 500 }
);
}
}
// POST: 새로운 갤러리 포스트 생성
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { title, content, items } = body;
if (!title) {
return NextResponse.json(
{ success: false, message: '제목이 필요합니다.' },
{ status: 400 }
);
}
if (!items || items.length === 0) {
return NextResponse.json(
{ success: false, message: '최소 1개 이상의 콘텐츠가 필요합니다.' },
{ status: 400 }
);
}
// 이미지가 최소 1개는 있어야 함
const hasImage = items.some((item: GalleryItem) => item.type === 'image');
if (!hasImage) {
return NextResponse.json(
{ success: false, message: '최소 1개 이상의 이미지가 필요합니다.' },
{ status: 400 }
);
}
// items를 order 순서대로 정렬
const sortedItems = [...items].sort((a, b) => a.order - b.order);
const post = await prisma.galleryPost.create({
data: {
title,
content: content || '',
images: {
create: sortedItems
.filter((item: GalleryItem) => item.type === 'image')
.map((item: GalleryItem) => ({
fileKey: item.fileKey!,
order: item.order,
aspectRatio: item.aspectRatio || null,
})),
},
textBlocks: {
create: sortedItems
.filter((item: GalleryItem) => item.type === 'text')
.map((item: GalleryItem) => ({
content: item.content!,
order: item.order,
})),
},
},
include: {
images: {
orderBy: { order: 'asc' },
},
textBlocks: {
orderBy: { order: 'asc' },
},
},
});
return NextResponse.json({
success: true,
data: post,
}, { status: 201 });
} catch (err) {
console.error('Create gallery post error:', err);
const errorMessage = err instanceof Error ? err.message : '갤러리 생성에 실패했습니다.';
return NextResponse.json(
{ success: false, message: errorMessage },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// PUT: 영상 순서 변경 (두 비디오의 order만 교환)
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
const { videoId1, videoId2 } = body;
// 입력 검증
if (!videoId1 || !videoId2) {
return NextResponse.json(
{ success: false, message: "두 개의 비디오 ID가 필요합니다." },
{ status: 400 }
);
}
if (videoId1 === videoId2) {
return NextResponse.json(
{ success: false, message: "동일한 비디오는 교환할 수 없습니다." },
{ status: 400 }
);
}
// 두 비디오 조회
const [video1, video2] = await Promise.all([
prisma.worshipVideo.findUnique({ where: { id: Number(videoId1) } }),
prisma.worshipVideo.findUnique({ where: { id: Number(videoId2) } }),
]);
if (!video1 || !video2) {
return NextResponse.json(
{ success: false, message: "비디오를 찾을 수 없습니다." },
{ status: 404 }
);
}
// 같은 카테고리인지 확인
if (video1.category !== video2.category) {
return NextResponse.json(
{ success: false, message: "같은 카테고리의 비디오만 교환할 수 있습니다." },
{ status: 400 }
);
}
await prisma.$transaction([
prisma.worshipVideo.update({
where: { id: video1.id },
data: { order: -1 }, // 임시 음수 값 (unique constraint 회피)
}),
prisma.worshipVideo.update({
where: { id: video2.id },
data: { order: video1.order },
}),
prisma.worshipVideo.update({
where: { id: video1.id },
data: { order: video2.order },
}),
]);
// 업데이트된 카테고리의 모든 비디오 반환
const updatedVideos = await prisma.worshipVideo.findMany({
where: { category: video1.category },
orderBy: { order: 'desc' },
});
return NextResponse.json({
success: true,
data: updatedVideos,
});
} catch (error) {
console.error("Error reordering worship videos:", error);
const errorMessage = error instanceof Error ? error.message : "예배 영상 순서 변경에 실패했습니다.";
return NextResponse.json(
{ success: false, message: errorMessage },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,151 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { isValidYouTubeUrl, getYouTubeThumbnailUrl, getYouTubeEmbedUrl } from "@/lib/utils/youtube";
// GET: 모든 예배 영상 또는 특정 카테고리 영상 가져오기
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const category = searchParams.get('category');
if (category) {
const videos = await prisma.worshipVideo.findMany({
where: { category },
orderBy: { order: 'desc' },
});
// 썸네일 및 embed URL 추가
const videosWithUrls = videos.map(video => ({
...video,
thumbnailUrl: getYouTubeThumbnailUrl(video.videoUrl),
embedUrl: getYouTubeEmbedUrl(video.videoUrl),
}));
return NextResponse.json({
success: true,
data: videosWithUrls,
});
}
const videos = await prisma.worshipVideo.findMany({
orderBy: [
{ category: 'asc' },
{ order: 'desc' },
],
});
// 썸네일 및 embed URL 추가
const videosWithUrls = videos.map(video => ({
...video,
thumbnailUrl: getYouTubeThumbnailUrl(video.videoUrl),
embedUrl: getYouTubeEmbedUrl(video.videoUrl),
}));
return NextResponse.json({
success: true,
data: videosWithUrls,
});
} catch (error) {
console.error("Error fetching worship videos:", error);
const errorMessage = error instanceof Error ? error.message : "예배 영상 조회에 실패했습니다.";
return NextResponse.json(
{ success: false, error: errorMessage },
{ status: 500 }
);
}
}
// POST: 새 영상 추가
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { category, videoUrl } = body;
if (!category || typeof category !== 'string') {
return NextResponse.json(
{ success: false, message: "카테고리가 유효하지 않습니다." },
{ status: 400 }
);
}
if (!videoUrl || typeof videoUrl !== 'string') {
return NextResponse.json(
{ success: false, message: "영상 URL이 유효하지 않습니다." },
{ status: 400 }
);
}
// YouTube URL 유효성 검사
if (!isValidYouTubeUrl(videoUrl)) {
return NextResponse.json(
{ success: false, message: "유효한 YouTube URL이 아닙니다." },
{ status: 400 }
);
}
// 카테고리 내 기존 영상 확인 (order 내림차순)
const existingVideos = await prisma.worshipVideo.findMany({
where: { category },
orderBy: { order: 'desc' },
});
// 기존 영상이 9개 이상이면 order가 가장 낮은(마지막) 영상 삭제
if (existingVideos.length >= 9) {
const videoToDelete = existingVideos[existingVideos.length - 1];
await prisma.worshipVideo.delete({
where: { id: videoToDelete.id },
});
}
// 새 영상은 현재 최고 order + 1로 설정 (맨 앞에 추가)
const maxOrder = existingVideos.length > 0 ? existingVideos[0].order : 0;
const newOrder = maxOrder + 1;
const newVideo = await prisma.worshipVideo.create({
data: {
category,
videoUrl,
order: newOrder,
},
});
return NextResponse.json({
success: true,
data: newVideo,
}, { status: 201 });
} catch (error) {
console.error("Error creating worship video:", error);
const errorMessage = error instanceof Error ? error.message : "예배 영상 생성에 실패했습니다.";
return NextResponse.json(
{ success: false, message: errorMessage },
{ status: 500 }
);
}
}
// DELETE: 영상 삭제
export async function DELETE(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const id = searchParams.get('id');
if (!id || isNaN(Number(id))) {
return NextResponse.json(
{ success: false, message: "영상 ID가 유효하지 않습니다." },
{ status: 400 }
);
}
await prisma.worshipVideo.delete({
where: { id: Number(id) },
});
return NextResponse.json({
success: true,
message: "영상이 삭제되었습니다."
});
} catch (error) {
console.error("Error deleting worship video:", error);
const errorMessage = error instanceof Error ? error.message : "예배 영상 삭제에 실패했습니다.";
return NextResponse.json(
{ success: false, message: errorMessage },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1 @@
export { metadata } from "./metadata";

View File

@@ -0,0 +1,131 @@
import { Metadata } from "next";
export const metadata: Metadata = {
// metadataBase: 소셜 미디어 이미지 URL 해석을 위한 기본 URL
metadataBase: new URL("https://www.disciples-church.com"),
// 기본 메타데이터
title: {
default: "제자들교회 - 인천",
template: "%s | 제자들교회"
},
applicationName: "제자들교회",
description: "인천 제자들교회 - 성경적 제자도를 실천하는 교회입니다. 예배, 양육, 선교, 교제를 통해 그리스도의 제자로 성장하는 공동체입니다.",
keywords: [
"제자들교회",
"인천교회",
"인천 교회",
"교회",
"예배",
"찬양",
"설교",
"기독교",
"제자도",
"제자훈련",
"성경공부",
"소그룹",
"다음세대",
"청년부",
"주일예배",
"수요예배",
"선교",
"봉사",
"신앙",
"믿음",
"교제",
"공동체"
],
authors: [{ name: "제자들교회" }],
creator: "제자들교회",
publisher: "제자들교회",
category: "religion",
// Open Graph (Facebook, KakaoTalk 등)
openGraph: {
type: "website",
locale: "ko_KR",
url: "https://www.disciples-church.com",
siteName: "제자들교회",
title: "제자들교회 - 인천",
description: "인천 제자들교회 - 성경적 제자도를 실천하는 교회입니다. 예배, 양육, 선교, 교제를 통해 그리스도의 제자로 성장하는 공동체입니다.",
images: [
{
url: "/opengraph-image.jpg",
width: 1200,
height: 630,
alt: "제자들교회 - 인천",
type: "image/jpeg",
}
],
},
// Twitter Card
twitter: {
card: "summary_large_image",
title: "제자들교회 - 인천",
description: "인천 제자들교회 - 성경적 제자도를 실천하는 교회",
images: ["/twitter-image.jpg"],
creator: "@jaejadle",
site: "@jaejadle",
},
// 검색 엔진 최적화
robots: {
index: true,
follow: true,
nocache: false,
googleBot: {
index: true,
follow: true,
noimageindex: false,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
// 기타
alternates: {
canonical: "https://www.disciples-church.com",
languages: {
'ko-KR': 'https://www.disciples-church.com',
},
},
// 아이콘
icons: {
icon: [
{ url: "/favicon.ico", sizes: "any" },
{ url: "/icon_black.webp", type: "image/webp", sizes: "any" },
{ url: "/logo.webp", type: "image/webp", sizes: "any" },
],
apple: [
{ url: "/icon_white.webp", type: "image/webp", sizes: "any" },
],
shortcut: [
{ url: "/favicon.ico" },
],
},
// 앱 링크 (모바일 앱이 있는 경우)
// appleWebApp: {
// capable: true,
// title: "제자들교회",
// statusBarStyle: "black-translucent",
// },
// 포맷 감지
formatDetection: {
telephone: true,
date: true,
address: true,
email: true,
},
// 기타 메타 태그
other: {
"mobile-web-app-capable": "yes",
"apple-mobile-web-app-capable": "yes",
"apple-mobile-web-app-status-bar-style": "black-translucent",
},
};

BIN
nextjs/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

73
nextjs/app/globals.css Normal file
View File

@@ -0,0 +1,73 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
/* 커스텀 브레이크포인트 */
--breakpoint-smalltablet: 600px;
--breakpoint-tablet: 600px;
--breakpoint-pc: 990px;
/* 커스텀 애니메이션 */
--animate-fade-in: fade-in 0.8s ease-in-out;
--animate-fade-in-fast: fade-in-fast 0.3s ease-in-out;
--animate-fade-in-up: fade-in-up 1s ease-out;
--animate-float: float 3s ease-in-out infinite;
}
@custom-variant smalltablet (@media (width >= 600px));
@custom-variant pc (@media (width >= 990px));
body {
background: var(--background);
color: var(--foreground);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans KR", sans-serif;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-in-fast {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}

49
nextjs/app/layout.tsx Normal file
View File

@@ -0,0 +1,49 @@
import { Geist, Geist_Mono } from "next/font/google";
import Script from "next/script";
import "./globals.css";
import Header from "@/components/widgets/Header";
import Footer from "@/components/widgets/Footer";
import SessionProvider from "@/components/providers/SessionProvider";
import { metadata } from "./config";
import { OrganizationJsonLd, WebSiteJsonLd } from "@/components/seo/JsonLd";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export { metadata };
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ko" >
<head>
<OrganizationJsonLd />
<WebSiteJsonLd />
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Script
src="https://umami0213.kro.kr/script.js"
data-website-id="5b673250-5798-4dd8-8103-c3d0e863474c"
strategy="afterInteractive"
/>
<SessionProvider>
<Header />
{children}
<Footer />
</SessionProvider>
</body>
</html>
);
}

47
nextjs/app/manifest.ts Normal file
View File

@@ -0,0 +1,47 @@
import { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: '제자들교회',
short_name: '제자들교회',
description: '인천 제자들교회 - 성경적 제자도를 실천하는 교회',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#000000',
orientation: 'portrait',
icons: [
{
src: '/icon_black.webp',
sizes: 'any',
type: 'image/webp',
},
{
src: '/icon_black.webp',
sizes: 'any',
type: 'image/webp',
purpose: 'maskable',
},
{
src: '/icon_white.webp',
sizes: 'any',
type: 'image/webp',
},
{
src: '/icon_white.webp',
sizes: 'any',
type: 'image/webp',
purpose: 'maskable',
},
{
src: '/logo.webp',
sizes: 'any',
type: 'image/webp',
},
],
categories: ['church', 'religion', 'community'],
lang: 'ko-KR',
dir: 'ltr',
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

18
nextjs/app/page.tsx Normal file
View File

@@ -0,0 +1,18 @@
import Hero from "@/components/landing/Hero";
import Intro from "@/components/landing/Intro";
import NewsAndGallery from "@/components/landing/NewsAndGallery";
import Contact from "@/components/landing/Contact";
import Welcome from "@/components/landing/Welcome";
export default function JaejadlePage() {
return (
<div>
<Hero />
<Intro />
<Welcome />
<NewsAndGallery />
<Contact />
</div>
);
}

20
nextjs/app/robots.ts Normal file
View File

@@ -0,0 +1,20 @@
import { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: [
'/api/',
'/admin/',
'/_next/',
'/private/',
],
},
],
sitemap: 'https://www.disciples-church.com/sitemap.xml',
}
}

94
nextjs/app/sitemap.ts Normal file
View File

@@ -0,0 +1,94 @@
import { MetadataRoute } from 'next'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = 'https://www.disciples-church.com'
// 정적 페이지들
const staticPages = [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: 1,
},
// 교회 소개
{
url: `${baseUrl}/greeting`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.8,
},
{
url: `${baseUrl}/vision`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.8,
},
{
url: `${baseUrl}/leaders`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.7,
},
{
url: `${baseUrl}/directions`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.7,
},
// 예배
{
url: `${baseUrl}/worship`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: 0.9,
},
// 다음세대
{
url: `${baseUrl}/generation`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.8,
},
// 제자화
{
url: `${baseUrl}/system`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.8,
},
// 선교
{
url: `${baseUrl}/mission`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.8,
},
// 소식
{
url: `${baseUrl}/announcements`,
lastModified: new Date(),
changeFrequency: 'daily' as const,
priority: 0.9,
},
{
url: `${baseUrl}/gallery`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: 0.7,
},
]
// 동적 페이지들 (공지사항, 갤러리 등)을 여기에 추가할 수 있습니다
// 예시:
// const announcements = await fetchAnnouncements()
// const announcementPages = announcements.map((announcement) => ({
// url: `${baseUrl}/announcements/${announcement.id}`,
// lastModified: announcement.updatedAt,
// changeFrequency: 'weekly' as const,
// priority: 0.6,
// }))
return [...staticPages]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

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;

54
nextjs/const/api.ts Normal file
View File

@@ -0,0 +1,54 @@
// API 엔드포인트 설정
export const API_ENDPOINTS = {
// 인증 관련 엔드포인트
AUTH: {
SIGN_IN: "/api/auth/signin",
SIGN_UP: "/api/auth/signup",
SIGN_OUT: "/api/auth/signout",
ME: "/api/auth/me",
},
// 파일 관련 엔드포인트
FILE: {
BASE: "/api/files",
BY_ID: (id: number) => `/api/files/${id}`,
DOWNLOAD_URL: "/api/files/download-url",
UPLOAD_URL: "/api/files/upload-url",
},
// 유저 관련 엔드포인트
USER: {
BASE: "/api/users",
BY_ID: (id: number) => `/api/users/${id}`,
},
// 공지사항 관련 엔드포인트
ANNOUNCEMENT: {
BASE: "/api/announcements",
BY_ID: (id: number) => `/api/announcements/${id}`,
},
// 갤러리 관련 엔드포인트
GALLERY: {
BASE: "/api/gallery",
BY_ID: (id: number) => `/api/gallery/${id}`,
},
// 예배 관련 엔드포인트
WORSHIP: {
BASE: "/api/worship",
BY_ID: (id: number) => `/api/worship/${id}`,
BY_CATEGORY: (category: string) => `/api/worship?category=${category}`,
REORDER: "/api/worship/reorder",
NEXT_ORDER: (category: string) => `/api/worship/next-order?category=${category}`,
},
// 제자훈련 관련 엔드포인트
DISCIPLE: {
BASE: "/api/disciple-videos",
BY_ID: (id: number) => `/api/disciple-videos?id=${id}`,
BY_STAGE: (stage: string) => `/api/disciple-videos?stage=${stage}`,
REORDER: "/api/disciple-videos/reorder",
},
} as const;

4
nextjs/const/index.ts Normal file
View File

@@ -0,0 +1,4 @@
// 모든 상수를 한 곳에서 export
export * from './api';
export * from './s3';

23
nextjs/const/s3.ts Normal file
View File

@@ -0,0 +1,23 @@
// S3 설정
export const S3_CONFIG = {
BUCKET_NAME: process.env.AWS_S3_BUCKET_NAME || 'jaejadle-bucket',
REGION: process.env.AWS_REGION || 'ap-northeast-2',
ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID || '',
SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY || '',
} as const;
// S3 클라이언트 인스턴스 (서버 사이드에서만 사용)
import { S3Client } from '@aws-sdk/client-s3';
export const s3Client = new S3Client({
region: S3_CONFIG.REGION,
credentials: {
accessKeyId: S3_CONFIG.ACCESS_KEY_ID,
secretAccessKey: S3_CONFIG.SECRET_ACCESS_KEY,
},
// MinIO를 사용하는 경우 endpoint와 path-style 설정 필요
...(process.env.AWS_S3_ENDPOINT && {
endpoint: process.env.AWS_S3_ENDPOINT,
forcePathStyle: true, // MinIO는 path-style URL 사용 (https://endpoint/bucket/path)
}),
});

137
nextjs/const/tabs.ts Normal file
View File

@@ -0,0 +1,137 @@
const tabs = [
{
label: "About",
href: "/greeting",
imageHref: "/subpages/about/aboutBG.webp",
sectionIndex: 0,
submenu: [
{
label: "인사말",
englishLabel: "GREETING",
href: "/greeting",
description: "",
},
{
label: "교회 비전",
englishLabel: "VISION",
href: "/vision",
description: "",
},
{
label: "섬기는 분들",
englishLabel: "LEADERS",
href: "/leaders",
description: "",
},
{
label: "오시는 길",
englishLabel: "DIRECTIONS",
href: "/directions",
description: "",
},
],
},
{
label: "Worship",
href: "/worship",
imageHref: "/subpages/worship/worshipBG.webp",
sectionIndex: 1,
submenu: [
{
label: "예배 안내",
englishLabel: "Worship Guide",
href: "/worship",
description: "",
},
],
},
{
label: "Discipling System",
href: "/system",
imageHref: "/subpages/system/systemBG.webp",
sectionIndex: 2,
submenu: [
{
label: "새가족반",
englishLabel: "NEW FAMILY",
href: "/system/new-family",
description: "",
},
{
label: "기초양육반",
englishLabel: "BASIC TRAINING",
href: "/system/basic",
description: "",
},
{
label: "제자훈련반",
englishLabel: "DISCIPLE TRAINING",
href: "/system/disciple",
description: "",
},
{
label: "전도훈련반",
englishLabel: "EVANGELISM TRAINING",
href: "/system/evangelism",
description: "",
},
{
label: "사역훈련반",
englishLabel: "MINISTRY TRAINING",
href: "/system/ministry",
description: "",
},
],
},
{
label: "Next Generation",
href: "/generation",
imageHref: "/subpages/generation/generationBG.webp",
sectionIndex: 3,
submenu: [
{
label: "다음 세대",
englishLabel: "NEXT GENERATION",
href: "/generation",
description: "",
},
],
},
{
label: "Mission",
href: "/mission",
imageHref: "/subpages/mission/missionBG.webp",
sectionIndex: 4,
submenu: [
{
label: "선교 비전",
englishLabel: "MISSION",
href: "/mission",
description: "제자들교회는 복음전도와 선교를 위해서 존재합니다.",
},
],
},
{
label: "Community",
href: "/announcements",
imageHref: "/subpages/community/communityBG.webp",
sectionIndex: 5,
submenu: [
{
label: "주보",
englishLabel: "BROCHURES",
href: "/announcements",
description: "",
},
{
label: "행사 앨범",
englishLabel: "EVENT ALBUM",
href: "/gallery",
description: "",
},
],
},
];
export default tabs;

32
nextjs/eslint.config.mjs Normal file
View File

@@ -0,0 +1,32 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
"prisma/client/**",
],
},
{
rules: {
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": "warn",
},
},
];
export default eslintConfig;

3
nextjs/hooks/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { useAuth } from "./useAuth";
export { useImageModal } from "./useImageModal";
export { usePagination } from "./usePagination";

65
nextjs/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,65 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { getMe, type User } from "@/lib/services";
interface UseAuthOptions {
/** 인증 실패 시 리다이렉트할 경로 */
redirectTo?: string;
/** 인증 필수 여부 (false면 인증 실패해도 에러 안 던짐) */
required?: boolean;
}
interface UseAuthReturn {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
checkAuth: () => Promise<void>;
}
/**
* 인증 상태를 관리하는 훅
*
* @example
* // 기본 사용 (인증 선택적)
* const { user, isLoading, isAuthenticated } = useAuth();
*
* @example
* // 인증 필수 페이지
* const { user, isLoading } = useAuth({
* required: true,
* redirectTo: '/login'
* });
*/
export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
const { redirectTo, required = false } = options;
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const checkAuth = useCallback(async () => {
try {
const userData = await getMe();
setUser(userData);
} catch {
setUser(null);
if (required && redirectTo && typeof window !== "undefined") {
window.location.href = redirectTo;
}
} finally {
setIsLoading(false);
}
}, [required, redirectTo]);
useEffect(() => {
checkAuth();
}, [checkAuth]);
return {
user,
isLoading,
isAuthenticated: !!user,
checkAuth,
};
}
export default useAuth;

View File

@@ -0,0 +1,123 @@
"use client";
import { useState, useCallback, useEffect } from "react";
interface UseImageModalReturn {
selectedIndex: number | null;
isOpen: boolean;
open: (index: number) => void;
close: () => void;
next: () => void;
prev: () => void;
goTo: (index: number) => void;
}
/**
* 이미지 갤러리 모달을 관리하는 훅
*
* @param totalImages 전체 이미지 개수
* @param options 추가 옵션
*
* @example
* const { selectedIndex, isOpen, open, close, next, prev } = useImageModal(images.length);
*
* // 이미지 클릭 시
* <div onClick={() => open(index)}>...</div>
*
* // 모달에서
* {isOpen && (
* <Modal onClose={close}>
* <button onClick={prev}>이전</button>
* <img src={images[selectedIndex].url} />
* <button onClick={next}>다음</button>
* </Modal>
* )}
*/
export function useImageModal(
totalImages: number,
options: {
loop?: boolean;
enableKeyboard?: boolean;
} = {}
): UseImageModalReturn {
const { loop = true, enableKeyboard = true } = options;
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const isOpen = selectedIndex !== null;
const open = useCallback((index: number) => {
setSelectedIndex(index);
}, []);
const close = useCallback(() => {
setSelectedIndex(null);
}, []);
const next = useCallback(() => {
if (selectedIndex === null || totalImages === 0) return;
if (loop) {
setSelectedIndex((prev) =>
prev !== null && prev < totalImages - 1 ? prev + 1 : 0
);
} else {
setSelectedIndex((prev) =>
prev !== null && prev < totalImages - 1 ? prev + 1 : prev
);
}
}, [selectedIndex, totalImages, loop]);
const prev = useCallback(() => {
if (selectedIndex === null || totalImages === 0) return;
if (loop) {
setSelectedIndex((prev) =>
prev !== null && prev > 0 ? prev - 1 : totalImages - 1
);
} else {
setSelectedIndex((prev) =>
prev !== null && prev > 0 ? prev - 1 : prev
);
}
}, [selectedIndex, totalImages, loop]);
const goTo = useCallback((index: number) => {
if (index >= 0 && index < totalImages) {
setSelectedIndex(index);
}
}, [totalImages]);
// 키보드 네비게이션
useEffect(() => {
if (!enableKeyboard || !isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case "ArrowLeft":
prev();
break;
case "ArrowRight":
next();
break;
case "Escape":
close();
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [enableKeyboard, isOpen, next, prev, close]);
return {
selectedIndex,
isOpen,
open,
close,
next,
prev,
goTo,
};
}
export default useImageModal;

View File

@@ -0,0 +1,97 @@
"use client";
import { useState, useCallback } from "react";
interface UsePaginationReturn {
currentPage: number;
totalPages: number;
setCurrentPage: (page: number) => void;
setTotalPages: (total: number) => void;
nextPage: () => void;
prevPage: () => void;
goToPage: (page: number) => void;
isFirstPage: boolean;
isLastPage: boolean;
reset: () => void;
}
interface UsePaginationOptions {
initialPage?: number;
initialTotalPages?: number;
}
/**
* 페이지네이션 상태를 관리하는 훅
*
* @example
* const {
* currentPage,
* totalPages,
* setCurrentPage,
* setTotalPages,
* nextPage,
* prevPage
* } = usePagination();
*
* // 데이터 로드 시
* const result = await fetchData(currentPage);
* setTotalPages(result.pagination.totalPages);
*
* // Pagination 컴포넌트와 함께
* <Pagination
* currentPage={currentPage}
* totalPages={totalPages}
* onPageChange={setCurrentPage}
* />
*/
export function usePagination(
options: UsePaginationOptions = {}
): UsePaginationReturn {
const { initialPage = 1, initialTotalPages = 1 } = options;
const [currentPage, setCurrentPageState] = useState(initialPage);
const [totalPages, setTotalPages] = useState(initialTotalPages);
const setCurrentPage = useCallback((page: number) => {
if (page >= 1) {
setCurrentPageState(page);
}
}, []);
const nextPage = useCallback(() => {
setCurrentPageState((prev) => (prev < totalPages ? prev + 1 : prev));
}, [totalPages]);
const prevPage = useCallback(() => {
setCurrentPageState((prev) => (prev > 1 ? prev - 1 : prev));
}, []);
const goToPage = useCallback(
(page: number) => {
if (page >= 1 && page <= totalPages) {
setCurrentPageState(page);
}
},
[totalPages]
);
const reset = useCallback(() => {
setCurrentPageState(initialPage);
setTotalPages(initialTotalPages);
}, [initialPage, initialTotalPages]);
return {
currentPage,
totalPages,
setCurrentPage,
setTotalPages,
nextPage,
prevPage,
goToPage,
isFirstPage: currentPage === 1,
isLastPage: currentPage === totalPages,
reset,
};
}
export default usePagination;

174
nextjs/lib/api/http.ts Normal file
View File

@@ -0,0 +1,174 @@
// 공통 헤더
const getDefaultHeaders = () => ({
'Content-Type': 'application/json',
});
// 인증 헤더 (쿠키 포함)
const getAuthHeaders = () => ({
...getDefaultHeaders(),
credentials: 'include' as const,
});
// 기본 fetch 래퍼
const apiRequest = async (
url: string,
options: RequestInit = {}
): Promise<Response> => {
const response = await fetch(url, {
...options,
headers: {
...getDefaultHeaders(),
...options.headers,
},
});
if (!response.ok) {
const errorText = await response.text();
let errorMessage = errorText;
// JSON 응답인지 확인하고 message 필드 추출
try {
const errorJson = JSON.parse(errorText);
if (errorJson.message) {
errorMessage = errorJson.message;
}
} catch {
// JSON 파싱 실패 시 무시
}
throw new Error(errorMessage);
}
return response;
};
// GET 요청
export const apiGet = async <T>(url: string): Promise<T> => {
try {
const response = await apiRequest(url);
const result = await response.json();
// success 필드가 있고 data 필드가 있으면 data를 반환
if (result && typeof result === 'object' && 'success' in result && 'data' in result) {
return result.data;
}
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
throw new Error(errorMessage);
}
};
// POST 요청
export const apiPost = async <TResponse, TBody = unknown>(
url: string,
data: TBody
): Promise<TResponse> => {
try {
const response = await apiRequest(url, {
method: 'POST',
body: JSON.stringify(data),
});
const result = await response.json();
// success 필드가 있고 data 필드가 있으면 data를 반환
if (result && typeof result === 'object' && 'success' in result && 'data' in result) {
return result.data as TResponse;
}
return result as TResponse;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
throw new Error(errorMessage);
}
};
// PUT 요청
export const apiPut = async <TResponse, TBody = unknown>(
url: string,
data: TBody
): Promise<TResponse> => {
try {
const response = await apiRequest(url, {
method: 'PUT',
body: JSON.stringify(data),
});
const result = await response.json();
// success 필드가 있고 data 필드가 있으면 data를 반환
if (result && typeof result === 'object' && 'success' in result && 'data' in result) {
return result.data as TResponse;
}
return result as TResponse;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
throw new Error(errorMessage);
}
};
// DELETE 요청
export const apiDelete = async <T>(url: string): Promise<T> => {
try {
const response = await apiRequest(url, {
method: 'DELETE',
});
const result = await response.json();
// success 필드가 있고 data 필드가 있으면 data를 반환
if (result && typeof result === 'object' && 'success' in result && 'data' in result) {
return result.data;
}
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
throw new Error(errorMessage);
}
};
// 인증 GET 요청
export const apiGetWithAuth = async <TResponse>(url: string): Promise<TResponse> => {
const response = await apiRequest(url, {
headers: getAuthHeaders(),
});
return response.json();
};
// 인증 POST 요청
export const apiPostWithAuth = async <TResponse, TBody = unknown>(
url: string,
data: TBody
): Promise<TResponse> => {
const response = await apiRequest(url, {
method: 'POST',
body: JSON.stringify(data),
headers: getAuthHeaders(),
});
return response.json();
};
// 인증 PUT 요청
export const apiPutWithAuth = async <TResponse, TBody = unknown>(
url: string,
data: TBody
): Promise<TResponse> => {
const response = await apiRequest(url, {
method: 'PUT',
body: JSON.stringify(data),
headers: getAuthHeaders(),
});
return response.json();
};
// 인증 DELETE 요청
export const apiDeleteWithAuth = async <T>(url: string): Promise<T> => {
const response = await apiRequest(url, {
method: 'DELETE',
headers: getAuthHeaders(),
});
return response.json();
};

2
nextjs/lib/api/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './http';

73
nextjs/lib/auth.ts Normal file
View File

@@ -0,0 +1,73 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { compare } from "bcryptjs";
import { prisma } from "@/lib/prisma";
export const { handlers, signIn, signOut, auth } = NextAuth({
trustHost: true,
secret: process.env.AUTH_SECRET || process.env.JWT_SECRET,
providers: [
Credentials({
name: "Credentials",
credentials: {
userId: { label: "아이디", type: "text" },
userPassword: { label: "비밀번호", type: "password" },
},
async authorize(credentials) {
if (!credentials?.userId || !credentials?.userPassword) {
return null;
}
const user = await prisma.user.findUnique({
where: { userId: credentials.userId as string },
});
if (!user) {
return null;
}
const isPasswordCorrect = await compare(
credentials.userPassword as string,
user.userPassword
);
if (!isPasswordCorrect) {
return null;
}
return {
id: user.id.toString(),
userId: user.userId,
name: user.userName,
email: user.userPhone,
};
},
}),
],
session: {
strategy: "jwt",
maxAge: 3 * 24 * 60 * 60, // 3일
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.userId = user.userId;
token.name = user.name;
}
return token;
},
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id as string;
session.user.userId = token.userId as string;
session.user.name = token.name as string;
}
return session;
},
},
pages: {
signIn: "/login",
},
});

15
nextjs/lib/prisma.ts Normal file
View File

@@ -0,0 +1,15 @@
import 'dotenv/config';
import { PrismaClient } from '@/prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

44
nextjs/lib/s3.ts Normal file
View File

@@ -0,0 +1,44 @@
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { S3_CONFIG, s3Client } from '@/const';
/**
* S3 파일의 signed URL 생성 (1시간 유효)
*/
export async function generateSignedUrl(
fileKey: string,
options?: {
fileName?: string;
expiresIn?: number;
}
): Promise<string> {
const command = new GetObjectCommand({
Bucket: S3_CONFIG.BUCKET_NAME,
Key: fileKey,
...(options?.fileName && {
ResponseContentDisposition: `attachment; filename="${encodeURIComponent(options.fileName)}"`,
}),
});
return getSignedUrl(s3Client, command, {
expiresIn: options?.expiresIn || 3600
});
}
/**
* 여러 파일의 signed URL 일괄 생성
*/
export async function generateSignedUrls(
fileKeys: string[]
): Promise<Map<string, string>> {
const urlMap = new Map<string, string>();
await Promise.all(
fileKeys.map(async (fileKey) => {
const url = await generateSignedUrl(fileKey);
urlMap.set(fileKey, url);
})
);
return urlMap;
}

View File

@@ -0,0 +1,87 @@
import { apiGet, apiPost, apiDelete } from "@/lib/api";
import { API_ENDPOINTS } from "@/const";
import { PaginatedResponse } from "@/lib/utils";
export interface AnnouncementAuthor {
userId: string;
userName: string;
}
export interface AnnouncementFile {
id: number;
fileKey: string;
fileName: string;
fileSize?: number | null;
mimeType?: string | null;
createdAt: string;
updatedAt: string;
signedUrl?: string;
}
export interface Announcement {
id: number;
title: string;
content: string;
isImportant: boolean;
viewCount: number;
authorId: number;
createdAt: string;
updatedAt: string;
author: AnnouncementAuthor;
files?: AnnouncementFile[];
}
export interface CreateAnnouncementData {
title: string;
content?: string;
isImportant: boolean;
authorId: number;
files?: {
fileKey: string;
fileName: string;
fileSize?: number;
mimeType?: string;
}[];
}
export type AnnouncementPaginatedResponse = PaginatedResponse<Announcement>;
/**
* 공지사항 목록 조회 (pagination)
*/
export async function getAnnouncements(
page = 1,
limit = 10
): Promise<AnnouncementPaginatedResponse> {
const url = `${API_ENDPOINTS.ANNOUNCEMENT.BASE}?page=${page}&limit=${limit}`;
const response = await fetch(url);
const result = await response.json();
return {
data: result.data,
pagination: result.pagination,
};
}
/**
* 공지사항 상세 조회
*/
export async function getAnnouncementById(id: number): Promise<Announcement> {
return apiGet<Announcement>(API_ENDPOINTS.ANNOUNCEMENT.BY_ID(id));
}
/**
* 공지사항 생성
*/
export async function createAnnouncement(
data: CreateAnnouncementData
): Promise<Announcement> {
return apiPost<Announcement>(API_ENDPOINTS.ANNOUNCEMENT.BASE, data);
}
/**
* 공지사항 삭제
*/
export async function deleteAnnouncement(id: number): Promise<void> {
return apiDelete<void>(API_ENDPOINTS.ANNOUNCEMENT.BY_ID(id));
}

View File

@@ -0,0 +1,32 @@
import { apiGet, apiPost } from "@/lib/api";
import { API_ENDPOINTS } from "@/const";
export interface User {
id: number;
userId: string;
userName: string;
}
export interface SignUpData {
userId: string;
userPassword: string;
userCheckPassword: string;
userName: string;
userPhone: string;
authCode?: string;
}
/**
* 현재 로그인한 사용자 정보 조회
*/
export async function getMe(): Promise<User> {
return apiGet<User>(API_ENDPOINTS.AUTH.ME);
}
/**
* 회원가입
*/
export async function signUp(data: SignUpData): Promise<void> {
return apiPost<void>(API_ENDPOINTS.AUTH.SIGN_UP, data);
}

View File

@@ -0,0 +1,63 @@
import { apiGet, apiPost, apiDelete, apiPut } from "@/lib/api";
import { API_ENDPOINTS } from "@/const";
export interface DiscipleVideo {
id: number;
stage: string;
step: string | null;
videoUrl: string;
order: number;
createdAt: string;
updatedAt: string;
thumbnailUrl?: string;
embedUrl?: string;
}
export interface DiscipleVideoData {
stage: string;
step?: string | null;
videoUrl: string;
}
interface DiscipleReorderData {
videoId1: number;
videoId2: number;
}
/**
* 모든 제자훈련 영상 가져오기
*/
export async function getAllDiscipleVideos(): Promise<DiscipleVideo[]> {
return apiGet<DiscipleVideo[]>(API_ENDPOINTS.DISCIPLE.BASE);
}
/**
* 특정 stage의 영상들 가져오기
*/
export async function getDiscipleVideosByStage(stage: string): Promise<DiscipleVideo[]> {
return apiGet<DiscipleVideo[]>(API_ENDPOINTS.DISCIPLE.BY_STAGE(stage));
}
/**
* 영상 생성
*/
export async function createDiscipleVideo(data: DiscipleVideoData): Promise<DiscipleVideo> {
return apiPost<DiscipleVideo>(API_ENDPOINTS.DISCIPLE.BASE, data);
}
/**
* 영상 삭제
*/
export async function deleteDiscipleVideo(id: number): Promise<void> {
return apiDelete<void>(API_ENDPOINTS.DISCIPLE.BY_ID(id));
}
/**
* 영상 순서 변경 (두 비디오의 순서를 교환)
*/
export async function swapDiscipleVideos(videoId1: number, videoId2: number): Promise<DiscipleVideo[]> {
return apiPut<DiscipleVideo[], DiscipleReorderData>(
API_ENDPOINTS.DISCIPLE.REORDER,
{ videoId1, videoId2 }
);
}

View File

@@ -0,0 +1,93 @@
import { apiPost } from "@/lib/api";
import { API_ENDPOINTS } from "@/const";
export interface UploadedFile {
fileKey: string;
originalName: string;
fileType: string;
fileSize: number;
}
/**
* S3 업로드 URL 생성
*/
export async function getUploadUrl(
fileName: string,
fileType: string,
folder?: string
): Promise<{ uploadUrl: string; fileKey: string }> {
return apiPost<{ uploadUrl: string; fileKey: string }>(
API_ENDPOINTS.FILE.UPLOAD_URL,
{
fileName,
fileType,
folder,
}
);
}
/**
* S3에 파일 업로드
*/
export async function uploadToS3(uploadUrl: string, file: File): Promise<void> {
const uploadResponse = await fetch(uploadUrl, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
"Cache-Control": "no-cache",
},
});
if (!uploadResponse.ok) {
throw new Error(
`S3 업로드에 실패했습니다. (${uploadResponse.status}: ${uploadResponse.statusText})`
);
}
}
/**
* 파일 업로드 전체 프로세스
*/
export async function uploadFile(file: File, folder?: string): Promise<UploadedFile> {
// 1. 업로드 URL 생성
const { uploadUrl, fileKey } = await getUploadUrl(file.name, file.type, folder);
// 2. S3에 업로드
await uploadToS3(uploadUrl, file);
// 3. 파일 정보 반환
return {
fileKey,
originalName: file.name,
fileType: file.type,
fileSize: file.size,
};
}
/**
* S3에서 파일 다운로드 URL 생성
*/
export async function getDownloadUrl(
fileKey: string,
fileName?: string
): Promise<string> {
const result = await apiPost<{ downloadUrl: string }>(
API_ENDPOINTS.FILE.DOWNLOAD_URL,
{ fileKey, fileName }
);
return result.downloadUrl;
}
/**
* 파일 다운로드
*/
export async function downloadFile(
fileKey: string,
fileName: string
): Promise<void> {
const downloadUrl = await getDownloadUrl(fileKey, fileName);
// ResponseContentDisposition이 설정되어 있으면 자동으로 다운로드됨
window.open(downloadUrl, "_blank");
}

View File

@@ -0,0 +1,143 @@
import { apiPost, apiDelete, apiGet } from "@/lib/api";
import { API_ENDPOINTS } from "@/const";
import { uploadFile } from "./file";
import { PaginatedResponse } from "@/lib/utils";
export interface GalleryImage {
id: number;
fileKey: string;
postId: number;
order: number;
aspectRatio?: number | null;
createdAt: string;
updatedAt: string;
displayUrl?: string;
}
export interface GalleryTextBlock {
id: number;
postId: number;
content: string;
order: number;
createdAt: string;
updatedAt: string;
}
export type GalleryContentItem =
| { type: 'image'; data: GalleryImage }
| { type: 'text'; data: GalleryTextBlock };
export interface GalleryPost {
id: number;
title: string;
content: string;
createdAt: string;
updatedAt: string;
images: GalleryImage[];
textBlocks?: GalleryTextBlock[];
thumbnailUrl?: string;
sortedContent?: GalleryContentItem[];
}
export interface CreateGalleryPostData {
title: string;
content?: string;
items: Array<
| { type: 'image'; fileKey: string; order: number; aspectRatio?: number }
| { type: 'text'; content: string; order: number }
>;
}
/**
* 이미지 파일의 비율을 계산하는 함수
*/
export function calculateImageAspectRatio(file: File): Promise<number> {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
const aspectRatio = img.naturalWidth / img.naturalHeight;
resolve(aspectRatio);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('이미지 로드에 실패했습니다.'));
};
img.src = url;
});
}
export type GalleryPostPaginatedResponse = PaginatedResponse<GalleryPost>;
/**
* 갤러리 포스트 목록 조회 (pagination, thumbnailUrl 포함)
*/
export async function getGalleryPosts(
page = 1,
limit = 12
): Promise<GalleryPostPaginatedResponse> {
const url = `${API_ENDPOINTS.GALLERY.BASE}?page=${page}&limit=${limit}`;
const response = await fetch(url);
const result = await response.json();
return {
data: result.data,
pagination: result.pagination,
};
}
/**
* 갤러리 포스트 상세 조회 (displayUrl, sortedContent 포함)
*/
export async function getGalleryPost(id: number): Promise<GalleryPost> {
return apiGet<GalleryPost>(API_ENDPOINTS.GALLERY.BY_ID(id));
}
/**
* 갤러리 포스트의 정렬된 콘텐츠 반환 (백엔드에서 이미 정렬됨)
*/
export function getSortedGalleryContent(post: GalleryPost): GalleryContentItem[] {
// 백엔드에서 sortedContent가 제공되면 그대로 사용
if (post.sortedContent) {
return post.sortedContent;
}
// fallback: 클라이언트에서 정렬
const items: GalleryContentItem[] = [
...post.images.map((img) => ({ type: 'image' as const, data: img })),
...(post.textBlocks || []).map((text) => ({ type: 'text' as const, data: text })),
];
return items.sort((a, b) => a.data.order - b.data.order);
}
/**
* 갤러리 포스트 생성
*/
export async function createGalleryPost(
data: CreateGalleryPostData
): Promise<GalleryPost> {
return apiPost<GalleryPost>(API_ENDPOINTS.GALLERY.BASE, data);
}
/**
* 갤러리 포스트 삭제
*/
export async function deleteGalleryPost(id: number): Promise<void> {
return apiDelete<void>(API_ENDPOINTS.GALLERY.BY_ID(id));
}
/**
* 여러 파일 업로드 후 fileKeys 반환
*/
export async function uploadGalleryFiles(files: File[]): Promise<string[]> {
const fileKeys: string[] = [];
for (const file of files) {
const { fileKey } = await uploadFile(file, 'gallery');
fileKeys.push(fileKey);
}
return fileKeys;
}

Some files were not shown because too many files have changed in this diff Show More