diff --git a/.gitignore b/.gitignore index dc53f0a..54d5505 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,10 @@ Thumbs.db *.swp *.swo +# Env (frontend JS 키 등 — 빌드에 포함되더라도 소스 트리에서는 제외) +frontend/.env +frontend/.env.local + # Build dist/ build/ diff --git a/backend/src/routes/admin/events.js b/backend/src/routes/admin/events.js index 6eb5934..1061c24 100644 --- a/backend/src/routes/admin/events.js +++ b/backend/src/routes/admin/events.js @@ -319,7 +319,7 @@ export default async function eventsRoutes(fastify) { [finalIds.length > 0 ? JSON.stringify(finalIds) : null, id] ); - await addOrUpdateSchedule(meilisearch, db, parseInt(id)); + await syncScheduleById(meilisearch, db, parseInt(id)); logActivity(db, { actor: 'admin', action: 'update', category: 'schedule', diff --git a/docs/api.md b/docs/api.md index 78f9efc..89e2253 100644 --- a/docs/api.md +++ b/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 diff --git a/docs/architecture.md b/docs/architecture.md index 19827ad..71410d6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -326,6 +326,10 @@ fromis_9/ - `concert_setlists` - 콘서트 셋리스트 - `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_x` - X 봇 설정 (username, 프로필, 동기화 간격, 텍스트 필터, 리트윗 포함, YouTube 추출) diff --git a/docs/development.md b/docs/development.md index 6c2b6e9..59765ed 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 봇은 `/docker/nitter/`의 Nitter 인스턴스(zedeus/nitter)를 스크래핑하여 트윗을 수집합니다. 백엔드는 `NITTER_URL`(기본값 `http://nitter:8080`)로 접속합니다. diff --git a/frontend/src/components/common/KakaoMap.jsx b/frontend/src/components/common/KakaoMap.jsx new file mode 100644 index 0000000..ae2790a --- /dev/null +++ b/frontend/src/components/common/KakaoMap.jsx @@ -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: `
${name}
`, + }); + overlay.setMap(map); + } + }) + .catch((err) => { + console.error('KakaoMap 로드 실패:', err); + }); + + return () => { + cancelled = true; + }; + }, [lat, lng, name, level]); + + return
; +} + +export default KakaoMap; diff --git a/frontend/src/components/common/index.js b/frontend/src/components/common/index.js index e98d766..22972b0 100644 --- a/frontend/src/components/common/index.js +++ b/frontend/src/components/common/index.js @@ -6,6 +6,7 @@ export { default as ScrollToTop } from './ScrollToTop'; export { default as Lightbox } from './Lightbox'; export { default as MobileLightbox } from './MobileLightbox'; export { default as LightboxIndicator } from './LightboxIndicator'; +export { default as KakaoMap } from './KakaoMap'; export { default as AnimatedNumber } from './AnimatedNumber'; export { default as Fromis9Logo } from './Fromis9Logo'; export { default as DebutCelebrationDialog } from './DebutCelebrationDialog'; diff --git a/frontend/src/components/pc/admin/schedule/ScheduleItem.jsx b/frontend/src/components/pc/admin/schedule/ScheduleItem.jsx index 920ca7f..149aeed 100644 --- a/frontend/src/components/pc/admin/schedule/ScheduleItem.jsx +++ b/frontend/src/components/pc/admin/schedule/ScheduleItem.jsx @@ -30,6 +30,8 @@ export const getEditPath = (scheduleId, categoryName, schedule) => { return `/admin/schedule/${scheduleId}/edit`; case '예능': return `/admin/schedule/${scheduleId}/edit/variety`; + case '행사': + return `/admin/schedule/${scheduleId}/edit/event`; default: return `/admin/schedule/${scheduleId}/edit`; } diff --git a/frontend/src/pages/mobile/schedule/ScheduleDetail.jsx b/frontend/src/pages/mobile/schedule/ScheduleDetail.jsx index 7b04268..255aa3a 100644 --- a/frontend/src/pages/mobile/schedule/ScheduleDetail.jsx +++ b/frontend/src/pages/mobile/schedule/ScheduleDetail.jsx @@ -2,8 +2,9 @@ import { useParams, Link } from 'react-router-dom'; import { useQuery, keepPreviousData } from '@tanstack/react-query'; import { useEffect, useState, useRef } from 'react'; 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 { KakaoMap } from '@/components/common'; import { decodeHtmlEntities, formatFullDate, formatTime, formatXDateTimeWithTime } from '@/utils'; 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 ( +
+ {/* 포스터 */} + {posters.length > 0 ? ( +
+
+ {schedule.title} +
+ {posters.length > 1 && ( +
+ {posters.slice(1).map((p) => ( + + + + ))} +
+ )} +
+ ) : ( +
+ +
+ )} + + {/* 정보 카드 */} +
+
+ {schedule.schoolName && ( + + + {schedule.schoolName} + + )} + + {formatFullDate(schedule.date)} + {schedule.time && ` · ${formatTime(schedule.time)}`} + +
+ +

+ {decodeHtmlEntities(schedule.title)} +

+ + {members.length > 0 && ( +
+ {isFullGroup ? ( + + 프로미스나인 + + ) : ( + members.map((member) => ( + + {member.name} + + )) + )} +
+ )} + + {venue && ( +
+
+ +
+

{venue.name}

+ {venue.address && ( +

{venue.address}

+ )} + {kakaoMapUrl && ( + + 카카오맵에서 보기 + + + )} +
+
+ {venue.lat && venue.lng && ( + + )} +
+ )} + + {postUrls.length > 0 && ( +
+

+ + 관련 링크 +

+
    + {postUrls.map((url, idx) => ( +
  • + · + + {url} + +
  • + ))} +
+
+ )} +
+
+ ); +} + /** * Mobile 예능 섹션 */ @@ -733,6 +884,8 @@ function MobileScheduleDetail() { return ; case '예능': return ; + case '행사': + return ; default: return ; } diff --git a/frontend/src/pages/pc/admin/schedules/edit/EventEditForm.jsx b/frontend/src/pages/pc/admin/schedules/edit/EventEditForm.jsx new file mode 100644 index 0000000..046bad4 --- /dev/null +++ b/frontend/src/pages/pc/admin/schedules/edit/EventEditForm.jsx @@ -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 ( + +
+ +
+
+ ); + } + + return ( + + setToast(null)} /> + + {/* 기본 정보 */} +
+

기본 정보

+
+
+ +
+ {SUBTYPES.map((opt) => ( + + ))} +
+
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+
+ + +
+
+ + +
+
+ +
+ + {venue ? ( +
+ +
+

{venue.name}

+ {venue.address && ( +

{venue.address}

+ )} +
+ + +
+ ) : ( + + )} +
+
+
+ + {/* 출연 멤버 */} +
+

+ + 출연 멤버 +

+
+ + {members.map((m) => ( + + ))} +
+
+ + {/* 포스터 */} +
+

+ + 포스터 (선택, 여러 장 가능) +

+
+ {existingPosters.map((p) => ( +
+ {`poster + +
+ ))} + {newPosterFiles.map((item, idx) => ( +
+ {`new + +
+ ))} + +
+
+ + {/* URL */} +
+

+ + 관련 URL (선택, 여러 개 가능) +

+
+ 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" + /> + +
+ {postUrls.length > 0 && ( +
    + {postUrls.map((url, idx) => ( +
  • + + {url} + + +
  • + ))} +
+ )} +
+ + {/* 버튼 */} +
+ + +
+
+ + setVenueDialogOpen(false)} + onSelect={(place) => setVenue(place)} + /> +
+ ); +} + +export default EventEditForm; diff --git a/frontend/src/pages/pc/public/schedule/ScheduleDetail.jsx b/frontend/src/pages/pc/public/schedule/ScheduleDetail.jsx index f779c34..b8b7e40 100644 --- a/frontend/src/pages/pc/public/schedule/ScheduleDetail.jsx +++ b/frontend/src/pages/pc/public/schedule/ScheduleDetail.jsx @@ -5,7 +5,7 @@ import { Calendar, ChevronRight } from 'lucide-react'; 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'; /** @@ -155,6 +155,8 @@ function PCScheduleDetail() { return ; case '예능': return ; + case '행사': + return ; default: return ; } @@ -163,11 +165,12 @@ function PCScheduleDetail() { const isYoutube = categoryName === '유튜브'; const isX = categoryName === 'X'; const isVariety = categoryName === '예능'; - const hasCustomLayout = isYoutube || isX || isVariety; + const isEvent = categoryName === '행사'; + const hasCustomLayout = isYoutube || isX || isVariety || isEvent; return (
-
+
{/* 브레드크럼 네비게이션 */} p.originalUrl || p.mediumUrl); + + const openLightbox = (index) => setLightbox({ open: true, index }); + + return ( +
+ {/* 왼쪽: 포스터 슬라이드 */} +
+ {posters.length > 0 ? ( +
+ 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) => ( + + + + ))} + + + {posters.length > 1 && ( + <> + + +
+ {posters.length}장 +
+ + )} +
+ ) : ( +
+ +
+ )} +
+ + {/* 오른쪽: 정보 */} +
+ {/* 학교 + 날짜 */} +
+ {schedule.schoolName && ( + + + {schedule.schoolName} + + )} + + + {formatFullDate(schedule.date)} + + {schedule.time && ( + + + {formatTime(schedule.time)} + + )} +
+ + {/* 제목 */} +

+ {decodeHtmlEntities(schedule.title)} +

+ + {/* 멤버 */} + {members.length > 0 && ( +
+ {isFullGroup ? ( + + 프로미스나인 + + ) : ( + members.map((member) => ( + + {member.name} + + )) + )} +
+ )} + + {/* 장소 */} + {venue && ( +
+
+ +
+

{venue.name}

+ {venue.address && ( +

{venue.address}

+ )} + {kakaoMapUrl && ( + + 카카오맵에서 보기 + + + )} +
+
+ {venue.lat && venue.lng && ( + + )} +
+ )} + + {/* URL 목록 */} + {postUrls.length > 0 && ( +
+

+ + 관련 링크 +

+
    + {postUrls.map((url, idx) => ( +
  • + · + + {url} + +
  • + ))} +
+
+ )} +
+ + {/* Lightbox */} + {posters.length > 0 && ( + setLightbox((prev) => ({ ...prev, open: false }))} + onIndexChange={(index) => setLightbox((prev) => ({ ...prev, index }))} + showCounter={posters.length > 1} + showDownload + /> + )} +
+ ); +} + +export default EventSection; diff --git a/frontend/src/pages/pc/public/schedule/sections/index.js b/frontend/src/pages/pc/public/schedule/sections/index.js index 6b0c760..bdbda57 100644 --- a/frontend/src/pages/pc/public/schedule/sections/index.js +++ b/frontend/src/pages/pc/public/schedule/sections/index.js @@ -1,5 +1,6 @@ export { default as YoutubeSection } from './YoutubeSection'; export { default as XSection } from './XSection'; export { default as VarietySection } from './VarietySection'; +export { default as EventSection } from './EventSection'; export { default as DefaultSection } from './DefaultSection'; export * from './utils'; diff --git a/frontend/src/routes/pc/admin/index.jsx b/frontend/src/routes/pc/admin/index.jsx index 0d9ac4e..8e61c50 100644 --- a/frontend/src/routes/pc/admin/index.jsx +++ b/frontend/src/routes/pc/admin/index.jsx @@ -34,6 +34,7 @@ import AdminScheduleFormPage from '@/pages/pc/admin/schedules/form'; import AdminYouTubeEditForm from '@/pages/pc/admin/schedules/edit/YouTubeEditForm'; import AdminConcertEditForm from '@/pages/pc/admin/schedules/edit/ConcertEditForm'; 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 AdminScheduleDict from '@/pages/pc/admin/schedules/ScheduleDict'; import AdminScheduleBots from '@/pages/pc/admin/schedules/ScheduleBots'; @@ -61,6 +62,7 @@ export default function AdminRoutes() { } /> } /> } /> + } /> } /> } /> } />