CHORE(merge): merge from develop
- Initial setup and all features from develop branch - Includes: auth, deploy, docker, style fixes - K3S deployment configuration
This commit is contained in:
110
.github/workflows/build.yml
vendored
110
.github/workflows/build.yml
vendored
@@ -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"
|
||||
|
||||
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
@@ -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
20
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
23
nextjs/app/(auth)/login/page.tsx
Normal file
23
nextjs/app/(auth)/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
38
nextjs/app/(auth)/signup/page.tsx
Normal file
38
nextjs/app/(auth)/signup/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
19
nextjs/app/(subpages)/(about)/directions/layout.tsx
Normal file
19
nextjs/app/(subpages)/(about)/directions/layout.tsx
Normal 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}</>;
|
||||
}
|
||||
|
||||
125
nextjs/app/(subpages)/(about)/directions/page.tsx
Normal file
125
nextjs/app/(subpages)/(about)/directions/page.tsx
Normal 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분 — 교회까지 직진 후 우회전
|
||||
</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>
|
||||
);
|
||||
}
|
||||
71
nextjs/app/(subpages)/(about)/greeting/page.tsx
Normal file
71
nextjs/app/(subpages)/(about)/greeting/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
nextjs/app/(subpages)/(about)/leaders/page.tsx
Normal file
103
nextjs/app/(subpages)/(about)/leaders/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
228
nextjs/app/(subpages)/(about)/vision/page.tsx
Normal file
228
nextjs/app/(subpages)/(about)/vision/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
422
nextjs/app/(subpages)/(discipling)/system/[stage]/page.tsx
Normal file
422
nextjs/app/(subpages)/(discipling)/system/[stage]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
nextjs/app/(subpages)/(discipling)/system/page.tsx
Normal file
161
nextjs/app/(subpages)/(discipling)/system/page.tsx
Normal 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 />;
|
||||
}
|
||||
44
nextjs/app/(subpages)/(mission)/mission/page.tsx
Normal file
44
nextjs/app/(subpages)/(mission)/mission/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
239
nextjs/app/(subpages)/(news)/announcements/[id]/page.tsx
Normal file
239
nextjs/app/(subpages)/(news)/announcements/[id]/page.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
getAnnouncementById,
|
||||
deleteAnnouncement,
|
||||
getDownloadUrl,
|
||||
type Announcement,
|
||||
type AnnouncementFile,
|
||||
} from "@/lib/services";
|
||||
import { useAuth, useImageModal } from "@/hooks";
|
||||
import { Download } from "lucide-react";
|
||||
|
||||
const IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "webp"];
|
||||
|
||||
export default function AnnouncementDetailPage() {
|
||||
const [announcement, setAnnouncement] = useState<Announcement | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
|
||||
const { user } = useAuth();
|
||||
|
||||
// 이미지 파일만 필터링 (API에서 signedUrl 포함)
|
||||
const imageFiles = useMemo(() => {
|
||||
if (!announcement?.files) return [];
|
||||
return announcement.files.filter((file) => {
|
||||
const ext = file.fileName.split(".").pop()?.toLowerCase();
|
||||
return IMAGE_EXTENSIONS.includes(ext || "") && file.signedUrl;
|
||||
}) as (AnnouncementFile & { signedUrl: string })[];
|
||||
}, [announcement?.files]);
|
||||
|
||||
const { selectedIndex, isOpen, open, close, next, prev } = useImageModal(imageFiles.length);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// 공지사항 상세 불러오기 (signedUrl 포함)
|
||||
const announcementData = await getAnnouncementById(parseInt(id));
|
||||
setAnnouncement(announcementData);
|
||||
} catch {
|
||||
alert("공지사항을 불러올 수 없습니다.");
|
||||
router.push("/announcements");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!announcement || !user) return;
|
||||
if (announcement.authorId !== user.id) {
|
||||
alert("삭제 권한이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm("정말 삭제하시겠습니까?")) return;
|
||||
|
||||
try {
|
||||
await deleteAnnouncement(announcement.id);
|
||||
alert("삭제되었습니다.");
|
||||
router.push("/announcements");
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "삭제에 실패했습니다.";
|
||||
alert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadAll = async () => {
|
||||
for (const img of imageFiles) {
|
||||
try {
|
||||
const downloadUrl = await getDownloadUrl(img.fileKey, img.fileName);
|
||||
window.open(downloadUrl, '_blank');
|
||||
} catch (error) {
|
||||
console.error("Download failed:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white w-full flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!announcement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isAuthor = user && announcement.authorId === user.id;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="py-6 smalltablet:py-12 px-4">
|
||||
<div className="max-w-7xl p-4 smalltablet:p-6 pc:p-8 rounded-xl bg-gray-100 mx-auto">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 smalltablet:mb-8">
|
||||
<div className="flex items-center gap-2 smalltablet:gap-3 mb-3 smalltablet:mb-4">
|
||||
{announcement.isImportant && (
|
||||
<span className="px-2 smalltablet:px-3 py-0.5 smalltablet:py-1 rounded-full text-xs smalltablet:text-sm font-semibold bg-red-100 text-red-700">
|
||||
중요
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-xl smalltablet:text-2xl pc:text-3xl font-bold text-gray-800 mb-3 smalltablet:mb-4 wrap-break-word">
|
||||
{announcement.title}
|
||||
</h1>
|
||||
<div className="flex flex-col smalltablet:flex-row smalltablet:items-center smalltablet:justify-between text-xs smalltablet:text-sm text-gray-600 pb-3 smalltablet:pb-4 border-b gap-3 smalltablet:gap-0">
|
||||
<div className="flex flex-wrap items-center gap-2 smalltablet:gap-4">
|
||||
<span>작성자: {announcement.author.userName}</span>
|
||||
<span className="hidden smalltablet:inline">•</span>
|
||||
<span className="hidden smalltablet:inline text-xs smalltablet:text-sm">{formatDate(announcement.createdAt)}</span>
|
||||
<span className="hidden smalltablet:inline">•</span>
|
||||
<span>조회수: {announcement.viewCount}</span>
|
||||
</div>
|
||||
{isAuthor && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="px-3 smalltablet:px-4 py-1.5 smalltablet:py-2 text-xs smalltablet:text-sm text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 이미지 갤러리 */}
|
||||
{imageFiles.length > 0 && (
|
||||
<div className="space-y-4 mb-6">
|
||||
{imageFiles.map((img, index) => (
|
||||
<div
|
||||
key={img.id}
|
||||
className="relative w-full bg-gray-200 rounded-lg overflow-hidden cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => open(index)}
|
||||
>
|
||||
<Image
|
||||
src={img.signedUrl}
|
||||
alt={`${announcement.title} - ${index + 1}`}
|
||||
width={1200}
|
||||
height={800}
|
||||
className="w-full h-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 전체 다운로드 버튼 */}
|
||||
<button
|
||||
onClick={handleDownloadAll}
|
||||
className="w-full px-4 py-3 bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] hover:from-[#6b95c6] hover:to-[#5b85b6] text-white rounded-lg shadow-md hover:shadow-lg transition-all font-semibold flex items-center justify-center gap-2 text-sm"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>이미지 다운로드 ({imageFiles.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 이미지가 없는 경우 */}
|
||||
{imageFiles.length === 0 && (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
첨부된 이미지가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 이미지 모달 */}
|
||||
{isOpen && selectedIndex !== null && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50"
|
||||
onClick={close}
|
||||
>
|
||||
<button
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-white text-4xl hover:text-gray-300 z-10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
prev();
|
||||
}}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
|
||||
<div className="relative max-w-7xl max-h-[90vh] w-full h-full mx-16">
|
||||
{imageFiles[selectedIndex]?.signedUrl && (
|
||||
<Image
|
||||
src={imageFiles[selectedIndex].signedUrl}
|
||||
alt={`${announcement.title} - ${selectedIndex + 1}`}
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white text-4xl hover:text-gray-300 z-10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
next();
|
||||
}}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="absolute top-4 right-4 text-white text-3xl hover:text-gray-300"
|
||||
onClick={close}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white text-xs smalltablet:text-sm">
|
||||
{selectedIndex + 1} / {imageFiles.length}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
180
nextjs/app/(subpages)/(news)/announcements/create/page.tsx
Normal file
180
nextjs/app/(subpages)/(news)/announcements/create/page.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { createAnnouncement, uploadFile } from "@/lib/services";
|
||||
import { useAuth } from "@/hooks";
|
||||
import ImageUpload, { PendingImage } from "@/components/ImageUpload";
|
||||
|
||||
interface AnnouncementFormData {
|
||||
title: string;
|
||||
isImportant: boolean;
|
||||
}
|
||||
|
||||
export default function CreateAnnouncementPage() {
|
||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||
const router = useRouter();
|
||||
|
||||
const { user, isLoading } = useAuth();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<AnnouncementFormData>({
|
||||
defaultValues: {
|
||||
title: "",
|
||||
isImportant: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 로그인하지 않은 경우 리다이렉트
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) {
|
||||
alert("로그인이 필요합니다.");
|
||||
router.push("/login");
|
||||
}
|
||||
}, [isLoading, user, router]);
|
||||
|
||||
const onSubmit = async (data: AnnouncementFormData) => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
// 이미지 업로드
|
||||
let uploadedFiles: {
|
||||
fileKey: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
}[] = [];
|
||||
|
||||
if (pendingImages.length > 0) {
|
||||
const sortedImages = [...pendingImages].sort((a, b) => a.order - b.order);
|
||||
const uploadPromises = sortedImages.map(async (img) => {
|
||||
const result = await uploadFile(img.file, "/announcement");
|
||||
return {
|
||||
fileKey: result.fileKey,
|
||||
fileName: img.file.name,
|
||||
fileSize: img.file.size,
|
||||
mimeType: img.file.type,
|
||||
};
|
||||
});
|
||||
uploadedFiles = await Promise.all(uploadPromises);
|
||||
}
|
||||
|
||||
await createAnnouncement({
|
||||
...data,
|
||||
content: "", // 내용 필드는 빈 문자열로 전송
|
||||
authorId: user.id,
|
||||
files: uploadedFiles.length > 0 ? uploadedFiles : undefined,
|
||||
});
|
||||
|
||||
// 미리보기 URL 정리
|
||||
pendingImages.forEach((img) => {
|
||||
if (img.preview) URL.revokeObjectURL(img.preview);
|
||||
});
|
||||
|
||||
alert("주보가 등록되었습니다.");
|
||||
router.push("/announcements");
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "주보 등록에 실패했습니다.";
|
||||
alert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white w-full flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white w-full">
|
||||
<div className="py-12 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 w-full">
|
||||
{/* 제목 */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
제목 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
{...register("title", {
|
||||
required: "제목을 입력해주세요",
|
||||
minLength: {
|
||||
value: 2,
|
||||
message: "제목은 2자 이상이어야 합니다",
|
||||
},
|
||||
})}
|
||||
disabled={isSubmitting}
|
||||
placeholder="제목을 입력해주세요"
|
||||
className={`w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:border-transparent disabled:opacity-50 transition-all ${
|
||||
errors.title
|
||||
? "border-red-300 focus:ring-red-400"
|
||||
: "border-gray-300 focus:ring-blue-500"
|
||||
}`}
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{errors.title.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 중요 공지 체크박스 */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isImportant"
|
||||
{...register("isImportant")}
|
||||
disabled={isSubmitting}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
<label
|
||||
htmlFor="isImportant"
|
||||
className="ml-2 text-sm font-medium text-gray-700"
|
||||
>
|
||||
중요 공지로 표시
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 이미지 업로드 */}
|
||||
<ImageUpload
|
||||
images={pendingImages}
|
||||
onImagesChange={setPendingImages}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex gap-4 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 px-6 py-3 bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] hover:from-[#6b95c6] hover:to-[#5b85b6] text-white rounded-lg shadow-md hover:shadow-lg transition-all font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? "등록 중..." : "등록하기"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
disabled={isSubmitting}
|
||||
className="px-6 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors font-semibold disabled:opacity-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
nextjs/app/(subpages)/(news)/announcements/layout.tsx
Normal file
19
nextjs/app/(subpages)/(news)/announcements/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '주보',
|
||||
description: '제자들교회의 주보와 공지사항을 확인하실 수 있습니다. 교회의 소식과 일정을 안내해드립니다.',
|
||||
openGraph: {
|
||||
title: '주보 | 제자들교회',
|
||||
description: '제자들교회의 주보와 공지사항을 확인하실 수 있습니다.',
|
||||
},
|
||||
};
|
||||
|
||||
export default function AnnouncementsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
197
nextjs/app/(subpages)/(news)/announcements/page.tsx
Normal file
197
nextjs/app/(subpages)/(news)/announcements/page.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { getAnnouncements, type Announcement } from "@/lib/services";
|
||||
import { useAuth, usePagination } from "@/hooks";
|
||||
import { FileTextIcon } from "lucide-react";
|
||||
import Pagination from "@/components/Pagination";
|
||||
|
||||
export default function AnnouncementsPage() {
|
||||
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const { user } = useAuth();
|
||||
const { currentPage, totalPages, setCurrentPage, setTotalPages } = usePagination();
|
||||
|
||||
useEffect(() => {
|
||||
loadData(currentPage);
|
||||
}, [currentPage]);
|
||||
|
||||
const loadData = async (page: number) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 공지사항 목록 불러오기
|
||||
const announcementsResponse = await getAnnouncements(page, 10);
|
||||
setAnnouncements(announcementsResponse.data);
|
||||
setTotalPages(announcementsResponse.pagination.totalPages);
|
||||
} catch (error) {
|
||||
console.error("Failed to load announcements:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-8 w-full flex flex-col items-center">
|
||||
<div className="max-w-7xl px-4 m-4 smalltablet:m-8 w-full">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
{/* 테이블 헤더 스켈레톤 - 데스크톱 */}
|
||||
<div className="hidden smalltablet:grid smalltablet:grid-cols-12 bg-gray-50 border-b border-gray-200 text-sm font-medium text-gray-700">
|
||||
<div className="col-span-1 px-6 py-4 text-center">번호</div>
|
||||
<div className="col-span-6 px-6 py-4">제목</div>
|
||||
<div className="col-span-2 px-6 py-4 text-center">작성자</div>
|
||||
<div className="col-span-2 px-6 py-4 text-center">작성일</div>
|
||||
<div className="col-span-1 px-6 py-4 text-center">조회수</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 바디 스켈레톤 */}
|
||||
<div className="divide-y divide-gray-200">
|
||||
{[...Array(5)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="grid grid-cols-1 smalltablet:grid-cols-12 animate-pulse"
|
||||
>
|
||||
{/* 모바일 뷰 스켈레톤 */}
|
||||
<div className="smalltablet:hidden px-6 py-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-3 bg-gray-200 rounded w-20"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-24"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데스크톱 뷰 스켈레톤 */}
|
||||
<div className="hidden smalltablet:block smalltablet:col-span-1 px-6 py-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-8 mx-auto"></div>
|
||||
</div>
|
||||
<div className="hidden smalltablet:block smalltablet:col-span-6 px-6 py-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
</div>
|
||||
<div className="hidden smalltablet:block smalltablet:col-span-2 px-6 py-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-16 mx-auto"></div>
|
||||
</div>
|
||||
<div className="hidden smalltablet:block smalltablet:col-span-2 px-6 py-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-20 mx-auto"></div>
|
||||
</div>
|
||||
<div className="hidden smalltablet:block smalltablet:col-span-1 px-6 py-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-8 mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 w-full flex flex-col items-center">
|
||||
<div className="max-w-7xl px-4 m-4 smalltablet:m-8 w-full">
|
||||
{/* 공지 작성 버튼 */}
|
||||
{user && (
|
||||
<div className="flex justify-end mb-4">
|
||||
<Link
|
||||
href="/announcements/create"
|
||||
className="px-6 py-2.5 bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] hover:from-[#6b95c6] hover:to-[#5b85b6] text-white rounded-lg shadow-md hover:shadow-lg transition-all font-medium text-sm"
|
||||
>
|
||||
공지 작성
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블 */}
|
||||
{announcements.length === 0 ? (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 text-center py-20 flex items-center justify-center flex-col">
|
||||
<FileTextIcon className="mx-auto h-16 w-16 text-gray-300 mb-4" />
|
||||
<p className="text-gray-500 text-lg">
|
||||
등록된 주보가 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
{/* 테이블 헤더 - 데스크톱 */}
|
||||
<div className="hidden smalltablet:grid smalltablet:grid-cols-12 bg-gray-50 border-b border-gray-200 text-sm font-medium text-gray-700">
|
||||
<div className="col-span-1 px-6 py-4 text-center">번호</div>
|
||||
<div className="col-span-6 px-6 py-4">제목</div>
|
||||
<div className="col-span-2 px-6 py-4 text-center">작성자</div>
|
||||
<div className="col-span-2 px-6 py-4 text-center">작성일</div>
|
||||
<div className="col-span-1 px-6 py-4 text-center">조회수</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 바디 */}
|
||||
<div className="divide-y divide-gray-200">
|
||||
{announcements.map((item, index) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={`/announcements/${item.id}`}
|
||||
className="grid grid-cols-1 smalltablet:grid-cols-12 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{/* 모바일 뷰 */}
|
||||
<div className="smalltablet:hidden px-6 py-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{item.isImportant && (
|
||||
<span className="px-2 py-0.5 bg-orange-100 text-orange-600 text-xs font-bold rounded">
|
||||
필독
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-medium text-gray-900 mb-2">{item.title}</h3>
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<span>{item.author.userName}</span>
|
||||
<span>{formatDate(item.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데스크톱 뷰 */}
|
||||
<div className="hidden smalltablet:block smalltablet:col-span-1 px-6 py-4 text-center text-sm text-gray-600">
|
||||
{announcements.length - index}
|
||||
</div>
|
||||
<div className="hidden smalltablet:block smalltablet:col-span-6 px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{item.isImportant && (
|
||||
<span className="px-2 py-0.5 bg-orange-100 text-orange-600 text-xs font-bold rounded">
|
||||
필독
|
||||
</span>
|
||||
)}
|
||||
<span className="text-gray-900 font-medium hover:text-blue-600 transition-colors">
|
||||
{item.title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden smalltablet:block smalltablet:col-span-2 px-6 py-4 text-center text-sm text-gray-600">
|
||||
{item.author.userName}
|
||||
</div>
|
||||
<div className="hidden smalltablet:flex smalltablet:col-span-2 px-6 py-4 justify-center text-center text-sm text-gray-600">
|
||||
{formatDate(item.createdAt)}
|
||||
</div>
|
||||
<div className="hidden smalltablet:block smalltablet:col-span-1 px-6 py-4 text-center text-sm text-gray-600">
|
||||
{item.viewCount}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
204
nextjs/app/(subpages)/(news)/gallery/[id]/page.tsx
Normal file
204
nextjs/app/(subpages)/(news)/gallery/[id]/page.tsx
Normal 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();
|
||||
}}
|
||||
>
|
||||
‹
|
||||
</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();
|
||||
}}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="absolute top-4 right-4 text-white text-3xl hover:text-gray-300"
|
||||
onClick={close}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white text-xs smalltablet:text-sm">
|
||||
{selectedIndex + 1} / {sortedImages.length}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
nextjs/app/(subpages)/(news)/gallery/layout.tsx
Normal file
19
nextjs/app/(subpages)/(news)/gallery/layout.tsx
Normal 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}</>;
|
||||
}
|
||||
|
||||
114
nextjs/app/(subpages)/(news)/gallery/page.tsx
Normal file
114
nextjs/app/(subpages)/(news)/gallery/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
327
nextjs/app/(subpages)/(news)/gallery/write/page.tsx
Normal file
327
nextjs/app/(subpages)/(news)/gallery/write/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
nextjs/app/(subpages)/(next-gen)/generation/page.tsx
Normal file
182
nextjs/app/(subpages)/(next-gen)/generation/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
nextjs/app/(subpages)/(worship)/worship/layout.tsx
Normal file
19
nextjs/app/(subpages)/(worship)/worship/layout.tsx
Normal 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}</>;
|
||||
}
|
||||
|
||||
465
nextjs/app/(subpages)/(worship)/worship/page.tsx
Normal file
465
nextjs/app/(subpages)/(worship)/worship/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
nextjs/app/(subpages)/layout.tsx
Normal file
20
nextjs/app/(subpages)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
132
nextjs/app/api/announcements/[id]/route.ts
Normal file
132
nextjs/app/api/announcements/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
96
nextjs/app/api/announcements/route.ts
Normal file
96
nextjs/app/api/announcements/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
4
nextjs/app/api/auth/[...nextauth]/route.ts
Normal file
4
nextjs/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { handlers } from "@/lib/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
|
||||
31
nextjs/app/api/auth/me/route.ts
Normal file
31
nextjs/app/api/auth/me/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
95
nextjs/app/api/auth/signup/route.ts
Normal file
95
nextjs/app/api/auth/signup/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
79
nextjs/app/api/disciple-videos/reorder/route.ts
Normal file
79
nextjs/app/api/disciple-videos/reorder/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
148
nextjs/app/api/disciple-videos/route.ts
Normal file
148
nextjs/app/api/disciple-videos/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
32
nextjs/app/api/files/download-url/route.ts
Normal file
32
nextjs/app/api/files/download-url/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
72
nextjs/app/api/files/upload-url/route.ts
Normal file
72
nextjs/app/api/files/upload-url/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
158
nextjs/app/api/gallery/[id]/route.ts
Normal file
158
nextjs/app/api/gallery/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
135
nextjs/app/api/gallery/route.ts
Normal file
135
nextjs/app/api/gallery/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
79
nextjs/app/api/worship/reorder/route.ts
Normal file
79
nextjs/app/api/worship/reorder/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
151
nextjs/app/api/worship/route.ts
Normal file
151
nextjs/app/api/worship/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
1
nextjs/app/config/index.ts
Normal file
1
nextjs/app/config/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { metadata } from "./metadata";
|
||||
131
nextjs/app/config/metadata.ts
Normal file
131
nextjs/app/config/metadata.ts
Normal 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
BIN
nextjs/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
73
nextjs/app/globals.css
Normal file
73
nextjs/app/globals.css
Normal 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
49
nextjs/app/layout.tsx
Normal 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
47
nextjs/app/manifest.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
|
||||
BIN
nextjs/app/opengraph-image.jpg
Normal file
BIN
nextjs/app/opengraph-image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
18
nextjs/app/page.tsx
Normal file
18
nextjs/app/page.tsx
Normal 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
20
nextjs/app/robots.ts
Normal 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
94
nextjs/app/sitemap.ts
Normal 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]
|
||||
}
|
||||
|
||||
BIN
nextjs/app/twitter-image.jpg
Normal file
BIN
nextjs/app/twitter-image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
186
nextjs/components/FileUpload.tsx
Normal file
186
nextjs/components/FileUpload.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import { X, Upload, Image as ImageIcon, FileText, File as FileIcon } from "lucide-react";
|
||||
|
||||
export interface PendingFile {
|
||||
file: File;
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
interface FileUploadProps {
|
||||
files: PendingFile[];
|
||||
onFilesChange: (files: PendingFile[]) => void;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
showImagePreview?: boolean;
|
||||
maxSizeMB?: number;
|
||||
}
|
||||
|
||||
export default function FileUpload({
|
||||
files,
|
||||
onFilesChange,
|
||||
accept,
|
||||
multiple = true,
|
||||
disabled = false,
|
||||
label = "파일 첨부",
|
||||
required = false,
|
||||
description = "이미지, PDF, 문서 등 모든 파일 형식 지원",
|
||||
showImagePreview = false,
|
||||
maxSizeMB,
|
||||
}: FileUploadProps) {
|
||||
const getFileIcon = (fileType: string) => {
|
||||
if (fileType.startsWith("image/")) {
|
||||
return ImageIcon;
|
||||
} else if (fileType.includes("pdf")) {
|
||||
return FileText;
|
||||
} else {
|
||||
return FileIcon;
|
||||
}
|
||||
};
|
||||
|
||||
const isImage = (fileType: string) => {
|
||||
return fileType.startsWith("image/");
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = e.target.files;
|
||||
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||
|
||||
let newFiles = Array.from(selectedFiles);
|
||||
|
||||
// 파일 크기 검사
|
||||
if (maxSizeMB) {
|
||||
const maxSize = maxSizeMB * 1024 * 1024;
|
||||
const oversizedFiles = newFiles.filter((f) => f.size > maxSize);
|
||||
if (oversizedFiles.length > 0) {
|
||||
alert(
|
||||
`파일 크기는 ${maxSizeMB}MB를 초과할 수 없습니다: ${oversizedFiles.map((f) => f.name).join(", ")}`
|
||||
);
|
||||
newFiles = newFiles.filter((f) => f.size <= maxSize);
|
||||
if (newFiles.length === 0) return;
|
||||
}
|
||||
}
|
||||
|
||||
const newPendingFiles: PendingFile[] = newFiles.map((file) => ({
|
||||
file,
|
||||
preview: file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined,
|
||||
}));
|
||||
|
||||
onFilesChange([...files, ...newPendingFiles]);
|
||||
|
||||
// input 초기화
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
const file = files[index];
|
||||
if (file.preview) {
|
||||
URL.revokeObjectURL(file.preview);
|
||||
}
|
||||
onFilesChange(files.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-400 transition-colors">
|
||||
<input
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
onChange={handleFileChange}
|
||||
disabled={disabled}
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
/>
|
||||
<label htmlFor="file-upload" className="cursor-pointer flex flex-col items-center">
|
||||
<Upload className="w-12 h-12 text-gray-400 mb-3" />
|
||||
<p className="text-gray-600 mb-1">클릭하여 파일을 선택하세요</p>
|
||||
<p className="text-sm text-gray-500">{description}</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 파일 목록 */}
|
||||
{files.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
선택된 파일 ({files.length})
|
||||
</p>
|
||||
|
||||
{/* 이미지 미리보기 그리드 */}
|
||||
{showImagePreview ? (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{files.map((pf, index) => (
|
||||
<div key={index} className="relative aspect-square">
|
||||
{pf.preview ? (
|
||||
<Image
|
||||
src={pf.preview}
|
||||
alt={`Preview ${index + 1}`}
|
||||
fill
|
||||
className="object-cover rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<FileIcon className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(index)}
|
||||
className="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm hover:bg-red-600"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* 파일 리스트 */
|
||||
<div className="space-y-2">
|
||||
{files.map((pf, index) => {
|
||||
const FileIconComponent = getFileIcon(pf.file.type);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="relative group border rounded-lg p-3 hover:shadow-md transition-shadow flex items-center gap-3"
|
||||
>
|
||||
<div className="shrink-0">
|
||||
<div className="w-12 h-12 bg-gray-100 rounded flex items-center justify-center">
|
||||
{isImage(pf.file.type) ? (
|
||||
<ImageIcon className="w-6 h-6 text-blue-500" />
|
||||
) : (
|
||||
<FileIconComponent className="w-6 h-6 text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-800 truncate">
|
||||
{pf.file.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{(pf.file.size / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(index)}
|
||||
className="shrink-0 bg-red-500 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-600"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
286
nextjs/components/ImageUpload.tsx
Normal file
286
nextjs/components/ImageUpload.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useCallback, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import { X, Upload, ArrowUp, ArrowDown } from "lucide-react";
|
||||
|
||||
export interface PendingImage {
|
||||
id: string;
|
||||
file: File;
|
||||
preview: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface ImageUploadProps {
|
||||
images: PendingImage[];
|
||||
onImagesChange: (images: PendingImage[]) => void;
|
||||
disabled?: boolean;
|
||||
maxSizeMB?: number;
|
||||
accept?: string;
|
||||
showOrder?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_ACCEPTED_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp"];
|
||||
const DEFAULT_ACCEPTED_EXTENSIONS = ".jpg,.jpeg,.png,.webp";
|
||||
|
||||
export default function ImageUpload({
|
||||
images,
|
||||
onImagesChange,
|
||||
disabled = false,
|
||||
maxSizeMB = 10,
|
||||
accept = DEFAULT_ACCEPTED_EXTENSIONS,
|
||||
showOrder = true,
|
||||
}: ImageUploadProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const validateAndAddFiles = useCallback(
|
||||
(files: File[]) => {
|
||||
const maxSize = maxSizeMB * 1024 * 1024;
|
||||
|
||||
// 이미지 파일만 필터링
|
||||
const validFiles = files.filter((file) => {
|
||||
if (!DEFAULT_ACCEPTED_TYPES.includes(file.type)) {
|
||||
return false;
|
||||
}
|
||||
if (file.size > maxSize) {
|
||||
alert(`파일 크기는 ${maxSizeMB}MB를 초과할 수 없습니다: ${file.name}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (validFiles.length === 0) return;
|
||||
|
||||
const newImages: PendingImage[] = validFiles.map((file, index) => ({
|
||||
id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
file,
|
||||
preview: URL.createObjectURL(file),
|
||||
order: images.length + index,
|
||||
}));
|
||||
|
||||
onImagesChange([...images, ...newImages]);
|
||||
},
|
||||
[images, onImagesChange, maxSizeMB]
|
||||
);
|
||||
|
||||
// 클립보드 붙여넣기 핸들러
|
||||
useEffect(() => {
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
if (disabled) return;
|
||||
|
||||
const clipboardItems = e.clipboardData?.items;
|
||||
if (!clipboardItems) return;
|
||||
|
||||
const imageFiles: File[] = [];
|
||||
|
||||
for (const item of Array.from(clipboardItems)) {
|
||||
// 직접 이미지 파일인 경우
|
||||
if (item.type.startsWith("image/")) {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
imageFiles.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
validateAndAddFiles(imageFiles);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("paste", handlePaste);
|
||||
return () => document.removeEventListener("paste", handlePaste);
|
||||
}, [disabled, validateAndAddFiles]);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = e.target.files;
|
||||
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||
|
||||
validateAndAddFiles(Array.from(selectedFiles));
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
if (disabled) return;
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
validateAndAddFiles(droppedFiles);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const removeImage = (id: string) => {
|
||||
const image = images.find((img) => img.id === id);
|
||||
if (image?.preview) {
|
||||
URL.revokeObjectURL(image.preview);
|
||||
}
|
||||
const newImages = images
|
||||
.filter((img) => img.id !== id)
|
||||
.map((img, index) => ({ ...img, order: index }));
|
||||
onImagesChange(newImages);
|
||||
};
|
||||
|
||||
const moveImage = (id: string, direction: "up" | "down") => {
|
||||
const index = images.findIndex((img) => img.id === id);
|
||||
if (index === -1) return;
|
||||
|
||||
const newIndex = direction === "up" ? index - 1 : index + 1;
|
||||
if (newIndex < 0 || newIndex >= images.length) return;
|
||||
|
||||
const newImages = [...images];
|
||||
[newImages[index], newImages[newIndex]] = [newImages[newIndex], newImages[index]];
|
||||
|
||||
// order 재정렬
|
||||
const reorderedImages = newImages.map((img, idx) => ({
|
||||
...img,
|
||||
order: idx,
|
||||
}));
|
||||
onImagesChange(reorderedImages);
|
||||
};
|
||||
|
||||
const sortedImages = [...images].sort((a, b) => a.order - b.order);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
이미지 첨부
|
||||
</label>
|
||||
|
||||
{/* 업로드 영역 */}
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
|
||||
disabled
|
||||
? "border-gray-200 bg-gray-50 cursor-not-allowed"
|
||||
: "border-gray-300 hover:border-blue-400 cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
disabled={disabled}
|
||||
className="hidden"
|
||||
id="image-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="image-upload"
|
||||
className={`flex flex-col items-center ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
<Upload className="w-12 h-12 text-gray-400 mb-3" />
|
||||
<p className="text-gray-600 mb-1">
|
||||
클릭하여 이미지를 선택하거나 드래그하세요
|
||||
</p>
|
||||
<p className="text-sm text-blue-600 font-medium mb-1">
|
||||
Ctrl+V로 이미지 붙여넣기 가능
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
JPG, PNG, WebP 형식 지원 (최대 {maxSizeMB}MB)
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 이미지 목록 */}
|
||||
{sortedImages.length > 0 && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
선택된 이미지 ({sortedImages.length})
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{sortedImages.map((img, index) => (
|
||||
<div
|
||||
key={img.id}
|
||||
className="border border-gray-300 rounded-lg p-3 bg-white"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{showOrder && (
|
||||
<span className="text-sm text-gray-500 font-medium pt-2 min-w-[24px]">
|
||||
{index + 1}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="relative w-full bg-gray-100 rounded-lg overflow-hidden">
|
||||
<Image
|
||||
src={img.preview}
|
||||
alt={`미리보기 ${index + 1}`}
|
||||
width={1200}
|
||||
height={800}
|
||||
className="w-full h-auto object-contain max-h-[300px]"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1 truncate">
|
||||
{img.file.name} ({(img.file.size / 1024).toFixed(1)} KB)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveImage(img.id, "up")}
|
||||
disabled={index === 0 || disabled}
|
||||
className="p-1 text-gray-600 hover:text-gray-900 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="위로 이동"
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveImage(img.id, "down")}
|
||||
disabled={index === sortedImages.length - 1 || disabled}
|
||||
className="p-1 text-gray-600 hover:text-gray-900 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="아래로 이동"
|
||||
>
|
||||
<ArrowDown className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(img.id)}
|
||||
disabled={disabled}
|
||||
className="p-1 text-red-600 hover:text-red-800 disabled:opacity-50"
|
||||
title="삭제"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 이미지 추가 함수 (외부에서 사용 가능) - Gallery에서 붙여넣기 연동용
|
||||
export function createPendingImage(file: File, currentLength: number): PendingImage {
|
||||
return {
|
||||
id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
file,
|
||||
preview: URL.createObjectURL(file),
|
||||
order: currentLength,
|
||||
};
|
||||
}
|
||||
|
||||
// 붙여넣기 이벤트에서 이미지 파일 추출
|
||||
export function extractImagesFromClipboard(e: ClipboardEvent): File[] {
|
||||
const clipboardItems = e.clipboardData?.items;
|
||||
if (!clipboardItems) return [];
|
||||
|
||||
const imageFiles: File[] = [];
|
||||
for (const item of Array.from(clipboardItems)) {
|
||||
if (item.type.startsWith("image/")) {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
imageFiles.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
return imageFiles;
|
||||
}
|
||||
87
nextjs/components/Pagination.tsx
Normal file
87
nextjs/components/Pagination.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export default function Pagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}: PaginationProps) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const getPageNumbers = () => {
|
||||
const pages: (number | string)[] = [];
|
||||
const maxVisible = 5;
|
||||
|
||||
if (totalPages <= maxVisible) {
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||
} else {
|
||||
if (currentPage <= 3) {
|
||||
for (let i = 1; i <= 4; i++) pages.push(i);
|
||||
pages.push('...');
|
||||
pages.push(totalPages);
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
pages.push(1);
|
||||
pages.push('...');
|
||||
for (let i = totalPages - 3; i <= totalPages; i++) pages.push(i);
|
||||
} else {
|
||||
pages.push(1);
|
||||
pages.push('...');
|
||||
for (let i = currentPage - 1; i <= currentPage + 1; i++) pages.push(i);
|
||||
pages.push('...');
|
||||
pages.push(totalPages);
|
||||
}
|
||||
}
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center gap-2 mt-8">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className={`px-3 py-2 rounded-lg border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
currentPage === 1
|
||||
? 'border-gray-300 text-gray-400'
|
||||
: 'border-[#94b7d6] text-[#6d96c5] hover:bg-[#a9c6e1]'
|
||||
}`}
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
|
||||
{getPageNumbers().map((page, idx) =>
|
||||
typeof page === 'number' ? (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => onPageChange(page)}
|
||||
className={`w-10 h-10 flex items-center justify-center rounded-lg transition-colors ${
|
||||
currentPage === page
|
||||
? 'bg-[#6d96c5] text-white'
|
||||
: 'border border-[#94b7d6] text-[#6d96c5] hover:bg-[#a9c6e1]'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
) : (
|
||||
<span key={idx} className="px-2">
|
||||
{page}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className={`px-3 py-2 rounded-lg border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
currentPage === totalPages
|
||||
? 'border-gray-300 text-gray-400'
|
||||
: 'border-[#94b7d6] text-[#6d96c5] hover:bg-[#a9c6e1]'
|
||||
}`}
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
nextjs/components/SignedImage.tsx
Normal file
70
nextjs/components/SignedImage.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { getDownloadUrl } from '@/lib/services';
|
||||
|
||||
interface SignedImageProps {
|
||||
fileKey: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
fill?: boolean;
|
||||
sizes?: string;
|
||||
}
|
||||
|
||||
const SignedImage: React.FC<SignedImageProps> = ({
|
||||
fileKey,
|
||||
alt,
|
||||
className = "",
|
||||
fill = false,
|
||||
sizes
|
||||
}) => {
|
||||
const [signedUrl, setSignedUrl] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const getSignedUrl = async () => {
|
||||
try {
|
||||
const downloadUrl = await getDownloadUrl(fileKey);
|
||||
setSignedUrl(downloadUrl);
|
||||
} catch (error) {
|
||||
console.error('서명된 URL 생성 실패:', error);
|
||||
setError('이미지를 불러올 수 없습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
getSignedUrl();
|
||||
}, [fileKey]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center bg-gray-200 ${className}`}>
|
||||
<div className="w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !signedUrl) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center bg-gray-200 ${className}`}>
|
||||
<span className="text-gray-500 text-sm">{error || '이미지 없음'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={signedUrl}
|
||||
alt={alt}
|
||||
fill={fill}
|
||||
sizes={sizes}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignedImage;
|
||||
|
||||
132
nextjs/components/auth/LoginForm.tsx
Normal file
132
nextjs/components/auth/LoginForm.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signIn } from "next-auth/react";
|
||||
|
||||
const LoginForm = () => {
|
||||
const [userId, setUserId] = useState("");
|
||||
const [userPassword, setUserPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await signIn("credentials", {
|
||||
userId,
|
||||
userPassword,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
setError("아이디 또는 비밀번호가 일치하지 않습니다.");
|
||||
} else {
|
||||
// 로그인 성공 시 홈으로 이동
|
||||
router.push("/");
|
||||
}
|
||||
} catch {
|
||||
setError("로그인에 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
아이디
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
placeholder="아이디를 입력해주세요"
|
||||
className="w-full h-12 px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-[#6b95c6] focus:border-transparent disabled:opacity-50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
비밀번호
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={userPassword}
|
||||
onChange={(e) => setUserPassword(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
placeholder="비밀번호를 입력해주세요"
|
||||
className="w-full h-12 px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-[#6b95c6] focus:border-transparent disabled:opacity-50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<Link
|
||||
href="/signup"
|
||||
className="text-gray-600 hover:text-[#6b95c6] hover:underline transition-colors font-medium"
|
||||
>
|
||||
회원가입
|
||||
</Link>
|
||||
<div className="flex items-center space-x-2 text-gray-600">
|
||||
<button
|
||||
type="button"
|
||||
className="hover:underline hover:text-[#6b95c6] transition-colors"
|
||||
>
|
||||
아이디 찾기
|
||||
</button>
|
||||
<span className="text-gray-300">|</span>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:underline hover:text-[#6b95c6] transition-colors"
|
||||
>
|
||||
비밀번호 찾기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full h-12 bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] hover:from-[#6b95c6] hover:to-[#5b85b6] text-white rounded-xl shadow-lg hover:shadow-xl hover:scale-[1.02] transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 flex items-center justify-center font-semibold"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
|
||||
로그인 중...
|
||||
</>
|
||||
) : (
|
||||
"로그인"
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 홈으로 돌아가기 */}
|
||||
<Link
|
||||
href="/"
|
||||
className="block text-center text-sm text-gray-600 hover:text-[#6b95c6] transition-colors"
|
||||
>
|
||||
← 홈으로 돌아가기
|
||||
</Link>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginForm;
|
||||
|
||||
288
nextjs/components/auth/SignUpForm.tsx
Normal file
288
nextjs/components/auth/SignUpForm.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { signUp, type SignUpData } from "@/lib/services";
|
||||
|
||||
// 비밀번호 검증 함수 (영문/숫자 포함 8자 이상)
|
||||
const validatePassword = (password: string): boolean => {
|
||||
const hasLetter = /[A-Za-z]/.test(password);
|
||||
const hasNumber = /\d/.test(password);
|
||||
const isLongEnough = password.length >= 8;
|
||||
return hasLetter && hasNumber && isLongEnough;
|
||||
};
|
||||
|
||||
const SignUpForm = () => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<SignUpData>({
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
userId: "",
|
||||
userPassword: "",
|
||||
userCheckPassword: "",
|
||||
userName: "",
|
||||
userPhone: "",
|
||||
authCode: "",
|
||||
},
|
||||
});
|
||||
|
||||
const password = watch("userPassword");
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const onSubmit = async (data: SignUpData) => {
|
||||
setSubmitError(null);
|
||||
|
||||
try {
|
||||
await signUp(data);
|
||||
|
||||
// 회원가입 성공
|
||||
alert("회원가입이 완료되었습니다. 로그인해주세요.");
|
||||
router.push("/login");
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "회원가입에 실패했습니다.";
|
||||
|
||||
// 아이디 중복 에러
|
||||
if (errorMessage.includes("아이디")) {
|
||||
setError("userId", {
|
||||
type: "manual",
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
// 전화번호 중복 에러
|
||||
else if (errorMessage.includes("전화번호")) {
|
||||
setError("userPhone", {
|
||||
type: "manual",
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
// 기타 에러
|
||||
else {
|
||||
setSubmitError(errorMessage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-8">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
{/* 아이디 */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
아이디 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
{...register("userId", {
|
||||
required: "아이디를 입력해주세요",
|
||||
minLength: {
|
||||
value: 4,
|
||||
message: "아이디는 4자 이상이어야 합니다",
|
||||
},
|
||||
})}
|
||||
disabled={isSubmitting}
|
||||
placeholder="아이디를 입력해주세요"
|
||||
className={`w-full h-12 px-4 py-3 bg-gray-50 border rounded-xl focus:outline-none focus:ring-2 focus:border-transparent disabled:opacity-50 transition-all ${
|
||||
errors.userId
|
||||
? "border-red-300 focus:ring-red-400"
|
||||
: "border-gray-200 focus:ring-[#6b95c6]"
|
||||
}`}
|
||||
/>
|
||||
{errors.userId && (
|
||||
<p className="text-red-500 text-xs mt-1">{errors.userId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
비밀번호 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
{...register("userPassword", {
|
||||
required: "비밀번호를 입력해주세요",
|
||||
validate: {
|
||||
validPassword: (value) =>
|
||||
validatePassword(value) ||
|
||||
"영문과 숫자를 포함하여 8자 이상 입력해주세요",
|
||||
},
|
||||
})}
|
||||
disabled={isSubmitting}
|
||||
placeholder="영문/숫자 포함 8자 이상"
|
||||
className={`w-full h-12 px-4 py-3 bg-gray-50 border rounded-xl focus:outline-none focus:ring-2 focus:border-transparent disabled:opacity-50 transition-all ${
|
||||
errors.userPassword
|
||||
? "border-red-300 focus:ring-red-400"
|
||||
: "border-gray-200 focus:ring-[#6b95c6]"
|
||||
}`}
|
||||
/>
|
||||
{errors.userPassword && (
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
{errors.userPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 확인 */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
비밀번호 확인 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
{...register("userCheckPassword", {
|
||||
required: "비밀번호를 다시 입력해주세요",
|
||||
validate: {
|
||||
matchPassword: (value) =>
|
||||
value === password || "비밀번호가 일치하지 않습니다",
|
||||
},
|
||||
})}
|
||||
disabled={isSubmitting}
|
||||
placeholder="비밀번호를 다시 입력해주세요"
|
||||
className={`w-full h-12 px-4 py-3 bg-gray-50 border rounded-xl focus:outline-none focus:ring-2 focus:border-transparent disabled:opacity-50 transition-all ${
|
||||
errors.userCheckPassword
|
||||
? "border-red-300 focus:ring-red-400"
|
||||
: "border-gray-200 focus:ring-[#6b95c6]"
|
||||
}`}
|
||||
/>
|
||||
{errors.userCheckPassword && (
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
{errors.userCheckPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 이름 */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
이름 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
{...register("userName", {
|
||||
required: "이름을 입력해주세요",
|
||||
minLength: {
|
||||
value: 2,
|
||||
message: "이름은 2자 이상이어야 합니다",
|
||||
},
|
||||
})}
|
||||
disabled={isSubmitting}
|
||||
placeholder="이름을 입력해주세요"
|
||||
className={`w-full h-12 px-4 py-3 bg-gray-50 border rounded-xl focus:outline-none focus:ring-2 focus:border-transparent disabled:opacity-50 transition-all ${
|
||||
errors.userName
|
||||
? "border-red-300 focus:ring-red-400"
|
||||
: "border-gray-200 focus:ring-[#6b95c6]"
|
||||
}`}
|
||||
/>
|
||||
{errors.userName && (
|
||||
<p className="text-red-500 text-xs mt-1">{errors.userName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 전화번호 */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
전화번호 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
{...register("userPhone", {
|
||||
required: "전화번호를 입력해주세요",
|
||||
pattern: {
|
||||
value: /^01[0-9][0-9]{7,8}$/,
|
||||
message: "하이픈(-) 없이 숫자만 입력해주세요 (예: 01012345678)",
|
||||
},
|
||||
})}
|
||||
disabled={isSubmitting}
|
||||
placeholder="01012345678"
|
||||
className={`w-full h-12 px-4 py-3 bg-gray-50 border rounded-xl focus:outline-none focus:ring-2 focus:border-transparent disabled:opacity-50 transition-all ${
|
||||
errors.userPhone
|
||||
? "border-red-300 focus:ring-red-400"
|
||||
: "border-gray-200 focus:ring-[#6b95c6]"
|
||||
}`}
|
||||
/>
|
||||
{errors.userPhone && (
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
{errors.userPhone.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 승인번호 */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
승인번호 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
{...register("authCode", {
|
||||
required: "승인번호를 입력해주세요",
|
||||
})}
|
||||
disabled={isSubmitting}
|
||||
className={`w-full h-12 px-4 py-3 bg-gray-50 border rounded-xl focus:outline-none focus:ring-2 focus:border-transparent disabled:opacity-50 transition-all ${
|
||||
errors.authCode
|
||||
? "border-red-300 focus:ring-red-400"
|
||||
: "border-gray-200 focus:ring-[#6b95c6]"
|
||||
}`}
|
||||
/>
|
||||
{errors.authCode && (
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
{errors.authCode.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 제출 에러 메시지 */}
|
||||
{submitError && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full h-12 bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] hover:from-[#6b95c6] hover:to-[#5b85b6] text-white rounded-xl shadow-lg hover:shadow-xl hover:scale-[1.02] transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 flex items-center justify-center font-semibold mt-6"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
|
||||
가입 중...
|
||||
</>
|
||||
) : (
|
||||
"회원가입"
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 로그인 페이지로 */}
|
||||
<div className="text-center text-sm">
|
||||
<span className="text-gray-600">이미 계정이 있으신가요? </span>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-[#6b95c6] hover:text-[#5b85b6] font-semibold hover:underline transition-colors"
|
||||
>
|
||||
로그인
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 홈으로 돌아가기 */}
|
||||
<Link
|
||||
href="/"
|
||||
className="block text-center text-sm text-gray-600 hover:text-[#6b95c6] transition-colors"
|
||||
>
|
||||
← 홈으로 돌아가기
|
||||
</Link>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignUpForm;
|
||||
|
||||
72
nextjs/components/landing/Contact.tsx
Normal file
72
nextjs/components/landing/Contact.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { MapPin, Youtube } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Contact() {
|
||||
return (
|
||||
<section id="contact" className="py-16 smalltablet:py-20 pc:py-24 bg-white">
|
||||
<div className="relative max-w-7xl mx-auto px-4 smalltablet:px-6 pc:px-8">
|
||||
{/* vertical dividers (pc+) */}
|
||||
<div className="hidden pc:block">
|
||||
<div
|
||||
className="absolute top-0 bottom-0 bg-gray-200"
|
||||
style={{ left: "33.3333%", width: 1 }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 bg-gray-200"
|
||||
style={{ left: "66.6666%", width: 1 }}
|
||||
/>
|
||||
{/* red accent bars near top */}
|
||||
<div
|
||||
className="absolute top-6 bg-[#6b95c6]"
|
||||
style={{ left: "33.3333%", width: 3, height: 70, transform: "translateX(-1px)" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-6 bg-[#6b95c6]"
|
||||
style={{ left: "66.6666%", width: 3, height: 70, transform: "translateX(-1px)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid pc:grid-cols-3 gap-10 smalltablet:gap-12 pc:gap-0 items-start">
|
||||
{/* Left: Title & description */}
|
||||
<div className="pr-0 pc:pr-12">
|
||||
<h2 className="text-3xl smalltablet:text-4xl pc:text-5xl font-black tracking-tight text-gray-900 mb-6 smalltablet:mb-8">
|
||||
Contact Us
|
||||
</h2>
|
||||
<p className="text-base smalltablet:text-lg pc:text-xl leading-7 smalltablet:leading-8 text-gray-500 max-w-[680px]">
|
||||
제자들교회는 언제나 여러분을 환영합니다.
|
||||
<br className="hidden smalltablet:block" />
|
||||
궁금하신 사항이나 기도제목이 있으시면 언제든 문의하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Middle: 오시는 길 */}
|
||||
<div className="text-center">
|
||||
<Link href="/directions" className="w-full rounded-xl p-6 smalltablet:p-8 pc:p-10 flex flex-col items-center transition-all duration-300 hover:-translate-y-1 cursor-pointer">
|
||||
<div className="w-24 h-24 smalltablet:w-28 smalltablet:h-28 rounded-full bg-gray-100 flex items-center justify-center mb-6 smalltablet:mb-8 transition-colors duration-300 hover:bg-[#6b95c6] group">
|
||||
<MapPin className="w-9 h-9 smalltablet:w-10 smalltablet:h-10 text-gray-700 group-hover:text-white transition-colors duration-300" strokeWidth={2} />
|
||||
</div>
|
||||
<h3 className="text-2xl smalltablet:text-3xl font-extrabold text-gray-900 mb-3 smalltablet:mb-4">오시는 길</h3>
|
||||
<p className="text-gray-500 text-base smalltablet:text-lg leading-7 smalltablet:leading-8">
|
||||
인천광역시 서구 고산후로 95번길 32 <br /> 명진프라자 3층 본당 / 4층 교육관
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Right: 유튜브 소개 */}
|
||||
<div className="text-center">
|
||||
<Link href="https://www.youtube.com/@Disciples2015" target="_blank" rel="noopener noreferrer" className="w-full rounded-xl p-6 smalltablet:p-8 pc:p-10 flex flex-col items-center transition-all duration-300 hover:-translate-y-1 cursor-pointer">
|
||||
<div className="w-24 h-24 smalltablet:w-28 smalltablet:h-28 rounded-full bg-gray-100 flex items-center justify-center mb-6 smalltablet:mb-8 transition-colors duration-300 hover:bg-[#6b95c6] group">
|
||||
<Youtube className="w-9 h-9 smalltablet:w-10 smalltablet:h-10 text-gray-700 group-hover:text-white transition-colors duration-300" strokeWidth={2} />
|
||||
</div>
|
||||
<h3 className="text-2xl smalltablet:text-3xl font-extrabold text-gray-900 mb-3 smalltablet:mb-4">유튜브 소개</h3>
|
||||
<p className="text-gray-500 text-base smalltablet:text-lg leading-7 smalltablet:leading-8">
|
||||
제자들교회의 유튜브 채널에서
|
||||
<br />설교와 다양한 영상을 만나보세요
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
56
nextjs/components/landing/FAQ.tsx
Normal file
56
nextjs/components/landing/FAQ.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
export default function FAQ() {
|
||||
const faqs = [
|
||||
{
|
||||
question: "처음 교회를 방문하려는데 어떻게 해야 하나요?",
|
||||
answer: "주일 오전 10시 30분 예배에 참석하시면 됩니다. 안내 데스크에서 새가족 등록을 도와드리며, 환영 선물도 준비되어 있습니다. 편안한 복장으로 오셔도 괜찮습니다."
|
||||
},
|
||||
{
|
||||
question: "주차 시설이 있나요?",
|
||||
answer: "네, 교회 지하에 주차장이 있습니다. 주일 예배 시간에는 주차 안내 봉사자가 도와드립니다. 만차인 경우 인근 공영주차장을 이용하실 수 있습니다."
|
||||
},
|
||||
{
|
||||
question: "어린이를 위한 프로그램이 있나요?",
|
||||
answer: "유아부(0-3세), 유치부(4-7세), 유년부(초등 1-3학년), 소년부(초등 4-6학년)로 나뉘어 연령별 맞춤 교육을 진행하고 있습니다. 전문 교사가 안전하게 돌봐드립니다."
|
||||
},
|
||||
{
|
||||
question: "새신자 교육은 어떻게 진행되나요?",
|
||||
answer: "매월 첫째 주 토요일 오후 2시에 새신자 교육이 진행됩니다. 총 4주 과정으로 기독교의 기본 교리와 우리 교회를 소개합니다. 수료 후 정식 등록 절차를 안내해 드립니다."
|
||||
},
|
||||
{
|
||||
question: "소그룹 모임에 참여하고 싶어요.",
|
||||
answer: "연령대별, 지역별로 다양한 소그룹이 운영되고 있습니다. 예배 후 안내 데스크나 담당 교역자에게 문의하시면 적합한 소그룹을 연결해 드립니다."
|
||||
},
|
||||
{
|
||||
question: "헌금은 어떻게 하나요?",
|
||||
answer: "현금, 계좌이체, 카드 결제 모두 가능합니다. 예배 시간에 헌금함에 넣으시거나, 교회 계좌로 이체하실 수 있습니다. 온라인 헌금 시스템도 운영 중입니다."
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="py-16 smalltablet:py-20 pc:py-24 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 smalltablet:px-6 pc:px-8">
|
||||
<div className="text-center mb-12 smalltablet:mb-16">
|
||||
<h2 className="text-3xl smalltablet:text-4xl pc:text-5xl font-black text-gray-900 mb-3 smalltablet:mb-4 tracking-tight">FAQ</h2>
|
||||
<p className="text-gray-600 text-sm smalltablet:text-base">자주 묻는 질문</p>
|
||||
</div>
|
||||
|
||||
{/* FAQ 리스트 */}
|
||||
<div className="grid grid-cols-1 smalltablet:grid-cols-2 gap-4 smalltablet:gap-6">
|
||||
{faqs.map((faq, index) => (
|
||||
<div key={index} className="p-4 space-y-4 smalltablet:p-6 rounded-2xl border border-gray-200 bg-white">
|
||||
<div className="flex items-center gap-3 smalltablet:gap-4 mb-4">
|
||||
<div className="w-9 h-9 smalltablet:w-10 smalltablet:h-10 bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] rounded-full flex items-center justify-center shrink-0 text-white font-bold text-sm smalltablet:text-base">Q</div>
|
||||
<h3 className="flex-1 text-base smalltablet:text-lg pc:text-xl font-bold text-gray-900 leading-snug">{faq.question}</h3>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 smalltablet:gap-4">
|
||||
<div className="w-9 h-9 smalltablet:w-10 smalltablet:h-10 bg-linear-to-br from-[#b8d5f0] to-[#a8c5e0] rounded-full flex items-center justify-center shrink-0 text-white font-bold text-sm smalltablet:text-base">A</div>
|
||||
<p className="text-gray-700 text-sm smalltablet:text-base leading-relaxed">{faq.answer}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
144
nextjs/components/landing/Hero.tsx
Normal file
144
nextjs/components/landing/Hero.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import Image from "next/image";
|
||||
import heroImage1 from "@/public/home/hero/image.webp";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
|
||||
export default function Hero() {
|
||||
const [currentSlide, setCurrentSlide] = useState(0); // 비디오로 시작
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
const slide = {
|
||||
image: heroImage1
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="relative h-screen overflow-hidden pt-safe">
|
||||
|
||||
{/* 배경 슬라이드 */}
|
||||
<div className="absolute inset-0">
|
||||
{/* 비디오 배경 */}
|
||||
<div
|
||||
className={`absolute inset-0 transition-opacity duration-1000 ${
|
||||
currentSlide === 0 ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full h-full object-cover"
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
preload="auto"
|
||||
poster="/home/hero/image2.webp"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
<source src="/home/hero/video1.webm" type="video/webm" />
|
||||
</video>
|
||||
<div className="absolute inset-0 bg-linear-to-b from-slate-900/40 via-slate-900/60 to-slate-900/80" />
|
||||
</div>
|
||||
|
||||
{/* 이미지 배경 */}
|
||||
<div
|
||||
className={`absolute inset-0 transition-opacity duration-1000 ${
|
||||
currentSlide === 1 ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={slide.image}
|
||||
alt="Hero background"
|
||||
fill
|
||||
priority
|
||||
placeholder="blur"
|
||||
className="object-cover scale-105"
|
||||
sizes="100vw"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-linear-to-b from-slate-900/40 via-slate-900/60 to-slate-900/80" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
<div className="relative h-full flex flex-col">
|
||||
<div className="flex-1 flex items-center justify-center relative">
|
||||
<div className="max-w-7xl mx-auto px-4 smalltablet:px-6 pc:px-8 w-full">
|
||||
{/* Welcome Home 텍스트 */}
|
||||
<div className="flex flex-col items-center justify-center text-center space-y-4">
|
||||
<h1
|
||||
className="text-6xl smalltablet:text-7xl pc:text-9xl font-bold tracking-wide text-white
|
||||
animate-[fade-in-up_1s_ease-out,float_3s_ease-in-out_infinite_1s]"
|
||||
style={{
|
||||
textShadow: '0 4px 20px rgba(0, 0, 0, 0.5), 0 0 40px rgba(255, 255, 255, 0.1)'
|
||||
}}
|
||||
>
|
||||
Welcome Home!
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 슬라이드 인디케이터 */}
|
||||
<div className="absolute bottom-20 smalltablet:bottom-24 left-1/2 -translate-x-1/2 flex gap-3 z-10">
|
||||
{[0, 1].map((index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setCurrentSlide(index)}
|
||||
className={`transition-all duration-300 rounded-full ${
|
||||
currentSlide === index
|
||||
? 'w-10 h-3 bg-white'
|
||||
: 'w-3 h-3 bg-white/40 hover:bg-white/60'
|
||||
}`}
|
||||
aria-label={`슬라이드 ${index + 1}로 이동`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 하단 메뉴 아이콘 그리드 */}
|
||||
{/* <div className="hidden smalltablet:block pb-8 smalltablet:pb-12 pc:pb-16">
|
||||
<div className="max-w-7xl mx-auto px-4 smalltablet:px-6 pc:px-8 w-full">
|
||||
<div className="grid grid-cols-3 smalltablet:grid pc:grid-cols-6 gap-4 smalltablet:gap-6 max-w-6xl mx-auto">
|
||||
{menuItems.map((item, index) => {
|
||||
const IconComponent = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={index}
|
||||
href={item.href}
|
||||
className="group flex flex-col items-center justify-center p-6 smalltablet:p-8 bg-white/10 backdrop-blur-sm border border-white/20 rounded-lg hover:bg-white/20 transition-all duration-300 hover:scale-105 hover:border-white/40"
|
||||
>
|
||||
<div className="text-white mb-3 smalltablet:mb-4 group-hover:scale-110 transition-transform duration-300">
|
||||
<IconComponent className="w-12 h-12 smalltablet:w-14 smalltablet:h-14" strokeWidth={1.5} />
|
||||
</div>
|
||||
<span className="text-white text-sm smalltablet:text-base font-medium text-center">
|
||||
{item.title}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
{/* TOP 버튼 - 현대적인 디자인 */}
|
||||
<button
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
className="fixed cursor-pointer bottom-6 right-6 smalltablet:bottom-8 smalltablet:right-8
|
||||
bg-linear-to-br from-[#6b95c6] to-[#5a7fb0] hover:from-[#7aa5d6] hover:to-[#6b95c6]
|
||||
text-white
|
||||
p-3 smalltablet:p-4 rounded-full
|
||||
shadow-lg hover:shadow-2xl
|
||||
transition-all duration-300
|
||||
z-50
|
||||
border-2 border-white/20
|
||||
hover:scale-110 hover:-translate-y-1
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-[#6b95c6]
|
||||
active:scale-95
|
||||
group"
|
||||
aria-label="맨 위로 이동"
|
||||
>
|
||||
<ArrowUp className="w-5 h-5 smalltablet:w-6 smalltablet:h-6 group-hover:translate-y-[-2px] transition-transform" strokeWidth={2.5} />
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
77
nextjs/components/landing/Intro.tsx
Normal file
77
nextjs/components/landing/Intro.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import introImage1 from "@/public/home/intro/church1.webp";
|
||||
import introImage3 from "@/public/home/intro/pray.webp";
|
||||
|
||||
export default function Intro() {
|
||||
const items = [
|
||||
{
|
||||
image: introImage1,
|
||||
title: "주일 설교",
|
||||
subtitle: "Sunday Sermon",
|
||||
category: "sermon",
|
||||
},
|
||||
{
|
||||
image: introImage3,
|
||||
title: "금요 성령집회",
|
||||
subtitle: "Friday Meeting",
|
||||
category: "friday",
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<section id="intro" className="py-16 smalltablet:py-20 pc:py-24 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 smalltablet:px-6 pc:px-8">
|
||||
<div className="flex flex-col smalltablet:flex-row justify-between smalltablet:items-end gap-6 smalltablet:gap-4 mb-12 smalltablet:mb-16">
|
||||
<div>
|
||||
<h2 className="text-3xl smalltablet:text-4xl pc:text-5xl font-black text-gray-800 mb-2 tracking-tight">Worship services.</h2>
|
||||
<p className="text-gray-600 text-lg smalltablet:text-xl font-semibold">금주의 예배와 찬양</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 smalltablet:grid-cols-2 pc:grid-cols-2 gap-3 smalltablet:gap-4 pc:gap-8 smalltablet:max-w-2xl smalltablet:mx-auto pc:max-w-7xl">
|
||||
{items.map((item, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
href={`/worship?category=${item.category}`}
|
||||
className="group hover:cursor-pointer rounded-2xl smalltablet:rounded-3xl p-8 smalltablet:p-10 relative overflow-hidden hover:shadow-xl transition-shadow col-span-2 aspect-2/1 pc:col-span-1 pc:aspect-[16/9] block"
|
||||
>
|
||||
{/* 배경 이미지 레이어 */}
|
||||
<Image
|
||||
src={item.image}
|
||||
alt={item.title}
|
||||
fill
|
||||
placeholder="blur"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
/>
|
||||
{/* 검은색 오버레이 */}
|
||||
<div className="absolute inset-0 z-10 bg-black opacity-50 group-hover:opacity-20 transition-opacity"/>
|
||||
|
||||
{/* 컨텐츠 */}
|
||||
<div className="absolute inset-0 z-20 flex flex-col justify-center items-center">
|
||||
{/* 텍스트 */}
|
||||
<div className="flex flex-col justify-center items-center w-full">
|
||||
<h3 className="text-4xl smalltablet:text-5xl pc:text-5xl font-extrabold text-white mb-0 smalltablet:mb-3 text-center smalltablet:text-left">
|
||||
{item.title}
|
||||
</h3>
|
||||
<h4 className="smalltablet:block text-xl smalltablet:text-2xl font-bold text-white mb-3 smalltablet:mb-3 pc:mb-6">
|
||||
{item.subtitle}
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽 아래 화살표 */}
|
||||
<div className="hidden smalltablet:block absolute bottom-5 right-5 smalltablet:bottom-6 smalltablet:right-6 z-20">
|
||||
<svg className="w-6 h-6 smalltablet:w-8 smalltablet:h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
59
nextjs/components/landing/Ministries.tsx
Normal file
59
nextjs/components/landing/Ministries.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
export default function Ministries() {
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: "담임목사님을 소개합니다",
|
||||
description: "이용희 담임목사님의 인사말과 \n 4가지의 핵심 가치를 소개합니다",
|
||||
buttonText: "PASTOR"
|
||||
},
|
||||
{
|
||||
title: "시설 안내",
|
||||
description: "본관과 교육관의 시설 현황을 \n 안내합니다.",
|
||||
buttonText: "FACILITY"
|
||||
},
|
||||
{
|
||||
title: "조직 구성 안내",
|
||||
description: "대전제일교회의 부서와 조직을 \n 안내합니다.",
|
||||
buttonText: "ORGANIZATION"
|
||||
},
|
||||
{
|
||||
title: "찾아오시는길 안내",
|
||||
description: "교회 주변의 약도와 \n 대중교통 노선을 안내 합니다.",
|
||||
buttonText: "CONTACT US"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<section
|
||||
className="py-12 smalltablet:py-16 pc:py-20 bg-gray-50"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 smalltablet:px-6 pc:px-8">
|
||||
<h2 className="text-3xl smalltablet:text-4xl pc:text-5xl font-black text-gray-900 mb-8 smalltablet:mb-10 pc:mb-12 text-end">More.</h2>
|
||||
<div className="grid grid-cols-1 smalltablet:grid-cols-2 gap-4 smalltablet:gap-6 pc:gap-8">
|
||||
{sections.map((section, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-row smalltablet:flex-col pc:flex-row items-center smalltablet:items-start pc:items-center justify-between smalltablet:justify-start pc:justify-between gap-4 smalltablet:gap-4 pc:gap-8 bg-[#e8f2fa] rounded-xl smalltablet:rounded-2xl p-4 smalltablet:p-6 pc:p-8"
|
||||
>
|
||||
{/* 왼쪽/위: 텍스트 콘텐츠 */}
|
||||
<div className="flex-1 smalltablet:w-full pc:flex-1">
|
||||
<h3 className="text-lg smalltablet:text-xl pc:text-2xl font-bold text-gray-900 mb-2 smalltablet:mb-2.5 pc:mb-3">
|
||||
{section.title}
|
||||
</h3>
|
||||
<p className="text-sm smalltablet:text-base pc:text-lg text-gray-600 leading-relaxed whitespace-pre-line">
|
||||
{section.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽/아래: 버튼 */}
|
||||
<button className="shrink-0 smalltablet:w-full smalltablet:mt-2 pc:w-auto pc:mt-0 bg-[#7ba5d6] hover:bg-[#6b95c6] text-white font-semibold uppercase px-4 smalltablet:px-8 pc:px-10 py-2.5 smalltablet:py-3.5 pc:py-4 rounded-xl smalltablet:rounded-2xl text-xs smalltablet:text-base pc:text-lg transition-colors duration-200 shadow-sm hover:shadow-md whitespace-nowrap">
|
||||
{section.buttonText}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
211
nextjs/components/landing/NewsAndGallery.tsx
Normal file
211
nextjs/components/landing/NewsAndGallery.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Announcement {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface GalleryPost {
|
||||
id: number;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
thumbnailUrl?: string;
|
||||
}
|
||||
|
||||
export default function NewsAndGalleryClient() {
|
||||
const [newsItems, setNewsItems] = useState<Announcement[]>([]);
|
||||
const [galleryPosts, setGalleryPosts] = useState<GalleryPost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [announcementsRes, galleryRes] = await Promise.all([
|
||||
fetch('/api/announcements?limit=6'),
|
||||
fetch('/api/gallery?limit=4'),
|
||||
]);
|
||||
|
||||
const [announcementsData, galleryData] = await Promise.all([
|
||||
announcementsRes.json(),
|
||||
galleryRes.json(),
|
||||
]);
|
||||
|
||||
setNewsItems(announcementsData.data || []);
|
||||
|
||||
// 갤러리 썸네일 URL 가져오기
|
||||
const galleryWithUrls = await Promise.all(
|
||||
(galleryData.data || []).map(async (post: GalleryPost & { images?: Array<{ fileKey: string }> }) => {
|
||||
if (post.images?.[0]?.fileKey) {
|
||||
const urlRes = await fetch('/api/files/download-url', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ fileKey: post.images[0].fileKey }),
|
||||
});
|
||||
const urlData = await urlRes.json();
|
||||
return { ...post, thumbnailUrl: urlData.data?.downloadUrl };
|
||||
}
|
||||
return post;
|
||||
})
|
||||
);
|
||||
|
||||
setGalleryPosts(galleryWithUrls);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
return { day, date: `${year}.${month}` };
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="py-16 smalltablet:py-20 pc:py-24 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 smalltablet:px-6 pc:px-8">
|
||||
<div className="grid pc:grid-cols-2 gap-10 smalltablet:gap-12">
|
||||
{/* 왼쪽: 소식 */}
|
||||
<div className="overflow-hidden">
|
||||
<div className="flex justify-between items-center gap-3 smalltablet:gap-4 pb-3 smalltablet:pb-4 mb-8 smalltablet:mb-10 border-b border-gray-200">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-3xl smalltablet:text-4xl pc:text-5xl font-black text-gray-900 mb-1 smalltablet:mb-2 tracking-tight">News</h2>
|
||||
<p className="text-gray-600 text-sm smalltablet:text-base">소식</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/announcements"
|
||||
aria-label="더보기"
|
||||
className="w-12 h-12 smalltablet:w-14 smalltablet:h-14 shrink-0 flex-none rounded-xl bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] hover:from-[#6b95c6] hover:to-[#5f89bc] text-white flex items-center justify-center transition-colors shadow-sm border border-[#6b95c6]"
|
||||
>
|
||||
<svg className="w-4 h-4 smalltablet:w-5 smalltablet:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{loading ? (
|
||||
Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="bg-white p-4 smalltablet:p-5 rounded-xl border border-gray-200 animate-pulse">
|
||||
<div className="flex items-start gap-3 smalltablet:gap-4">
|
||||
<div className="shrink-0 bg-gray-200 w-14 h-14 smalltablet:w-16 smalltablet:h-16 rounded-lg" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-5 bg-gray-200 rounded w-3/4" />
|
||||
<div className="h-4 bg-gray-200 rounded w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : newsItems.length > 0 ? (
|
||||
newsItems.slice(0, 6).map((item, index) => {
|
||||
const { day, date } = formatDate(item.createdAt);
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={`/announcements/${item.id}`}
|
||||
className={`bg-white hover:bg-gray-50 p-4 smalltablet:p-5 rounded-xl transition-all duration-200 cursor-pointer border border-gray-200 group block ${index >= 4 ? 'hidden smalltablet:block' : ''}`}
|
||||
>
|
||||
<div className="flex items-start gap-3 smalltablet:gap-4">
|
||||
<div className="shrink-0 bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] text-white w-14 h-14 smalltablet:w-16 smalltablet:h-16 rounded-lg flex flex-col items-center justify-center">
|
||||
<div className="text-xl smalltablet:text-2xl font-bold">{day}</div>
|
||||
<div className="text-xs mt-0.5 smalltablet:mt-1">{date}</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base smalltablet:text-lg font-bold text-gray-900 mb-1 smalltablet:mb-2 group-hover:text-[#6b95c6] transition-colors">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-xs smalltablet:text-sm text-gray-600 leading-relaxed line-clamp-2">
|
||||
{item.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">아직 공지사항이 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 갤러리 */}
|
||||
<div className="overflow-hidden">
|
||||
<div className="flex justify-between items-center gap-3 smalltablet:gap-4 pb-3 smalltablet:pb-4 mb-8 smalltablet:mb-10 border-b border-gray-200">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-3xl smalltablet:text-4xl pc:text-5xl font-black text-gray-900 mb-1 smalltablet:mb-2 tracking-tight">Photos</h2>
|
||||
<p className="text-gray-600 text-sm smalltablet:text-base">앨범</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/gallery"
|
||||
aria-label="더보기"
|
||||
className="w-12 h-12 smalltablet:w-14 smalltablet:h-14 shrink-0 flex-none rounded-xl bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] hover:from-[#6b95c6] hover:to-[#5f89bc] text-white flex items-center justify-center transition-colors shadow-sm border border-[#6b95c6]"
|
||||
>
|
||||
<svg className="w-4 h-4 smalltablet:w-5 smalltablet:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 smalltablet:mx-auto smalltablet:grid-cols-3 smalltablet:gap-4 pc:grid-cols-2">
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="aspect-square bg-gray-200 rounded-xl animate-pulse" />
|
||||
))
|
||||
) : galleryPosts.length > 0 ? (
|
||||
galleryPosts.map((post) => {
|
||||
const { date } = formatDate(post.createdAt);
|
||||
return (
|
||||
<Link
|
||||
key={post.id}
|
||||
href={`/gallery/${post.id}`}
|
||||
className="group relative bg-white rounded-xl overflow-hidden shadow-md hover:shadow-xl transition-all duration-300 hover:-translate-y-1 cursor-pointer border border-gray-200 block"
|
||||
>
|
||||
<div className="aspect-square bg-gray-100 relative overflow-hidden">
|
||||
{post.thumbnailUrl ? (
|
||||
<Image
|
||||
src={post.thumbnailUrl}
|
||||
alt={post.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 50vw, 25vw"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gray-200 text-gray-400">
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-linear-to-t from-black/70 to-transparent transition-opacity flex items-end">
|
||||
<div className="p-2.5 smalltablet:p-3 text-white w-full">
|
||||
<h3 className="text-xs smalltablet:text-sm font-bold mb-0.5 smalltablet:mb-1">
|
||||
{post.title}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-200">{date}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="col-span-2 text-center py-8 text-gray-500">아직 갤러리가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
117
nextjs/components/landing/ServiceTimes.tsx
Normal file
117
nextjs/components/landing/ServiceTimes.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export default function ServiceTimes() {
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
|
||||
const programs = [
|
||||
{
|
||||
number: "01",
|
||||
fraction: "/ 06",
|
||||
title: "정신건강검진 프로그램",
|
||||
description: "편하게 방문하시면 전문의와 상담을 통해 필요한 검사 프로그램을 안내해드립니다.",
|
||||
icon: (
|
||||
<svg className="w-24 h-24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
),
|
||||
bgColor: "bg-linear-to-br from-[#7ba5d6] to-[#6b95c6]"
|
||||
},
|
||||
{
|
||||
number: "02",
|
||||
fraction: "/ 06",
|
||||
title: "성인/청소년 종합심리검사",
|
||||
description: "현재 겪고 있는 다양한 심리적 어려움과 그 원인을 다각하고 전문적인 도구를 사용하여 파악하고 이를 객관할 수 있는 검사입니다.",
|
||||
icon: (
|
||||
<svg className="w-24 h-24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
bgColor: "bg-linear-to-br from-[#a8c5e0] to-[#98b5d0]"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<section id="exam" className="py-16 smalltablet:py-20 pc:py-24 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 smalltablet:px-6 pc:px-8">
|
||||
<div className="grid pc:grid-cols-2 gap-8 smalltablet:gap-12 pc:gap-16 items-center">
|
||||
{/* 왼쪽 텍스트 */}
|
||||
<div>
|
||||
<h2 className="text-3xl smalltablet:text-4xl pc:text-5xl font-black text-gray-800 mb-6 smalltablet:mb-8 tracking-tight">
|
||||
검사 프로그램.
|
||||
</h2>
|
||||
<p className="text-gray-600 text-base smalltablet:text-lg leading-relaxed mb-8 smalltablet:mb-12">
|
||||
마음우산에서는 체계화된 시스템으로 정서상태, 성격, 스트레스,<br className="hidden pc:block" />
|
||||
인지능력, 집중력, 뇌기능상태를 종합적으로 평가하여 제공하는<br className="hidden pc:block" />
|
||||
프로그램을 운영합니다.
|
||||
</p>
|
||||
<button className="bg-linear-to-r from-[#7ba5d6] to-[#6b95c6] hover:from-[#6b95c6] hover:to-[#5b85b6] text-white px-6 py-2.5 smalltablet:px-8 smalltablet:py-3 text-sm smalltablet:text-base flex items-center gap-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#6b95c6] active:scale-[0.98] rounded-lg">
|
||||
VIEW MORE
|
||||
<svg className="w-4 h-4 smalltablet:w-5 smalltablet:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽 슬라이더 */}
|
||||
<div className="relative">
|
||||
<div className="flex gap-6 overflow-hidden">
|
||||
{programs.map((program, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`shrink-0 w-full ${program.bgColor} rounded-2xl smalltablet:rounded-3xl p-8 smalltablet:p-10 pc:p-12 relative transition-all ${
|
||||
currentSlide === index ? 'opacity-100' : 'opacity-0 absolute inset-0'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="text-white opacity-70 mb-4 smalltablet:mb-6">
|
||||
<svg className="w-16 h-16 smalltablet:w-20 smalltablet:h-20 pc:w-24 pc:h-24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{program.icon.props.children}
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-2xl smalltablet:text-3xl font-bold text-white mb-4 smalltablet:mb-6">
|
||||
{program.title}
|
||||
</h3>
|
||||
<p className="text-white/90 text-sm smalltablet:text-base leading-relaxed mb-6 smalltablet:mb-8 grow">
|
||||
{program.description}
|
||||
</p>
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="text-white/40 text-6xl smalltablet:text-7xl pc:text-8xl font-bold">
|
||||
{program.number}
|
||||
</div>
|
||||
<div className="text-white text-base smalltablet:text-lg">
|
||||
{program.fraction}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 슬라이더 컨트롤 */}
|
||||
<div className="flex gap-3 smalltablet:gap-4 mt-6 smalltablet:mt-8">
|
||||
<button
|
||||
onClick={() => setCurrentSlide(Math.max(0, currentSlide - 1))}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#6b95c6] active:scale-[0.98] rounded"
|
||||
>
|
||||
<svg className="w-8 h-8 smalltablet:w-10 smalltablet:h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentSlide(Math.min(programs.length - 1, currentSlide + 1))}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#6b95c6] active:scale-[0.98] rounded"
|
||||
>
|
||||
<svg className="w-8 h-8 smalltablet:w-10 smalltablet:h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
82
nextjs/components/landing/Welcome.tsx
Normal file
82
nextjs/components/landing/Welcome.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import Image from "next/image";
|
||||
import circlesImage from "@/public/home/welcome/circles.webp";
|
||||
|
||||
export default function Welcome() {
|
||||
const features = [
|
||||
{
|
||||
title: "제자들 교회에 처음 오셨나요?",
|
||||
description: "“너희는 위로하라 내 백성을 위로하라”(사40:1) \n 제자들교회는 닫힌 숨이 열리는 진정한 위로의 공동체입니다. ",
|
||||
color:"#a9c6e1"
|
||||
},
|
||||
{
|
||||
title: "말씀으로 살아가는 교회",
|
||||
description: "“너희가 내 말에 거하면 참으로 내 제자가 되고”(요8:31) \n 제자들교회는 말씀을 묵상함으로 하나님의 창조적인 역사를 \n 경험해 가는 공동체입니다. ",
|
||||
color:"#94b7d6"
|
||||
},
|
||||
{
|
||||
title: "서로 사랑하는 교회",
|
||||
description: "“너희가 서로 사랑하면 이로써 모든 사람이 너희가 내 제자인 줄 알리라”(요13:35) \n 제자들교회는 서로의 외로움을 채우며 마음껏 섬겨가는 사랑의 공동체입니다. ",
|
||||
color:"#88aad2"
|
||||
},
|
||||
{
|
||||
title: "복음 전도와 선교를 위해 존재하는 교회",
|
||||
description: "“너희가 열매를 많이 맺으면 내 아버지께서 영광을 받으실 것이요 \n 너희는 내 제자가 되리라”(요15:8) \n 제자들교회는 한 영혼을 살리는 복음전도와 선교에 \n 집중하는 교회입니다. ",
|
||||
color:"#6d96c5"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="py-16 smalltablet:py-20 pc:py-24 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 smalltablet:px-6 pc:px-8">
|
||||
{/* 제목 */}
|
||||
<div className="flex flex-col items-end justify-end mb-12 smalltablet:mb-16">
|
||||
<h2 className="text-3xl smalltablet:text-4xl pc:text-5xl font-black text-end text-gray-900 mb-3 smalltablet:mb-4">
|
||||
Welcome!
|
||||
</h2>
|
||||
<p className="text-sm smalltablet:text-base pc:text-lg text-gray-700 leading-relaxed whitespace-pre-line text-end">
|
||||
제자들교회에 오신 것을 환영합니다!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
<div className="flex flex-row items-center justify-center gap-4 smalltablet:gap-6 pc:gap-0">
|
||||
{/* 왼쪽: 교회 아이콘 */}
|
||||
<div className="w-1/3 smalltablet:w-2/5 pc:w-1/2 flex justify-center shrink-0">
|
||||
<div className="relative w-24 h-24 smalltablet:w-48 smalltablet:h-48 pc:w-[450px] pc:h-[450px]">
|
||||
{/* 원형 이미지 */}
|
||||
<Image
|
||||
src={circlesImage}
|
||||
alt="청주에덴교회"
|
||||
fill
|
||||
className="object-contain drop-shadow-2xl"
|
||||
placeholder="blur"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 특징 리스트 */}
|
||||
<div className="pc:w-1/2 space-y-4 smalltablet:space-y-6 pc:space-y-8">
|
||||
{features.map((feature, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border-l-4 pl-4 smalltablet:pl-6 pc:pl-6"
|
||||
style={{ borderColor: feature.color }}
|
||||
>
|
||||
<h3
|
||||
className="text-sm smalltablet:text-xl pc:text-2xl font-bold mb-1 smalltablet:mb-2 pc:mb-2 leading-tight"
|
||||
style={{ color: feature.color }}
|
||||
>
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="text-xs hidden smalltablet:block smalltablet:text-sm pc:text-base text-gray-700 leading-relaxed whitespace-pre-line">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
11
nextjs/components/providers/SessionProvider.tsx
Normal file
11
nextjs/components/providers/SessionProvider.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { SessionProvider as NextAuthSessionProvider } from "next-auth/react";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export default function SessionProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<NextAuthSessionProvider refetchInterval={0} refetchOnWindowFocus={true}>
|
||||
{children}
|
||||
</NextAuthSessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
178
nextjs/components/seo/JsonLd.tsx
Normal file
178
nextjs/components/seo/JsonLd.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React from 'react'
|
||||
|
||||
interface JsonLdProps {
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function JsonLd({ data }: JsonLdProps) {
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 교회 조직 구조화 데이터
|
||||
export function OrganizationJsonLd() {
|
||||
const organizationData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Church',
|
||||
name: '제자들교회',
|
||||
alternateName: '인천 제자들교회',
|
||||
url: 'https://www.disciples-church.com',
|
||||
logo: 'https://www.disciples-church.com/logo.webp',
|
||||
description: '인천 제자들교회 - 성경적 제자도를 실천하는 교회',
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: '인천광역시',
|
||||
addressCountry: 'KR',
|
||||
},
|
||||
contactPoint: {
|
||||
'@type': 'ContactPoint',
|
||||
contactType: 'customer service',
|
||||
availableLanguage: 'Korean',
|
||||
},
|
||||
sameAs: [
|
||||
// 소셜 미디어 링크가 있다면 여기에 추가
|
||||
// 'https://www.facebook.com/jaejadle',
|
||||
// 'https://www.instagram.com/jaejadle',
|
||||
// 'https://www.youtube.com/@jaejadle',
|
||||
],
|
||||
}
|
||||
|
||||
return <JsonLd data={organizationData} />
|
||||
}
|
||||
|
||||
// 웹사이트 구조화 데이터
|
||||
export function WebSiteJsonLd() {
|
||||
const websiteData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: '제자들교회',
|
||||
alternateName: '인천 제자들교회',
|
||||
url: 'https://www.disciples-church.com',
|
||||
description: '인천 제자들교회 - 성경적 제자도를 실천하는 교회',
|
||||
inLanguage: 'ko-KR',
|
||||
publisher: {
|
||||
'@type': 'Church',
|
||||
name: '제자들교회',
|
||||
url: 'https://www.disciples-church.com',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: 'https://www.disciples-church.com/logo.webp',
|
||||
},
|
||||
},
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: {
|
||||
'@type': 'EntryPoint',
|
||||
urlTemplate: 'https://www.disciples-church.com/search?q={search_term_string}',
|
||||
},
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
}
|
||||
|
||||
return <JsonLd data={websiteData} />
|
||||
}
|
||||
|
||||
// 빵가루 네비게이션 구조화 데이터
|
||||
interface BreadcrumbItem {
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export function BreadcrumbJsonLd({ items }: { items: BreadcrumbItem[] }) {
|
||||
const breadcrumbData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: items.map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: item.name,
|
||||
item: item.url,
|
||||
})),
|
||||
}
|
||||
|
||||
return <JsonLd data={breadcrumbData} />
|
||||
}
|
||||
|
||||
// 예배 이벤트 구조화 데이터
|
||||
interface ServiceEvent {
|
||||
name: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
description?: string
|
||||
location?: string
|
||||
}
|
||||
|
||||
export function ServiceEventJsonLd({ event }: { event: ServiceEvent }) {
|
||||
const eventData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Event',
|
||||
name: event.name,
|
||||
startDate: event.startDate,
|
||||
endDate: event.endDate,
|
||||
description: event.description,
|
||||
location: {
|
||||
'@type': 'Place',
|
||||
name: event.location || '제자들교회',
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: '인천광역시',
|
||||
addressCountry: 'KR',
|
||||
},
|
||||
},
|
||||
organizer: {
|
||||
'@type': 'Church',
|
||||
name: '제자들교회',
|
||||
url: 'https://www.disciples-church.com',
|
||||
},
|
||||
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
|
||||
eventStatus: 'https://schema.org/EventScheduled',
|
||||
}
|
||||
|
||||
return <JsonLd data={eventData} />
|
||||
}
|
||||
|
||||
// 기사/블로그 포스트 구조화 데이터
|
||||
interface ArticleData {
|
||||
headline: string
|
||||
description: string
|
||||
datePublished: string
|
||||
dateModified?: string
|
||||
author?: string
|
||||
image?: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export function ArticleJsonLd({ article }: { article: ArticleData }) {
|
||||
const articleData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: article.headline,
|
||||
description: article.description,
|
||||
datePublished: article.datePublished,
|
||||
dateModified: article.dateModified || article.datePublished,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: article.author || '제자들교회',
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: '제자들교회',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: 'https://www.disciples-church.com/logo.webp',
|
||||
},
|
||||
},
|
||||
image: article.image,
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': article.url,
|
||||
},
|
||||
}
|
||||
|
||||
return <JsonLd data={articleData} />
|
||||
}
|
||||
|
||||
77
nextjs/components/seo/MetaTags.tsx
Normal file
77
nextjs/components/seo/MetaTags.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 동적 메타 태그 컴포넌트
|
||||
*
|
||||
* 사용 예시:
|
||||
* <MetaTags
|
||||
* title="페이지 제목"
|
||||
* description="페이지 설명"
|
||||
* keywords={['키워드1', '키워드2']}
|
||||
* image="/images/og-image.jpg"
|
||||
* url="/about"
|
||||
* />
|
||||
*/
|
||||
|
||||
import { Metadata } from 'next';
|
||||
|
||||
interface MetaTagsProps {
|
||||
title: string;
|
||||
description: string;
|
||||
keywords?: string[];
|
||||
image?: string;
|
||||
url: string;
|
||||
type?: 'website' | 'article';
|
||||
author?: string;
|
||||
publishedTime?: string;
|
||||
modifiedTime?: string;
|
||||
}
|
||||
|
||||
export function generateMetadata({
|
||||
title,
|
||||
description,
|
||||
keywords = [],
|
||||
image,
|
||||
url,
|
||||
type = 'website',
|
||||
author,
|
||||
}: MetaTagsProps): Metadata {
|
||||
const baseUrl = 'https://www.disciples-church.com';
|
||||
const fullUrl = `${baseUrl}${url}`;
|
||||
const ogImage = image ? `${baseUrl}${image}` : `${baseUrl}/opengraph-image.jpg`;
|
||||
|
||||
const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
keywords: [...keywords, '제자들교회', '인천', '교회'],
|
||||
authors: author ? [{ name: author }] : [{ name: '제자들교회' }],
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: fullUrl,
|
||||
type,
|
||||
images: [
|
||||
{
|
||||
url: ogImage,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: title,
|
||||
},
|
||||
],
|
||||
siteName: '제자들교회',
|
||||
locale: 'ko_KR',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description,
|
||||
images: [ogImage],
|
||||
},
|
||||
alternates: {
|
||||
canonical: fullUrl,
|
||||
},
|
||||
};
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export default generateMetadata;
|
||||
|
||||
81
nextjs/components/widgets/AuthButton.tsx
Normal file
81
nextjs/components/widgets/AuthButton.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
|
||||
const AuthButton = ({
|
||||
shouldShowScrolled = false,
|
||||
onLinkClick
|
||||
}: {
|
||||
shouldShowScrolled: boolean;
|
||||
onLinkClick?: () => void;
|
||||
}) => {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await signOut({ redirect: false });
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error("Logout error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="h-9 w-18 bg-gray-400 animate-pulse rounded-lg"/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (session?.user) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 구분선 */}
|
||||
<div className={`h-6 w-px transition-colors ${
|
||||
shouldShowScrolled
|
||||
? "bg-gray-300"
|
||||
: "bg-white/30 pc:group-hover:bg-gray-300"
|
||||
}`} />
|
||||
|
||||
<span className={`text-sm transition-colors ${
|
||||
shouldShowScrolled
|
||||
? "text-gray-600"
|
||||
: "text-white/90 pc:group-hover:text-gray-600"
|
||||
}`}>
|
||||
{session.user.name}님
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
shouldShowScrolled
|
||||
? "text-gray-700 hover:text-gray-900 bg-gray-200 hover:bg-gray-300"
|
||||
: "text-white hover:text-gray-200 bg-white/10 hover:bg-white/20 pc:group-hover:text-gray-700 pc:group-hover:bg-gray-200 pc:group-hover:hover:text-gray-900 pc:group-hover:hover:bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Link
|
||||
href="/login"
|
||||
onClick={onLinkClick}
|
||||
className="px-4 py-2 w-18 text-center text-sm font-medium text-white bg-linear-to-br from-[#7ba5d6] to-[#6b95c6] hover:from-[#6b95c6] hover:to-[#5b85b6] rounded-lg shadow-md hover:shadow-lg transition-all"
|
||||
>
|
||||
로그인
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthButton;
|
||||
|
||||
52
nextjs/components/widgets/Footer.tsx
Normal file
52
nextjs/components/widgets/Footer.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import Link from 'next/link';
|
||||
import { Youtube } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import cafeIcon from '@/public/footer/cafe.webp';
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="bg-slate-900 border-t border-slate-800">
|
||||
<div className="mx-auto px-6 py-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* 하단 정보 */}
|
||||
<div className="space-y-2 text-sm text-slate-400">
|
||||
<p>
|
||||
담임목사 : 김경한 | 주소 : 인천광역시 서구 고산후로 95번길 32 명진프라자 3층 / 4층
|
||||
</p>
|
||||
<Link href="/login" className="text-slate-500">
|
||||
<p>
|
||||
COPYRIGHT © 2026 제자들교회 DISCIPLES CHURCH. All rights reserved.
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 아이콘들 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="https://www.youtube.com/@Disciples2015"
|
||||
className="text-slate-100 hover:text-white transition-colors cursor-pointer flex flex-col items-center gap-1"
|
||||
aria-label="YouTube"
|
||||
>
|
||||
<Youtube className="w-6 h-6" />
|
||||
<span className='text-xs'>유튜브</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="https://cafe.naver.com/discipling"
|
||||
className="text-slate-100 hover:text-white transition-colors cursor-pointer flex flex-col items-center gap-1"
|
||||
aria-label="Naver Cafe"
|
||||
>
|
||||
<Image
|
||||
src={cafeIcon}
|
||||
alt="Naver Cafe"
|
||||
width={24}
|
||||
height={24}
|
||||
placeholder="blur"
|
||||
/>
|
||||
<span className='text-xs'>네이버 카페</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
243
nextjs/components/widgets/Header.tsx
Normal file
243
nextjs/components/widgets/Header.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import iconBlack from "@/public/icon_black.webp";
|
||||
import iconWhite from "@/public/icon_white.webp";
|
||||
// import AuthButton from "@/components/widgets/AuthButton";
|
||||
import tabs from "@/const/tabs";
|
||||
|
||||
export default function Header() {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [hoveredTab, setHoveredTab] = useState<number | null>(null);
|
||||
const [expandedTabs, setExpandedTabs] = useState<Set<number>>(new Set());
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
// 로그인/회원가입 페이지인지 확인
|
||||
const isAuthPage = pathname === "/login" || pathname === "/signup";
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 0);
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
// 로그인/회원가입 페이지 또는 모바일 메뉴가 열렸을 때 항상 스크롤된 상태로 표시
|
||||
// hover는 CSS로만 처리 (데스크톱에서만 작동)
|
||||
const shouldShowScrolled = isAuthPage || isScrolled || isMenuOpen || hoveredTab !== null;
|
||||
|
||||
// 모바일 메뉴에서 탭 확장/축소 토글
|
||||
const toggleTab = (index: number) => {
|
||||
setExpandedTabs((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(index)) {
|
||||
newSet.delete(index);
|
||||
} else {
|
||||
newSet.add(index);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`group pr-4 fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||
shouldShowScrolled || isMenuOpen
|
||||
? "bg-white shadow-md"
|
||||
: "bg-transparent pc:hover:bg-white pc:hover:shadow-md"
|
||||
}`}
|
||||
>
|
||||
<nav className="max-w-[1400px] mx-auto">
|
||||
<div className="flex justify-between items-stretch h-[56px] pc:h-[70px] relative z-10">
|
||||
{/* 로고 */}
|
||||
<div className="shrink-0">
|
||||
<Link href="/" className="flex items-center gap-3 group h-full px-6">
|
||||
{/* 아이콘 - 모바일은 작게, 데스크톱은 크게 */}
|
||||
<div className="relative w-8 h-8 pc:w-10 pc:h-10">
|
||||
{/* 흰색 아이콘 */}
|
||||
<Image
|
||||
src={iconWhite}
|
||||
alt="제자들교회 로고"
|
||||
width={40}
|
||||
height={40}
|
||||
className={`absolute inset-0 w-full h-full transition-opacity duration-300 ${
|
||||
shouldShowScrolled ? "opacity-0" : "opacity-100 pc:group-hover:opacity-0"
|
||||
}`}
|
||||
placeholder="blur"
|
||||
priority
|
||||
/>
|
||||
{/* 검은색 아이콘 */}
|
||||
<Image
|
||||
src={iconBlack}
|
||||
alt="제자들교회 로고"
|
||||
width={40}
|
||||
height={40}
|
||||
className={`absolute inset-0 w-full h-full transition-opacity duration-300 ${
|
||||
shouldShowScrolled ? "opacity-100" : "opacity-0 pc:group-hover:opacity-100"
|
||||
}`}
|
||||
placeholder="blur"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className={shouldShowScrolled || isMenuOpen ? "text-black pc:hover:text-black" : "text-white pc:group-hover:text-black"}>
|
||||
<div className="text-xl pc:text-2xl font-bold tracking-wide">제자들교회</div>
|
||||
{/* 데스크톱: 영어 이름 표시 */}
|
||||
<div className="hidden pc:block text-xs opacity-90">DISCIPLES CHURCH</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 데스크톱 네비게이션 */}
|
||||
<div className="hidden pc:flex">
|
||||
{tabs.map((tab, index) => (
|
||||
<div
|
||||
key={tab.label}
|
||||
className="relative flex items-stretch"
|
||||
onMouseEnter={() => setHoveredTab(index)}
|
||||
onMouseLeave={() => setHoveredTab(null)}
|
||||
>
|
||||
<Link
|
||||
href={tab.href || tab.submenu[0]?.href || "#"}
|
||||
className={`${shouldShowScrolled ? "text-black hover:text-black" : "text-white/90 pc:group-hover:text-black/90"} font-medium transition-colors flex items-center px-6`}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
|
||||
{/* 말풍선 스타일 드롭다운 */}
|
||||
{tab.submenu.length > 1 && (
|
||||
<div
|
||||
className={`absolute top-full left-1/2 -translate-x-1/2 pt-4 transition-all duration-300 ease-in-out ${
|
||||
hoveredTab === index
|
||||
? "opacity-100 translate-y-0 pointer-events-auto"
|
||||
: "opacity-0 -translate-y-2 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
{/* 말풍선 본체 */}
|
||||
<div className="bg-gray-500 text-white rounded-3xl shadow-xl overflow-visible min-w-[180px] relative">
|
||||
{/* 말풍선 꼬리 (위쪽 삼각형) - 박스 안쪽 최상단에 위치 */}
|
||||
<div className="absolute left-1/2 -translate-x-1/2 -top-3 w-0 h-0 border-l-14 border-l-transparent border-r-14 border-r-transparent border-b-14 border-b-gray-500"/>
|
||||
|
||||
{tab.submenu.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="block px-6 py-3 hover:bg-white/10 transition-colors text-center border-b border-gray-600 last:border-b-0"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* <AuthButton shouldShowScrolled={shouldShowScrolled} /> */}
|
||||
</div>
|
||||
|
||||
{/* 햄버거 메뉴 */}
|
||||
<div className="pc:hidden flex items-center">
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
className={`${shouldShowScrolled || isMenuOpen ? "text-black" : "text-white pc:group-hover:text-black"} flex items-center px-6 transition-colors duration-300`}
|
||||
>
|
||||
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d={isMenuOpen ? "M6 18L18 6M6 6l12 12" : "M4 6h16M4 12h16M4 18h16"}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모바일 메뉴 */}
|
||||
{isMenuOpen && (
|
||||
<div className="pc:hidden fixed inset-0 z-30">
|
||||
{/* 배경 오버레이 */}
|
||||
<div
|
||||
className="absolute inset-0 transition-opacity duration-300"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
/>
|
||||
|
||||
{/* 모달 메뉴 */}
|
||||
<div className="absolute top-[56px] left-0 right-0 border-b border-gray-300 bg-white max-h-[calc(100vh-56px)] overflow-y-auto pt-4 px-6 pb-6 transition-all z-30 animate-fade-in-fast">
|
||||
<div className="space-y-1">
|
||||
{tabs.map((tab, index) => {
|
||||
const isExpanded = expandedTabs.has(index);
|
||||
const firstSubmenuHref = tab.submenu[0]?.href || "#";
|
||||
|
||||
const handleMainTabClick = () => {
|
||||
if (tab.submenu.length > 1) {
|
||||
// 서브메뉴가 있는 경우 토글
|
||||
toggleTab(index);
|
||||
} else {
|
||||
// 서브메뉴가 없는 경우 바로 이동
|
||||
const targetHref = tab.href || firstSubmenuHref;
|
||||
if (targetHref !== "#") {
|
||||
router.push(targetHref);
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={tab.label} className="border-b border-gray-100 last:border-b-0">
|
||||
{/* 메인 탭 */}
|
||||
<div
|
||||
className="w-full flex justify-between items-center font-semibold py-3 text-lg cursor-pointer"
|
||||
onClick={handleMainTabClick}
|
||||
>
|
||||
<span className="flex-1 text-left py-2">
|
||||
{tab.label}
|
||||
</span>
|
||||
{tab.submenu.length > 1 && (
|
||||
<span className="ml-2 p-2">
|
||||
<ChevronDown
|
||||
className={`w-5 h-5 transition-transform duration-200 ${
|
||||
isExpanded ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 서브 탭들 */}
|
||||
{isExpanded && (
|
||||
<div className="ml-4 space-y-1 pb-2 animate-fade-in-fast">
|
||||
{tab.submenu.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="block py-3 text-base text-gray-700 hover:bg-gray-50 rounded-lg px-3"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* <div className="mt-4 text-center pb-6">
|
||||
<AuthButton shouldShowScrolled={true} onLinkClick={() => setIsMenuOpen(false)} />
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
76
nextjs/components/widgets/SubNavbar.tsx
Normal file
76
nextjs/components/widgets/SubNavbar.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { getTabInfo } from "@/lib/tabs";
|
||||
|
||||
const SubNavbar: React.FC = () => {
|
||||
const pathname = usePathname();
|
||||
|
||||
const tabInfo = getTabInfo(pathname);
|
||||
|
||||
if (!tabInfo) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 이미지 안 (Navbar 제외) */}
|
||||
<div className="relative w-full h-[200px] smalltablet:h-[300px] pc:h-[500px]">
|
||||
{/* 백그라운드 이미지 */}
|
||||
{tabInfo.image && (
|
||||
<Image
|
||||
src={tabInfo.image}
|
||||
alt="Banner Image"
|
||||
fill
|
||||
style={{ objectFit: "cover" }}
|
||||
// placeholder="blur"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 검은색 오버레이 */}
|
||||
<div className="absolute inset-0 bg-black opacity-50" />
|
||||
|
||||
{/* 이미지 한가운데 제목 */}
|
||||
<div className="font-bold flex items-center justify-center absolute inset-0 text-white text-4xl smalltablet:text-6xl pc:text-7xl">
|
||||
{tabInfo.title}
|
||||
</div>
|
||||
|
||||
{/* 이미지 안쪽 중 가장 아래에 있는 탭 바 */}
|
||||
<div className="backdrop-blur-sm absolute bottom-0 left-0 right-0 items-center justify-center hidden smalltablet:flex">
|
||||
{tabInfo.tab.submenu.map((item, subIndex) => (
|
||||
<Link key={subIndex} href={item.href}>
|
||||
<div
|
||||
className={`px-10 pc:px-20 py-4 text-base pc:text-xl font-semibold transition-all duration-300
|
||||
${item.href === pathname ? "text-gray-700 bg-white" : "text-white hover:bg-gray-50 hover:bg-opacity-10 hover:text-gray-700"}`}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 이미지 밖 */}
|
||||
<div className="font-bold text-center px-4 smalltablet:px-8 pc:px-12 mt-6 smalltablet:mt-10 pc:mt-12">
|
||||
{/* 영어 제목 */}
|
||||
<div className="text-blue-500 text-sm smalltablet:text-base pc:text-lg">
|
||||
{tabInfo.subtitleEnglish}
|
||||
</div>
|
||||
{/* 한글 제목 */}
|
||||
<div className="text-4xl smalltablet:text-4xl pc:text-6xl">
|
||||
{tabInfo.subtitle}
|
||||
</div>
|
||||
{/* 설명 */}
|
||||
{tabInfo.description && (
|
||||
<div className="text-gray-600 text-base smalltablet:text-lg pc:text-xl font-normal mt-4 smalltablet:mt-6">
|
||||
{tabInfo.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubNavbar;
|
||||
|
||||
54
nextjs/const/api.ts
Normal file
54
nextjs/const/api.ts
Normal 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
4
nextjs/const/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// 모든 상수를 한 곳에서 export
|
||||
export * from './api';
|
||||
export * from './s3';
|
||||
|
||||
23
nextjs/const/s3.ts
Normal file
23
nextjs/const/s3.ts
Normal 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
137
nextjs/const/tabs.ts
Normal 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
32
nextjs/eslint.config.mjs
Normal 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
3
nextjs/hooks/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { useAuth } from "./useAuth";
|
||||
export { useImageModal } from "./useImageModal";
|
||||
export { usePagination } from "./usePagination";
|
||||
65
nextjs/hooks/useAuth.ts
Normal file
65
nextjs/hooks/useAuth.ts
Normal 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;
|
||||
123
nextjs/hooks/useImageModal.ts
Normal file
123
nextjs/hooks/useImageModal.ts
Normal 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;
|
||||
97
nextjs/hooks/usePagination.ts
Normal file
97
nextjs/hooks/usePagination.ts
Normal 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
174
nextjs/lib/api/http.ts
Normal 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
2
nextjs/lib/api/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './http';
|
||||
|
||||
73
nextjs/lib/auth.ts
Normal file
73
nextjs/lib/auth.ts
Normal 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
15
nextjs/lib/prisma.ts
Normal 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
44
nextjs/lib/s3.ts
Normal 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;
|
||||
}
|
||||
87
nextjs/lib/services/announcement.ts
Normal file
87
nextjs/lib/services/announcement.ts
Normal 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));
|
||||
}
|
||||
|
||||
32
nextjs/lib/services/auth.ts
Normal file
32
nextjs/lib/services/auth.ts
Normal 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);
|
||||
}
|
||||
|
||||
63
nextjs/lib/services/disciple.ts
Normal file
63
nextjs/lib/services/disciple.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
93
nextjs/lib/services/file.ts
Normal file
93
nextjs/lib/services/file.ts
Normal 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");
|
||||
}
|
||||
|
||||
143
nextjs/lib/services/gallery.ts
Normal file
143
nextjs/lib/services/gallery.ts
Normal 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
Reference in New Issue
Block a user