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