- Admin: EventEditForm 추가 (기존 포스터 유지 + 신규 추가 조합), ScheduleItem 편집 경로에 '행사' 분기 - PC 공개 상세: EventSection 추가 - 포스터 Swiper 슬라이드 + 호버 화살표, 클릭 시 Lightbox, 카카오맵 + 마커 + 장소명 오버레이, 관련 링크는 중간점+primary 색상, max-w-5xl 및 text-2xl로 크기 확대 - Mobile 공개 상세: MobileEventSection 추가 (포스터/장소/지도/링크) - KakaoMap 공용 컴포넌트 신규 (SDK 1회 로드 공유), VITE_KAKAO_JS_KEY 사용 - .gitignore: frontend/.env 제외 - routes/admin/events.js: PUT 핸들러의 addOrUpdateSchedule → syncScheduleById 정정 - 관련 문서(api/architecture/development) 업데이트 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
221 lines
8.3 KiB
JavaScript
221 lines
8.3 KiB
JavaScript
import { useState } from 'react';
|
|
import { Swiper, SwiperSlide } from 'swiper/react';
|
|
import { Navigation } from 'swiper/modules';
|
|
import {
|
|
Calendar, Clock, MapPin, Link2, GraduationCap, ExternalLink,
|
|
ChevronLeft, ChevronRight,
|
|
} from 'lucide-react';
|
|
import 'swiper/css';
|
|
import 'swiper/css/navigation';
|
|
import { Lightbox, KakaoMap } from '@/components/common';
|
|
import { decodeHtmlEntities, formatFullDate, formatTime } from './utils';
|
|
|
|
/**
|
|
* 행사 일정 섹션 컴포넌트 (학교 행사 등)
|
|
*/
|
|
function EventSection({ schedule }) {
|
|
const members = schedule.members || [];
|
|
const isFullGroup = members.length === 5;
|
|
const posters = schedule.posters || [];
|
|
const postUrls = schedule.postUrls || [];
|
|
const venue = schedule.venue || null;
|
|
const categoryColor = schedule.category?.color || '#facc15';
|
|
const kakaoMapUrl = venue && venue.lat && venue.lng
|
|
? `https://map.kakao.com/link/map/${encodeURIComponent(venue.name)},${venue.lat},${venue.lng}`
|
|
: null;
|
|
|
|
const [lightbox, setLightbox] = useState({ open: false, index: 0 });
|
|
const lightboxImages = posters.map((p) => p.originalUrl || p.mediumUrl);
|
|
|
|
const openLightbox = (index) => setLightbox({ open: true, index });
|
|
|
|
return (
|
|
<div className="flex gap-5 items-start">
|
|
{/* 왼쪽: 포스터 슬라이드 */}
|
|
<div className="flex-shrink-0 w-[420px]">
|
|
{posters.length > 0 ? (
|
|
<div className="relative group bg-white rounded-2xl overflow-hidden shadow-sm border border-gray-100">
|
|
<Swiper
|
|
modules={[Navigation]}
|
|
navigation={posters.length > 1 ? {
|
|
prevEl: '.event-poster-prev',
|
|
nextEl: '.event-poster-next',
|
|
} : false}
|
|
spaceBetween={0}
|
|
slidesPerView={1}
|
|
loop={posters.length > 1}
|
|
className="w-full"
|
|
>
|
|
{posters.map((p, idx) => (
|
|
<SwiperSlide key={p.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => openLightbox(idx)}
|
|
className="block w-full cursor-pointer"
|
|
>
|
|
<img
|
|
src={p.mediumUrl || p.originalUrl}
|
|
alt={`${schedule.title} 포스터 ${idx + 1}`}
|
|
className="w-full h-auto object-cover"
|
|
/>
|
|
</button>
|
|
</SwiperSlide>
|
|
))}
|
|
</Swiper>
|
|
|
|
{posters.length > 1 && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="event-poster-prev absolute left-3 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-white/80 hover:bg-white shadow-md flex items-center justify-center text-gray-700 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
aria-label="이전 포스터"
|
|
>
|
|
<ChevronLeft size={20} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="event-poster-next absolute right-3 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-white/80 hover:bg-white shadow-md flex items-center justify-center text-gray-700 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
aria-label="다음 포스터"
|
|
>
|
|
<ChevronRight size={20} />
|
|
</button>
|
|
<div className="absolute bottom-3 right-3 px-2 py-0.5 rounded-full bg-black/50 text-white text-xs font-medium">
|
|
{posters.length}장
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div
|
|
className="w-full aspect-[3/4] bg-white rounded-2xl flex items-center justify-center border border-gray-100"
|
|
style={{ backgroundColor: `${categoryColor}10` }}
|
|
>
|
|
<GraduationCap size={72} style={{ color: categoryColor }} strokeWidth={1.5} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 오른쪽: 정보 */}
|
|
<div className="flex-1 bg-white rounded-2xl shadow-sm border border-gray-100 p-8">
|
|
{/* 학교 + 날짜 */}
|
|
<div className="flex items-center gap-3 mb-4 flex-wrap">
|
|
{schedule.schoolName && (
|
|
<span
|
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-base font-semibold rounded-md"
|
|
style={{ backgroundColor: `${categoryColor}25`, color: '#92400e' }}
|
|
>
|
|
<GraduationCap size={15} />
|
|
{schedule.schoolName}
|
|
</span>
|
|
)}
|
|
<span className="flex items-center gap-1.5 text-base text-gray-500">
|
|
<Calendar size={15} />
|
|
{formatFullDate(schedule.date)}
|
|
</span>
|
|
{schedule.time && (
|
|
<span className="flex items-center gap-1.5 text-base text-gray-500">
|
|
<Clock size={15} />
|
|
{formatTime(schedule.time)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* 제목 */}
|
|
<h1 className="text-2xl font-bold text-gray-900 leading-snug mb-6">
|
|
{decodeHtmlEntities(schedule.title)}
|
|
</h1>
|
|
|
|
{/* 멤버 */}
|
|
{members.length > 0 && (
|
|
<div className="flex flex-wrap gap-2 mb-6">
|
|
{isFullGroup ? (
|
|
<span className="px-3.5 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">
|
|
프로미스나인
|
|
</span>
|
|
) : (
|
|
members.map((member) => (
|
|
<span key={member.id} className="px-3.5 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">
|
|
{member.name}
|
|
</span>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 장소 */}
|
|
{venue && (
|
|
<div className="pt-5 border-t border-gray-100 mb-5">
|
|
<div className="flex items-start gap-2.5 mb-3">
|
|
<MapPin size={18} className="text-gray-400 mt-0.5 flex-shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium text-base text-gray-900">{venue.name}</p>
|
|
{venue.address && (
|
|
<p className="text-sm text-gray-500 mt-0.5">{venue.address}</p>
|
|
)}
|
|
{kakaoMapUrl && (
|
|
<a
|
|
href={kakaoMapUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1 mt-2 text-sm text-primary hover:underline"
|
|
>
|
|
카카오맵에서 보기
|
|
<ExternalLink size={12} />
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{venue.lat && venue.lng && (
|
|
<KakaoMap
|
|
lat={Number(venue.lat)}
|
|
lng={Number(venue.lng)}
|
|
name={venue.name}
|
|
className="w-full h-52 rounded-xl overflow-hidden border border-gray-100"
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* URL 목록 */}
|
|
{postUrls.length > 0 && (
|
|
<div className="pt-5 border-t border-gray-100">
|
|
<p className="flex items-center gap-1.5 text-sm font-medium text-gray-700 mb-2">
|
|
<Link2 size={15} />
|
|
관련 링크
|
|
</p>
|
|
<ul className="space-y-1">
|
|
{postUrls.map((url, idx) => (
|
|
<li key={idx} className="flex items-center gap-2 text-sm">
|
|
<span className="text-gray-300 select-none">·</span>
|
|
<a
|
|
href={url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-primary hover:underline truncate"
|
|
>
|
|
{url}
|
|
</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Lightbox */}
|
|
{posters.length > 0 && (
|
|
<Lightbox
|
|
images={lightboxImages}
|
|
currentIndex={lightbox.index}
|
|
isOpen={lightbox.open}
|
|
onClose={() => setLightbox((prev) => ({ ...prev, open: false }))}
|
|
onIndexChange={(index) => setLightbox((prev) => ({ ...prev, index }))}
|
|
showCounter={posters.length > 1}
|
|
showDownload
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default EventSection;
|