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 ? (
+
+
+

+
+ {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) => (
+
+

+
+
+ ))}
+ {newPosterFiles.map((item, idx) => (
+
+

+
+
+ ))}
+
+
+
+
+ {/* 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() {
} />
} />
} />
+ } />
} />
} />
} />