fromis_9/frontend/src/pages/pc/public/schedule/sections/EventSection.jsx
caadiq 7c20e9bb17 feat(schedule): 행사 수정 폼 + 공개 상세 페이지 + 지도
- 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>
2026-04-23 12:24:01 +09:00

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;