From 1d17c835688e09aee4db81d04411f2b66b2f8c2b Mon Sep 17 00:00:00 2001 From: caadiq Date: Wed, 21 Jan 2026 11:41:39 +0900 Subject: [PATCH] =?UTF-8?q?X=20=EC=9D=BC=EC=A0=95=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API 응답 형식 변경 (category_id → category.id) - X 일정에 content, imageUrls, postUrl, profile 필드 추가 - 본문 내 URL 자동 하이퍼링크 추가 (react-linkify) - PC/모바일 ScheduleDetail에서 새 API 형식 사용 Co-Authored-By: Claude Opus 4.5 --- backend/src/routes/schedules/index.js | 61 +++++++----- frontend/package-lock.json | 85 ++++++++++++++++ frontend/package.json | 2 + .../pages/mobile/public/ScheduleDetail.jsx | 96 +++++++++++++------ .../src/pages/pc/public/ScheduleDetail.jsx | 13 +-- .../pc/public/schedule-sections/XSection.jsx | 96 +++++++++++++------ 6 files changed, 263 insertions(+), 90 deletions(-) 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 ( - {username && ( - @{username} - )} + @{username} @@ -357,32 +377,51 @@ function XSection({ schedule }) { {/* 본문 */}

- {decodeHtmlEntities(schedule.description || schedule.title)} + + {decodeHtmlEntities(schedule.content || schedule.title)} +

{/* 이미지 */} - {schedule.image_url && ( + {schedule.imageUrls?.length > 0 && (
- + {schedule.imageUrls.length === 1 ? ( + + ) : ( +
+ {schedule.imageUrls.slice(0, 4).map((url, i) => ( + + ))} +
+ )}
)} {/* 날짜/시간 */}
- {formatXDateTime(schedule.date, schedule.time)} + {formatDateTime(schedule.datetime)}
{/* X에서 보기 버튼 */}
{ - switch (schedule.category_id) { + switch (categoryId) { case CATEGORY_ID.YOUTUBE: return ; case CATEGORY_ID.X: @@ -897,9 +937,9 @@ function MobileScheduleDetail() {
- {schedule.category_name} + {schedule.category?.name}
diff --git a/frontend/src/pages/pc/public/ScheduleDetail.jsx b/frontend/src/pages/pc/public/ScheduleDetail.jsx index 0039563..d87348b 100644 --- a/frontend/src/pages/pc/public/ScheduleDetail.jsx +++ b/frontend/src/pages/pc/public/ScheduleDetail.jsx @@ -118,8 +118,9 @@ function ScheduleDetail() { } // 카테고리별 섹션 렌더링 + const categoryId = schedule.category?.id; const renderCategorySection = () => { - switch (schedule.category_id) { + switch (categoryId) { case CATEGORY_ID.YOUTUBE: return ; case CATEGORY_ID.X: @@ -131,9 +132,9 @@ function ScheduleDetail() { } }; - const isYoutube = schedule.category_id === CATEGORY_ID.YOUTUBE; - const isX = schedule.category_id === CATEGORY_ID.X; - const isConcert = schedule.category_id === CATEGORY_ID.CONCERT; + const isYoutube = categoryId === CATEGORY_ID.YOUTUBE; + const isX = categoryId === CATEGORY_ID.X; + const isConcert = categoryId === CATEGORY_ID.CONCERT; const hasCustomLayout = isYoutube || isX || isConcert; return ( @@ -151,9 +152,9 @@ function ScheduleDetail() { - {schedule.category_name} + {schedule.category?.name} diff --git a/frontend/src/pages/pc/public/schedule-sections/XSection.jsx b/frontend/src/pages/pc/public/schedule-sections/XSection.jsx index e1d6a9a..6d3fe33 100644 --- a/frontend/src/pages/pc/public/schedule-sections/XSection.jsx +++ b/frontend/src/pages/pc/public/schedule-sections/XSection.jsx @@ -1,30 +1,45 @@ -import { useQuery } from '@tanstack/react-query'; import { motion } from 'framer-motion'; -import { getXProfile } from '../../../../api/public/schedules'; -import { decodeHtmlEntities, formatXDateTime } from './utils'; +import Linkify from 'react-linkify'; +import { decodeHtmlEntities } from './utils'; -// X URL에서 username 추출 -const extractXUsername = (url) => { - if (!url) return null; - const match = url.match(/(?:twitter\.com|x\.com)\/([^/]+)/); - return match ? match[1] : null; +// datetime 포맷팅 (2026-01-18 19:00 → 오후 7:00 · 2026년 1월 18일) +const formatXDateTime = (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}일`; }; // 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} + + ); + return (
{/* X 스타일 카드 */} @@ -60,9 +75,7 @@ function XSection({ schedule }) {
- {username && ( - @{username} - )} + @{username}
@@ -70,32 +83,53 @@ function XSection({ schedule }) { {/* 본문 */}

- {decodeHtmlEntities(schedule.description || schedule.title)} + + {decodeHtmlEntities(schedule.content || schedule.title)} +

{/* 이미지 */} - {schedule.image_url && ( + {schedule.imageUrls?.length > 0 && (
- + {schedule.imageUrls.length === 1 ? ( + + ) : ( +
+ {schedule.imageUrls.slice(0, 4).map((url, i) => ( + + ))} +
+ )}
)} {/* 날짜/시간 */}
- {formatXDateTime(schedule.date, schedule.time)} + {formatXDateTime(schedule.datetime)}
{/* X에서 보기 버튼 */}