diff --git a/backend/src/routes/schedules/index.js b/backend/src/routes/schedules/index.js
index 58d0bf0..06122d2 100644
--- a/backend/src/routes/schedules/index.js
+++ b/backend/src/routes/schedules/index.js
@@ -87,7 +87,7 @@ export default async function schedulesRoutes(fastify) {
/**
* GET /api/schedules/:id
- * 일정 상세 조회
+ * 일정 상세 조회 (카테고리별 다른 형식 반환)
*/
fastify.get('/:id', {
schema: {
@@ -105,7 +105,9 @@ export default async function schedulesRoutes(fastify) {
sy.channel_name as youtube_channel,
sy.video_id as youtube_video_id,
sy.video_type as youtube_video_type,
- sx.post_id as x_post_id
+ sx.post_id as x_post_id,
+ sx.content as x_content,
+ sx.image_urls as x_image_urls
FROM schedules s
LEFT JOIN schedule_categories c ON s.category_id = c.id
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
@@ -128,43 +130,52 @@ export default async function schedulesRoutes(fastify) {
ORDER BY m.id
`, [id]);
+ // datetime 생성 (date + time)
+ const dateStr = s.date instanceof Date ? s.date.toISOString().split('T')[0] : s.date?.split('T')[0];
+ const timeStr = s.time ? s.time.slice(0, 5) : null;
+ const datetime = timeStr ? `${dateStr} ${timeStr}` : dateStr;
+
+ // 공통 필드
const result = {
id: s.id,
title: s.title,
- date: s.date,
- time: s.time,
+ datetime,
category: {
id: s.category_id,
name: s.category_name,
color: s.category_color,
},
- members: members,
- youtube: s.youtube_video_id ? {
- videoId: s.youtube_video_id,
- videoType: s.youtube_video_type,
- channelName: s.youtube_channel,
- } : null,
- x: s.x_post_id ? {
- postId: s.x_post_id,
- } : null,
- created_at: s.created_at,
- updated_at: s.updated_at,
+ members,
+ createdAt: s.created_at,
+ updatedAt: s.updated_at,
};
- // source 정보 추가 (YouTube: 2, X: 3)
+ // 카테고리별 추가 필드
if (s.category_id === 2 && s.youtube_video_id) {
- const videoUrl = s.youtube_video_type === 'shorts'
+ // YouTube
+ result.videoId = s.youtube_video_id;
+ result.videoType = s.youtube_video_type;
+ result.channelName = s.youtube_channel;
+ result.videoUrl = s.youtube_video_type === 'shorts'
? `https://www.youtube.com/shorts/${s.youtube_video_id}`
: `https://www.youtube.com/watch?v=${s.youtube_video_id}`;
- result.source = {
- name: s.youtube_channel || 'YouTube',
- url: videoUrl,
- };
} else if (s.category_id === 3 && s.x_post_id) {
- result.source = {
- name: '',
- url: `https://x.com/realfromis_9/status/${s.x_post_id}`,
- };
+ // X (Twitter)
+ const username = 'realfromis_9';
+ result.postId = s.x_post_id;
+ result.content = s.x_content || null;
+ result.imageUrls = s.x_image_urls ? JSON.parse(s.x_image_urls) : [];
+ result.postUrl = `https://x.com/${username}/status/${s.x_post_id}`;
+
+ // 프로필 정보 (Redis 캐시 → DB)
+ const profile = await fastify.xBot.getProfile(username);
+ if (profile) {
+ result.profile = {
+ username: profile.username,
+ displayName: profile.displayName,
+ avatarUrl: profile.avatarUrl,
+ };
+ }
}
return result;
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 56e43f8..33ef6c0 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,6 +8,7 @@
"name": "fromis9-frontend",
"version": "1.0.0",
"dependencies": {
+ "@babel/runtime": "^7.28.6",
"@tanstack/react-query": "^5.90.16",
"@tanstack/react-virtual": "^3.13.18",
"canvas-confetti": "^1.9.4",
@@ -22,6 +23,7 @@
"react-infinite-scroll-component": "^6.1.1",
"react-intersection-observer": "^10.0.0",
"react-ios-time-picker": "^0.2.2",
+ "react-linkify": "^1.0.0-alpha",
"react-photo-album": "^3.4.0",
"react-router-dom": "^6.22.3",
"react-window": "^2.2.3",
@@ -285,6 +287,15 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
+ "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -333,6 +344,25 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@emotion/is-prop-valid": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
+ "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==",
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "@emotion/memoize": "^0.9.0"
+ }
+ },
+ "node_modules/@emotion/memoize": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
+ "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -1283,6 +1313,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/node": {
+ "version": "25.0.9",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz",
+ "integrity": "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "undici-types": "~7.16.0"
+ }
+ },
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -1986,6 +2028,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/linkify-it": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz",
+ "integrity": "sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==",
+ "license": "MIT",
+ "dependencies": {
+ "uc.micro": "^1.0.1"
+ }
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -2518,6 +2569,16 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
+ "node_modules/react-linkify": {
+ "version": "1.0.0-alpha",
+ "resolved": "https://registry.npmjs.org/react-linkify/-/react-linkify-1.0.0-alpha.tgz",
+ "integrity": "sha512-7gcIUvJkAXXttt1fmBK9cwn+1jTa4hbKLGCZ9J1U6EOkyb2/+LKL1Z28d9rtDLMnpvImlNlLPdTPooorl5cpmg==",
+ "license": "MIT",
+ "dependencies": {
+ "linkify-it": "^2.0.3",
+ "tlds": "^1.199.0"
+ }
+ },
"node_modules/react-photo-album": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/react-photo-album/-/react-photo-album-3.4.0.tgz",
@@ -2930,6 +2991,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/tlds": {
+ "version": "1.261.0",
+ "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz",
+ "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==",
+ "license": "MIT",
+ "bin": {
+ "tlds": "bin.js"
+ }
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -2982,6 +3052,21 @@
"node": "*"
}
},
+ "node_modules/uc.micro": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz",
+ "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==",
+ "license": "MIT"
+ },
+ "node_modules/undici-types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index f662dcb..e891306 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
+ "@babel/runtime": "^7.28.6",
"@tanstack/react-query": "^5.90.16",
"@tanstack/react-virtual": "^3.13.18",
"canvas-confetti": "^1.9.4",
@@ -23,6 +24,7 @@
"react-infinite-scroll-component": "^6.1.1",
"react-intersection-observer": "^10.0.0",
"react-ios-time-picker": "^0.2.2",
+ "react-linkify": "^1.0.0-alpha",
"react-photo-album": "^3.4.0",
"react-router-dom": "^6.22.3",
"react-window": "^2.2.3",
diff --git a/frontend/src/pages/mobile/public/ScheduleDetail.jsx b/frontend/src/pages/mobile/public/ScheduleDetail.jsx
index 2bd64e5..ffe0507 100644
--- a/frontend/src/pages/mobile/public/ScheduleDetail.jsx
+++ b/frontend/src/pages/mobile/public/ScheduleDetail.jsx
@@ -3,7 +3,8 @@ import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { useEffect, useState, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Calendar, Clock, ChevronLeft, Check, Link2, MapPin, Navigation, ExternalLink } from 'lucide-react';
-import { getSchedule, getXProfile } from '../../../api/public/schedules';
+import Linkify from 'react-linkify';
+import { getSchedule } from '../../../api/public/schedules';
import '../../../mobile.css';
// 카카오맵 SDK 키
@@ -142,7 +143,7 @@ const decodeHtmlEntities = (text) => {
return textarea.value;
};
-// 유튜브 비디오 ID 추출
+// 유튜브 비디오 ID 추출 (YoutubeSection용)
const extractYoutubeVideoId = (url) => {
if (!url) return null;
const shortMatch = url.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/);
@@ -154,6 +155,7 @@ const extractYoutubeVideoId = (url) => {
return null;
};
+
// 날짜 포맷팅
const formatFullDate = (dateStr) => {
if (!dateStr) return '';
@@ -301,19 +303,39 @@ function YoutubeSection({ schedule }) {
// X(트위터) 섹션 컴포넌트
function XSection({ schedule }) {
- const username = extractXUsername(schedule.source?.url);
-
- // 프로필 정보 조회
- const { data: profile } = useQuery({
- queryKey: ['x-profile', username],
- queryFn: () => getXProfile(username),
- enabled: !!username,
- staleTime: 1000 * 60 * 60, // 1시간
- });
-
- const displayName = profile?.displayName || schedule.source?.name || username || 'Unknown';
+ const profile = schedule.profile;
+ const username = profile?.username || 'realfromis_9';
+ const displayName = profile?.displayName || username;
const avatarUrl = profile?.avatarUrl;
+ // 링크 데코레이터 (새 탭에서 열기)
+ const linkDecorator = (href, text, key) => (
+
+ {text}
+
+ );
+
+ // datetime 포맷팅
+ const formatDateTime = (datetime) => {
+ if (!datetime) return '';
+ const [datePart, timePart] = datetime.split(' ');
+ const [year, month, day] = datePart.split('-').map(Number);
+ let timeStr = '';
+ if (timePart) {
+ const [hour, minute] = timePart.split(':').map(Number);
+ const period = hour >= 12 ? '오후' : '오전';
+ const hour12 = hour > 12 ? hour - 12 : hour === 0 ? 12 : hour;
+ timeStr = `${period} ${hour12}:${String(minute).padStart(2, '0')} · `;
+ }
+ return `${timeStr}${year}년 ${month}월 ${day}일`;
+ };
+
return (
- {decodeHtmlEntities(schedule.description || schedule.title)}
+
- {decodeHtmlEntities(schedule.description || schedule.title)}
+