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>
This commit is contained in:
parent
d9836d2f5d
commit
7c20e9bb17
14 changed files with 1003 additions and 5 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -15,6 +15,10 @@ Thumbs.db
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
|
# Env (frontend JS 키 등 — 빌드에 포함되더라도 소스 트리에서는 제외)
|
||||||
|
frontend/.env
|
||||||
|
frontend/.env.local
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
|
|
||||||
|
|
@ -319,7 +319,7 @@ export default async function eventsRoutes(fastify) {
|
||||||
[finalIds.length > 0 ? JSON.stringify(finalIds) : null, id]
|
[finalIds.length > 0 ? JSON.stringify(finalIds) : null, id]
|
||||||
);
|
);
|
||||||
|
|
||||||
await addOrUpdateSchedule(meilisearch, db, parseInt(id));
|
await syncScheduleById(meilisearch, db, parseInt(id));
|
||||||
|
|
||||||
logActivity(db, {
|
logActivity(db, {
|
||||||
actor: 'admin', action: 'update', category: 'schedule',
|
actor: 'admin', action: 'update', category: 'schedule',
|
||||||
|
|
|
||||||
57
docs/api.md
57
docs/api.md
|
|
@ -532,6 +532,63 @@ X 일정 저장
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 관리자 - 행사 (인증 필요)
|
||||||
|
|
||||||
|
### GET /admin/events/:id
|
||||||
|
행사 상세 조회 (수정 폼용)
|
||||||
|
|
||||||
|
**응답:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 2565,
|
||||||
|
"title": "2026 UNION : PAINT THE UNION🎨",
|
||||||
|
"date": "2026-05-07",
|
||||||
|
"time": "21:30",
|
||||||
|
"subtype": "university",
|
||||||
|
"schoolName": "인천대학교",
|
||||||
|
"memberIds": [1, 2, 3, 4, 5],
|
||||||
|
"venue": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "인천대학교",
|
||||||
|
"address": "...",
|
||||||
|
"roadAddress": "...",
|
||||||
|
"lat": 37.xxx,
|
||||||
|
"lng": 126.xxx,
|
||||||
|
"kakao_id": null
|
||||||
|
},
|
||||||
|
"postUrls": ["https://www.instagram.com/p/..."],
|
||||||
|
"posters": [
|
||||||
|
{ "id": 10001, "originalUrl": "...", "mediumUrl": "...", "thumbUrl": "..." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /admin/events
|
||||||
|
행사 생성 (`multipart/form-data`)
|
||||||
|
|
||||||
|
**multipart 파트:**
|
||||||
|
- `payload` (JSON string): `{ subtype, title, schoolName, date, time, memberIds, venue, postUrls }`
|
||||||
|
- `subtype`: 현재 `'university'`만 지원
|
||||||
|
- `venue`: `{ name, address, roadAddress?, lat, lng, kakao_id? }` — kakao_id 기준으로 event_venues 테이블에 upsert
|
||||||
|
- `title`, `schoolName`, `date`, `venue` 필수
|
||||||
|
- `posters` (파일, 0개 이상): 포스터 이미지. 여러 장 가능
|
||||||
|
|
||||||
|
**응답:** `{ "id": 2565 }`
|
||||||
|
|
||||||
|
### PUT /admin/events/:id
|
||||||
|
행사 수정 (`multipart/form-data`)
|
||||||
|
|
||||||
|
**multipart 파트:**
|
||||||
|
- `payload` (JSON string): 위 POST 필드 + `keepPosterIds: number[]` (유지할 기존 포스터 ID 순서대로)
|
||||||
|
- `posters` (파일, 0개 이상): 새로 추가할 포스터
|
||||||
|
|
||||||
|
서버는 `keepPosterIds` 다음에 새 파일 id들을 이어붙여 `poster_image_ids` 업데이트.
|
||||||
|
|
||||||
|
### DELETE /admin/events/:id
|
||||||
|
행사 삭제 (schedules CASCADE로 schedule_event도 정리)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 관리자 - 활동 로그 (인증 필요)
|
## 관리자 - 활동 로그 (인증 필요)
|
||||||
|
|
||||||
### GET /admin/logs
|
### GET /admin/logs
|
||||||
|
|
|
||||||
|
|
@ -326,6 +326,10 @@ fromis_9/
|
||||||
- `concert_setlists` - 콘서트 셋리스트
|
- `concert_setlists` - 콘서트 셋리스트
|
||||||
- `concert_setlist_members` - 셋리스트-멤버 연결
|
- `concert_setlist_members` - 셋리스트-멤버 연결
|
||||||
|
|
||||||
|
#### 행사
|
||||||
|
- `event_venues` - 행사 장소 정보 (카카오맵 기반, 콘서트와 분리)
|
||||||
|
- `schedule_event` - 행사 상세 (subtype, school_name, venue_id, post_urls JSON, poster_image_ids JSON)
|
||||||
|
|
||||||
#### 봇
|
#### 봇
|
||||||
- `bot_youtube` - YouTube 봇 설정 (채널 정보, 동기화 간격 또는 주간 지정 시간, 필터 등, video_id UNIQUE)
|
- `bot_youtube` - YouTube 봇 설정 (채널 정보, 동기화 간격 또는 주간 지정 시간, 필터 등, video_id UNIQUE)
|
||||||
- `bot_x` - X 봇 설정 (username, 프로필, 동기화 간격, 텍스트 필터, 리트윗 포함, YouTube 추출)
|
- `bot_x` - X 봇 설정 (username, 프로필, 동기화 간격, 텍스트 필터, 리트윗 포함, YouTube 추출)
|
||||||
|
|
|
||||||
|
|
@ -304,6 +304,32 @@ queryClient.invalidateQueries();
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 행사 (Event)
|
||||||
|
|
||||||
|
`schedule_categories`의 "행사" 카테고리(id=11)로 일반 일정과 분리된 상세 테이블(`schedule_event`)을 가짐. 세부 타입(`subtype`)으로 폼/UI를 분기.
|
||||||
|
|
||||||
|
### 세부 타입
|
||||||
|
| slug | label | 현재 사용 필드 |
|
||||||
|
|------|-------|---------------|
|
||||||
|
| `university` | 학교 축제 | `school_name`, venue(카카오맵), 멤버, 포스터 다중, URL 다중 |
|
||||||
|
|
||||||
|
추가 세부 타입을 도입할 때는 1) `frontend/src/pages/pc/admin/schedules/form/event/index.jsx` 의 `SUBTYPES` 상수에 추가, 2) 필요 시 `schedule_event` 컬럼 확장 (또는 `details JSON`), 3) `routes/admin/events.js`의 `VALID_SUBTYPES`, 4) 상세 페이지 섹션(`EventSection`, `MobileEventSection`)에 분기 추가.
|
||||||
|
|
||||||
|
### 장소 관리
|
||||||
|
- `event_venues` 테이블에 `name`/`address`/`road_address`/`lat`/`lng`/`kakao_id` 저장
|
||||||
|
- 카카오맵 검색은 기존 `/api/admin/kakao/places` 엔드포인트 재사용 (콘서트와 동일)
|
||||||
|
- `kakao_id` 기준 upsert — 같은 장소가 여러 행사에서 쓰여도 row는 1개
|
||||||
|
|
||||||
|
### 포스터 업로드 경로
|
||||||
|
S3: `event/{scheduleId}/poster/{original|medium_800|thumb_400}/{파일명}`
|
||||||
|
`services/image.js` 의 `uploadEventPoster(scheduleId, filename, buffer)` 사용.
|
||||||
|
|
||||||
|
### Meilisearch 검색 지원
|
||||||
|
- `source_name`에 `school_name`이 들어가 Meilisearch 검색 가능
|
||||||
|
- 부분 입력 대응: `resolveSchoolNames(db, query)` 가 `schedule_event` 테이블에서 LIKE로 부분 일치 학교명을 찾아 검색 쿼리를 확장 (예: "인천대" → "인천대학교" 쿼리 추가). 멤버 별명 확장과 동일한 패턴.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## X 봇 / Nitter
|
## X 봇 / Nitter
|
||||||
|
|
||||||
X 봇은 `/docker/nitter/`의 Nitter 인스턴스(zedeus/nitter)를 스크래핑하여 트윗을 수집합니다. 백엔드는 `NITTER_URL`(기본값 `http://nitter:8080`)로 접속합니다.
|
X 봇은 `/docker/nitter/`의 Nitter 인스턴스(zedeus/nitter)를 스크래핑하여 트윗을 수집합니다. 백엔드는 `NITTER_URL`(기본값 `http://nitter:8080`)로 접속합니다.
|
||||||
|
|
|
||||||
76
frontend/src/components/common/KakaoMap.jsx
Normal file
76
frontend/src/components/common/KakaoMap.jsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
const KAKAO_JS_KEY = import.meta.env.VITE_KAKAO_JS_KEY;
|
||||||
|
|
||||||
|
let kakaoLoadPromise = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kakao Maps JavaScript SDK 1회 로드 (애플리케이션 전체에서 공유)
|
||||||
|
*/
|
||||||
|
function loadKakaoSdk() {
|
||||||
|
if (typeof window === 'undefined') return Promise.reject(new Error('window unavailable'));
|
||||||
|
if (window.kakao?.maps) return Promise.resolve(window.kakao);
|
||||||
|
if (kakaoLoadPromise) return kakaoLoadPromise;
|
||||||
|
|
||||||
|
kakaoLoadPromise = new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = `//dapi.kakao.com/v2/maps/sdk.js?appkey=${KAKAO_JS_KEY}&autoload=false`;
|
||||||
|
script.async = true;
|
||||||
|
script.onload = () => {
|
||||||
|
if (!window.kakao?.maps) {
|
||||||
|
reject(new Error('Kakao SDK loaded but window.kakao.maps missing'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.kakao.maps.load(() => resolve(window.kakao));
|
||||||
|
};
|
||||||
|
script.onerror = () => reject(new Error('Failed to load Kakao Maps SDK'));
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
|
||||||
|
return kakaoLoadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kakao 지도 + 마커 렌더링
|
||||||
|
*
|
||||||
|
* @param {number} lat
|
||||||
|
* @param {number} lng
|
||||||
|
* @param {string} name - 마커에 표시할 이름 (선택)
|
||||||
|
* @param {string} className - 컨테이너 클래스
|
||||||
|
* @param {number} level - 지도 확대 레벨 (기본 3)
|
||||||
|
*/
|
||||||
|
function KakaoMap({ lat, lng, name, className, level = 3 }) {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lat || !lng || !containerRef.current) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
loadKakaoSdk()
|
||||||
|
.then((kakao) => {
|
||||||
|
if (cancelled || !containerRef.current) return;
|
||||||
|
const center = new kakao.maps.LatLng(lat, lng);
|
||||||
|
const map = new kakao.maps.Map(containerRef.current, { center, level });
|
||||||
|
const marker = new kakao.maps.Marker({ position: center, map });
|
||||||
|
if (name) {
|
||||||
|
const overlay = new kakao.maps.CustomOverlay({
|
||||||
|
position: center,
|
||||||
|
yAnchor: 2.2,
|
||||||
|
content: `<div style="padding:4px 10px;background:#fff;border:1px solid #e5e7eb;border-radius:9999px;font-size:12px;color:#374151;white-space:nowrap;box-shadow:0 1px 2px rgba(0,0,0,0.05);">${name}</div>`,
|
||||||
|
});
|
||||||
|
overlay.setMap(map);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('KakaoMap 로드 실패:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [lat, lng, name, level]);
|
||||||
|
|
||||||
|
return <div ref={containerRef} className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default KakaoMap;
|
||||||
|
|
@ -6,6 +6,7 @@ export { default as ScrollToTop } from './ScrollToTop';
|
||||||
export { default as Lightbox } from './Lightbox';
|
export { default as Lightbox } from './Lightbox';
|
||||||
export { default as MobileLightbox } from './MobileLightbox';
|
export { default as MobileLightbox } from './MobileLightbox';
|
||||||
export { default as LightboxIndicator } from './LightboxIndicator';
|
export { default as LightboxIndicator } from './LightboxIndicator';
|
||||||
|
export { default as KakaoMap } from './KakaoMap';
|
||||||
export { default as AnimatedNumber } from './AnimatedNumber';
|
export { default as AnimatedNumber } from './AnimatedNumber';
|
||||||
export { default as Fromis9Logo } from './Fromis9Logo';
|
export { default as Fromis9Logo } from './Fromis9Logo';
|
||||||
export { default as DebutCelebrationDialog } from './DebutCelebrationDialog';
|
export { default as DebutCelebrationDialog } from './DebutCelebrationDialog';
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ export const getEditPath = (scheduleId, categoryName, schedule) => {
|
||||||
return `/admin/schedule/${scheduleId}/edit`;
|
return `/admin/schedule/${scheduleId}/edit`;
|
||||||
case '예능':
|
case '예능':
|
||||||
return `/admin/schedule/${scheduleId}/edit/variety`;
|
return `/admin/schedule/${scheduleId}/edit/variety`;
|
||||||
|
case '행사':
|
||||||
|
return `/admin/schedule/${scheduleId}/edit/event`;
|
||||||
default:
|
default:
|
||||||
return `/admin/schedule/${scheduleId}/edit`;
|
return `/admin/schedule/${scheduleId}/edit`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@ import { useParams, Link } from 'react-router-dom';
|
||||||
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight, Tv, ExternalLink, Play } from 'lucide-react';
|
import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight, Tv, ExternalLink, Play, MapPin, GraduationCap } from 'lucide-react';
|
||||||
import { getSchedule } from '@/api';
|
import { getSchedule } from '@/api';
|
||||||
|
import { KakaoMap } from '@/components/common';
|
||||||
import { decodeHtmlEntities, formatFullDate, formatTime, formatXDateTimeWithTime } from '@/utils';
|
import { decodeHtmlEntities, formatFullDate, formatTime, formatXDateTimeWithTime } from '@/utils';
|
||||||
import Birthday from './Birthday';
|
import Birthday from './Birthday';
|
||||||
|
|
||||||
|
|
@ -475,6 +476,156 @@ function MobileXSection({ schedule }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mobile 행사 섹션 (학교 행사 등)
|
||||||
|
*/
|
||||||
|
function MobileEventSection({ 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;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 포스터 */}
|
||||||
|
{posters.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="rounded-xl overflow-hidden shadow-sm bg-white">
|
||||||
|
<img
|
||||||
|
src={posters[0].mediumUrl || posters[0].originalUrl}
|
||||||
|
alt={schedule.title}
|
||||||
|
className="w-full h-auto object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{posters.length > 1 && (
|
||||||
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
|
{posters.slice(1).map((p) => (
|
||||||
|
<a
|
||||||
|
key={p.id}
|
||||||
|
href={p.originalUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block aspect-square rounded-md overflow-hidden border border-gray-100"
|
||||||
|
>
|
||||||
|
<img src={p.thumbUrl || p.mediumUrl} alt="" className="w-full h-full object-cover" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="w-full aspect-[3/4] rounded-xl flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: `${categoryColor}10` }}
|
||||||
|
>
|
||||||
|
<GraduationCap size={48} style={{ color: categoryColor }} strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 정보 카드 */}
|
||||||
|
<div className="bg-white rounded-xl shadow-sm p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
{schedule.schoolName && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-semibold rounded-md"
|
||||||
|
style={{ backgroundColor: `${categoryColor}25`, color: '#92400e' }}
|
||||||
|
>
|
||||||
|
<GraduationCap size={10} />
|
||||||
|
{schedule.schoolName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{formatFullDate(schedule.date)}
|
||||||
|
{schedule.time && ` · ${formatTime(schedule.time)}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="font-bold text-gray-900 text-base leading-snug mb-3">
|
||||||
|
{decodeHtmlEntities(schedule.title)}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{members.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||||
|
{isFullGroup ? (
|
||||||
|
<span className="px-2.5 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
|
||||||
|
프로미스나인
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
members.map((member) => (
|
||||||
|
<span key={member.id} className="px-2.5 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
|
||||||
|
{member.name}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{venue && (
|
||||||
|
<div className="pt-3 border-t border-gray-100 mb-3">
|
||||||
|
<div className="flex items-start gap-2 mb-2.5">
|
||||||
|
<MapPin size={14} className="text-gray-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{venue.name}</p>
|
||||||
|
{venue.address && (
|
||||||
|
<p className="text-xs text-gray-500">{venue.address}</p>
|
||||||
|
)}
|
||||||
|
{kakaoMapUrl && (
|
||||||
|
<a
|
||||||
|
href={kakaoMapUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 mt-1 text-xs text-primary"
|
||||||
|
>
|
||||||
|
카카오맵에서 보기
|
||||||
|
<ExternalLink size={10} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{venue.lat && venue.lng && (
|
||||||
|
<KakaoMap
|
||||||
|
lat={Number(venue.lat)}
|
||||||
|
lng={Number(venue.lng)}
|
||||||
|
name={venue.name}
|
||||||
|
className="w-full h-40 rounded-lg overflow-hidden border border-gray-100"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{postUrls.length > 0 && (
|
||||||
|
<div className="pt-3 border-t border-gray-100">
|
||||||
|
<p className="flex items-center gap-1 text-xs font-medium text-gray-700 mb-1.5">
|
||||||
|
<Link2 size={12} />
|
||||||
|
관련 링크
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{postUrls.map((url, idx) => (
|
||||||
|
<li key={idx} className="flex items-center gap-1.5 text-xs">
|
||||||
|
<span className="text-gray-300 select-none">·</span>
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary truncate"
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mobile 예능 섹션
|
* Mobile 예능 섹션
|
||||||
*/
|
*/
|
||||||
|
|
@ -733,6 +884,8 @@ function MobileScheduleDetail() {
|
||||||
return <MobileXSection schedule={schedule} />;
|
return <MobileXSection schedule={schedule} />;
|
||||||
case '예능':
|
case '예능':
|
||||||
return <MobileVarietySection schedule={schedule} />;
|
return <MobileVarietySection schedule={schedule} />;
|
||||||
|
case '행사':
|
||||||
|
return <MobileEventSection schedule={schedule} />;
|
||||||
default:
|
default:
|
||||||
return <MobileDefaultSection schedule={schedule} />;
|
return <MobileDefaultSection schedule={schedule} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
448
frontend/src/pages/pc/admin/schedules/edit/EventEditForm.jsx
Normal file
448
frontend/src/pages/pc/admin/schedules/edit/EventEditForm.jsx
Normal file
|
|
@ -0,0 +1,448 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Save, Loader2, GraduationCap, MapPin, Link2, Image as ImageIcon, Users, X,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import AdminLayout from "@/components/pc/admin/layout/Layout";
|
||||||
|
import Toast from "@/components/common/Toast";
|
||||||
|
import DatePicker from "@/components/pc/admin/common/DatePicker";
|
||||||
|
import TimePicker from "@/components/pc/admin/common/TimePicker";
|
||||||
|
import LocationSearchDialog from "@/components/pc/admin/schedule/LocationSearchDialog";
|
||||||
|
import { useToast } from "@/hooks/common";
|
||||||
|
import { useAdminAuth } from "@/hooks/pc/admin";
|
||||||
|
import { getMembers } from "@/api/public/members";
|
||||||
|
import { getEvent, updateEvent } from "@/api/admin/events";
|
||||||
|
|
||||||
|
const SUBTYPES = [
|
||||||
|
{ value: "university", label: "학교 축제" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function EventEditForm() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { toast, setToast } = useToast();
|
||||||
|
const { isAuthenticated } = useAdminAuth();
|
||||||
|
|
||||||
|
const { data: membersData = [] } = useQuery({
|
||||||
|
queryKey: ["members"],
|
||||||
|
queryFn: getMembers,
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
const members = membersData.filter((m) => !m.is_former);
|
||||||
|
|
||||||
|
const { data: eventData, isLoading } = useQuery({
|
||||||
|
queryKey: ["event-schedule", id],
|
||||||
|
queryFn: () => getEvent(id),
|
||||||
|
enabled: isAuthenticated && !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [subtype, setSubtype] = useState("university");
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [schoolName, setSchoolName] = useState("");
|
||||||
|
const [date, setDate] = useState("");
|
||||||
|
const [time, setTime] = useState("");
|
||||||
|
const [selectedMemberIds, setSelectedMemberIds] = useState([]);
|
||||||
|
const [venue, setVenue] = useState(null);
|
||||||
|
const [venueDialogOpen, setVenueDialogOpen] = useState(false);
|
||||||
|
const [existingPosters, setExistingPosters] = useState([]); // [{id, mediumUrl}]
|
||||||
|
const [keepPosterIds, setKeepPosterIds] = useState([]);
|
||||||
|
const [newPosterFiles, setNewPosterFiles] = useState([]); // [{file, preview}]
|
||||||
|
const [postUrls, setPostUrls] = useState([]);
|
||||||
|
const [urlInput, setUrlInput] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (eventData && !initialized) {
|
||||||
|
setSubtype(eventData.subtype || "university");
|
||||||
|
setTitle(eventData.title || "");
|
||||||
|
setSchoolName(eventData.schoolName || "");
|
||||||
|
setDate(eventData.date || "");
|
||||||
|
setTime(eventData.time || "");
|
||||||
|
setSelectedMemberIds(eventData.memberIds || []);
|
||||||
|
setVenue(eventData.venue || null);
|
||||||
|
setExistingPosters(eventData.posters || []);
|
||||||
|
setKeepPosterIds((eventData.posters || []).map((p) => p.id));
|
||||||
|
setPostUrls(eventData.postUrls || []);
|
||||||
|
setInitialized(true);
|
||||||
|
}
|
||||||
|
}, [eventData, initialized]);
|
||||||
|
|
||||||
|
const toggleMember = (memberId) => {
|
||||||
|
setSelectedMemberIds((prev) =>
|
||||||
|
prev.includes(memberId) ? prev.filter((i) => i !== memberId) : [...prev, memberId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const toggleAllMembers = () => {
|
||||||
|
setSelectedMemberIds(
|
||||||
|
selectedMemberIds.length === members.length ? [] : members.map((m) => m.id)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 포스터
|
||||||
|
const handlePosterChange = (e) => {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
const newItems = files.map((file) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => resolve({ file, preview: reader.result });
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Promise.all(newItems).then((items) => {
|
||||||
|
setNewPosterFiles((prev) => [...prev, ...items]);
|
||||||
|
});
|
||||||
|
e.target.value = "";
|
||||||
|
};
|
||||||
|
const removeExistingPoster = (posterId) => {
|
||||||
|
setKeepPosterIds((prev) => prev.filter((id) => id !== posterId));
|
||||||
|
setExistingPosters((prev) => prev.filter((p) => p.id !== posterId));
|
||||||
|
};
|
||||||
|
const removeNewPoster = (index) => {
|
||||||
|
setNewPosterFiles((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
// URL
|
||||||
|
const addUrl = () => {
|
||||||
|
const url = urlInput.trim();
|
||||||
|
if (!url) return;
|
||||||
|
if (!postUrls.includes(url)) setPostUrls([...postUrls, url]);
|
||||||
|
setUrlInput("");
|
||||||
|
};
|
||||||
|
const removeUrl = (index) => {
|
||||||
|
setPostUrls(postUrls.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!title.trim() || !schoolName.trim() || !date || !venue) {
|
||||||
|
setToast({ type: "error", message: "필수 항목을 입력해주세요." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
subtype,
|
||||||
|
title: title.trim(),
|
||||||
|
schoolName: schoolName.trim(),
|
||||||
|
date,
|
||||||
|
time: time || null,
|
||||||
|
memberIds: selectedMemberIds,
|
||||||
|
venue,
|
||||||
|
postUrls,
|
||||||
|
keepPosterIds,
|
||||||
|
};
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("payload", JSON.stringify(payload));
|
||||||
|
newPosterFiles.forEach((item) => {
|
||||||
|
formData.append("posters", item.file);
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateEvent(id, formData);
|
||||||
|
sessionStorage.setItem(
|
||||||
|
"scheduleToast",
|
||||||
|
JSON.stringify({ type: "success", message: "행사 일정이 수정되었습니다." })
|
||||||
|
);
|
||||||
|
navigate("/admin/schedule");
|
||||||
|
} catch (err) {
|
||||||
|
setToast({ type: "error", message: err.message || "수정에 실패했습니다." });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<AdminLayout>
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout>
|
||||||
|
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||||
|
<motion.form
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="space-y-6 max-w-4xl mx-auto px-6 py-8"
|
||||||
|
>
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 mb-6">기본 정보</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">세부 타입</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{SUBTYPES.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSubtype(opt.value)}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm border transition-colors ${
|
||||||
|
subtype === opt.value
|
||||||
|
? "border-primary bg-primary text-white"
|
||||||
|
: "border-gray-200 text-gray-600 hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">제목 *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5">
|
||||||
|
<GraduationCap size={14} />
|
||||||
|
학교명 *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={schoolName}
|
||||||
|
onChange={(e) => setSchoolName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-700 mb-1.5 block">날짜 *</label>
|
||||||
|
<DatePicker value={date} onChange={setDate} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-700 mb-1.5 block">시간 (선택)</label>
|
||||||
|
<TimePicker value={time} onChange={setTime} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5">
|
||||||
|
<MapPin size={14} />
|
||||||
|
장소 *
|
||||||
|
</label>
|
||||||
|
{venue ? (
|
||||||
|
<div className="flex items-start gap-3 p-3 border border-gray-200 rounded-lg">
|
||||||
|
<MapPin size={16} className="text-primary mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-gray-900">{venue.name}</p>
|
||||||
|
{venue.address && (
|
||||||
|
<p className="text-sm text-gray-500 truncate">{venue.address}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVenueDialogOpen(true)}
|
||||||
|
className="text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
변경
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVenue(null)}
|
||||||
|
className="text-gray-400 hover:text-red-500"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVenueDialogOpen(true)}
|
||||||
|
className="w-full px-3 py-2.5 border border-dashed border-gray-300 rounded-lg text-sm text-gray-500 hover:border-primary hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
장소 검색
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 출연 멤버 */}
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||||
|
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-900 mb-6">
|
||||||
|
<Users size={18} />
|
||||||
|
출연 멤버
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleAllMembers}
|
||||||
|
className={`px-4 py-1.5 rounded-full border text-sm transition-colors ${
|
||||||
|
selectedMemberIds.length === members.length
|
||||||
|
? "border-primary bg-primary text-white"
|
||||||
|
: "border-gray-200 text-gray-500 hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selectedMemberIds.length === members.length ? "전체 해제" : "전체 선택"}
|
||||||
|
</button>
|
||||||
|
{members.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleMember(m.id)}
|
||||||
|
className={`flex items-center gap-2 pr-3.5 pl-1.5 py-1.5 rounded-full border transition-colors ${
|
||||||
|
selectedMemberIds.includes(m.id) ? "border-primary" : "border-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="w-9 h-9 rounded-full overflow-hidden bg-gray-200 flex-shrink-0">
|
||||||
|
{m.image_url ? (
|
||||||
|
<img src={m.image_url} alt={m.name} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-gray-300" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-700">{m.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 포스터 */}
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||||
|
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-900 mb-4">
|
||||||
|
<ImageIcon size={18} />
|
||||||
|
포스터 <span className="text-sm font-normal text-gray-400">(선택, 여러 장 가능)</span>
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{existingPosters.map((p) => (
|
||||||
|
<div key={`e-${p.id}`} className="relative">
|
||||||
|
<img src={p.mediumUrl || p.thumbUrl} alt={`poster ${p.id}`} className="h-32 w-32 object-cover rounded-lg border border-gray-200" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeExistingPoster(p.id)}
|
||||||
|
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full text-xs flex items-center justify-center hover:bg-red-600"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{newPosterFiles.map((item, idx) => (
|
||||||
|
<div key={`n-${idx}`} className="relative">
|
||||||
|
<img src={item.preview} alt={`new poster ${idx}`} className="h-32 w-32 object-cover rounded-lg border border-gray-200" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeNewPoster(idx)}
|
||||||
|
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full text-xs flex items-center justify-center hover:bg-red-600"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<label className="flex items-center justify-center h-32 w-32 border-2 border-dashed border-gray-200 rounded-lg cursor-pointer hover:border-primary/50 hover:bg-primary/5 transition-colors">
|
||||||
|
<div className="text-center">
|
||||||
|
<ImageIcon size={20} className="mx-auto text-gray-400 mb-1" />
|
||||||
|
<span className="text-xs text-gray-400">추가</span>
|
||||||
|
</div>
|
||||||
|
<input type="file" accept="image/*" multiple className="hidden" onChange={handlePosterChange} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* URL */}
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||||
|
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-900 mb-4">
|
||||||
|
<Link2 size={18} />
|
||||||
|
관련 URL <span className="text-sm font-normal text-gray-400">(선택, 여러 개 가능)</span>
|
||||||
|
</h2>
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={urlInput}
|
||||||
|
onChange={(e) => setUrlInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
addUrl();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="https://..."
|
||||||
|
className="flex-1 px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addUrl}
|
||||||
|
className="px-4 py-2.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{postUrls.length > 0 && (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{postUrls.map((url, idx) => (
|
||||||
|
<li key={idx} className="flex items-center justify-between gap-2 p-2 bg-gray-50 rounded-lg">
|
||||||
|
<a href={url} target="_blank" rel="noreferrer" className="flex-1 truncate text-sm text-gray-600 hover:underline">
|
||||||
|
{url}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeUrl(idx)}
|
||||||
|
className="text-gray-400 hover:text-red-500"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<div className="flex items-center justify-end gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate("/admin/schedule")}
|
||||||
|
className="px-6 py-2.5 text-gray-600 hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-2 px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
수정 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save size={18} />
|
||||||
|
수정
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.form>
|
||||||
|
|
||||||
|
<LocationSearchDialog
|
||||||
|
isOpen={venueDialogOpen}
|
||||||
|
onClose={() => setVenueDialogOpen(false)}
|
||||||
|
onSelect={(place) => setVenue(place)}
|
||||||
|
/>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EventEditForm;
|
||||||
|
|
@ -5,7 +5,7 @@ import { Calendar, ChevronRight } from 'lucide-react';
|
||||||
import { getSchedule } from '@/api';
|
import { getSchedule } from '@/api';
|
||||||
|
|
||||||
// 섹션 컴포넌트들
|
// 섹션 컴포넌트들
|
||||||
import { YoutubeSection, XSection, VarietySection, DefaultSection, decodeHtmlEntities } from './sections';
|
import { YoutubeSection, XSection, VarietySection, EventSection, DefaultSection, decodeHtmlEntities } from './sections';
|
||||||
import Birthday from './Birthday';
|
import Birthday from './Birthday';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -155,6 +155,8 @@ function PCScheduleDetail() {
|
||||||
return <XSection schedule={schedule} />;
|
return <XSection schedule={schedule} />;
|
||||||
case '예능':
|
case '예능':
|
||||||
return <VarietySection schedule={schedule} />;
|
return <VarietySection schedule={schedule} />;
|
||||||
|
case '행사':
|
||||||
|
return <EventSection schedule={schedule} />;
|
||||||
default:
|
default:
|
||||||
return <DefaultSection schedule={schedule} />;
|
return <DefaultSection schedule={schedule} />;
|
||||||
}
|
}
|
||||||
|
|
@ -163,11 +165,12 @@ function PCScheduleDetail() {
|
||||||
const isYoutube = categoryName === '유튜브';
|
const isYoutube = categoryName === '유튜브';
|
||||||
const isX = categoryName === 'X';
|
const isX = categoryName === 'X';
|
||||||
const isVariety = categoryName === '예능';
|
const isVariety = categoryName === '예능';
|
||||||
const hasCustomLayout = isYoutube || isX || isVariety;
|
const isEvent = categoryName === '행사';
|
||||||
|
const hasCustomLayout = isYoutube || isX || isVariety || isEvent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[calc(100vh-64px)] bg-gray-50">
|
<div className="min-h-[calc(100vh-64px)] bg-gray-50">
|
||||||
<div className={`${isYoutube ? 'max-w-5xl' : 'max-w-3xl'} mx-auto px-6 py-8`}>
|
<div className={`${isYoutube || isEvent ? 'max-w-5xl' : 'max-w-3xl'} mx-auto px-6 py-8`}>
|
||||||
{/* 브레드크럼 네비게이션 */}
|
{/* 브레드크럼 네비게이션 */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
|
|
|
||||||
221
frontend/src/pages/pc/public/schedule/sections/EventSection.jsx
Normal file
221
frontend/src/pages/pc/public/schedule/sections/EventSection.jsx
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
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;
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export { default as YoutubeSection } from './YoutubeSection';
|
export { default as YoutubeSection } from './YoutubeSection';
|
||||||
export { default as XSection } from './XSection';
|
export { default as XSection } from './XSection';
|
||||||
export { default as VarietySection } from './VarietySection';
|
export { default as VarietySection } from './VarietySection';
|
||||||
|
export { default as EventSection } from './EventSection';
|
||||||
export { default as DefaultSection } from './DefaultSection';
|
export { default as DefaultSection } from './DefaultSection';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import AdminScheduleFormPage from '@/pages/pc/admin/schedules/form';
|
||||||
import AdminYouTubeEditForm from '@/pages/pc/admin/schedules/edit/YouTubeEditForm';
|
import AdminYouTubeEditForm from '@/pages/pc/admin/schedules/edit/YouTubeEditForm';
|
||||||
import AdminConcertEditForm from '@/pages/pc/admin/schedules/edit/ConcertEditForm';
|
import AdminConcertEditForm from '@/pages/pc/admin/schedules/edit/ConcertEditForm';
|
||||||
import AdminVarietyEditForm from '@/pages/pc/admin/schedules/edit/VarietyEditForm';
|
import AdminVarietyEditForm from '@/pages/pc/admin/schedules/edit/VarietyEditForm';
|
||||||
|
import AdminEventEditForm from '@/pages/pc/admin/schedules/edit/EventEditForm';
|
||||||
import AdminScheduleCategory from '@/pages/pc/admin/schedules/ScheduleCategory';
|
import AdminScheduleCategory from '@/pages/pc/admin/schedules/ScheduleCategory';
|
||||||
import AdminScheduleDict from '@/pages/pc/admin/schedules/ScheduleDict';
|
import AdminScheduleDict from '@/pages/pc/admin/schedules/ScheduleDict';
|
||||||
import AdminScheduleBots from '@/pages/pc/admin/schedules/ScheduleBots';
|
import AdminScheduleBots from '@/pages/pc/admin/schedules/ScheduleBots';
|
||||||
|
|
@ -61,6 +62,7 @@ export default function AdminRoutes() {
|
||||||
<Route path="/admin/schedule/:id/edit/youtube" element={<RequireAuth><AdminYouTubeEditForm /></RequireAuth>} />
|
<Route path="/admin/schedule/:id/edit/youtube" element={<RequireAuth><AdminYouTubeEditForm /></RequireAuth>} />
|
||||||
<Route path="/admin/schedule/concert/:seriesId/edit" element={<RequireAuth><AdminConcertEditForm /></RequireAuth>} />
|
<Route path="/admin/schedule/concert/:seriesId/edit" element={<RequireAuth><AdminConcertEditForm /></RequireAuth>} />
|
||||||
<Route path="/admin/schedule/:id/edit/variety" element={<RequireAuth><AdminVarietyEditForm /></RequireAuth>} />
|
<Route path="/admin/schedule/:id/edit/variety" element={<RequireAuth><AdminVarietyEditForm /></RequireAuth>} />
|
||||||
|
<Route path="/admin/schedule/:id/edit/event" element={<RequireAuth><AdminEventEditForm /></RequireAuth>} />
|
||||||
<Route path="/admin/schedule/categories" element={<RequireAuth><AdminScheduleCategory /></RequireAuth>} />
|
<Route path="/admin/schedule/categories" element={<RequireAuth><AdminScheduleCategory /></RequireAuth>} />
|
||||||
<Route path="/admin/schedule/dict" element={<RequireAuth><AdminScheduleDict /></RequireAuth>} />
|
<Route path="/admin/schedule/dict" element={<RequireAuth><AdminScheduleDict /></RequireAuth>} />
|
||||||
<Route path="/admin/schedule/bots" element={<RequireAuth><AdminScheduleBots /></RequireAuth>} />
|
<Route path="/admin/schedule/bots" element={<RequireAuth><AdminScheduleBots /></RequireAuth>} />
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue