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