X 일정 상세 페이지 개선

- API 응답 형식 변경 (category_id → category.id)
- X 일정에 content, imageUrls, postUrl, profile 필드 추가
- 본문 내 URL 자동 하이퍼링크 추가 (react-linkify)
- PC/모바일 ScheduleDetail에서 새 API 형식 사용

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-21 11:41:39 +09:00
parent 8bcd3e881d
commit 1d17c83568
6 changed files with 263 additions and 90 deletions

View file

@ -87,7 +87,7 @@ export default async function schedulesRoutes(fastify) {
/** /**
* GET /api/schedules/:id * GET /api/schedules/:id
* 일정 상세 조회 * 일정 상세 조회 (카테고리별 다른 형식 반환)
*/ */
fastify.get('/:id', { fastify.get('/:id', {
schema: { schema: {
@ -105,7 +105,9 @@ export default async function schedulesRoutes(fastify) {
sy.channel_name as youtube_channel, sy.channel_name as youtube_channel,
sy.video_id as youtube_video_id, sy.video_id as youtube_video_id,
sy.video_type as youtube_video_type, 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 FROM schedules s
LEFT JOIN schedule_categories c ON s.category_id = c.id LEFT JOIN schedule_categories c ON s.category_id = c.id
LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id
@ -128,44 +130,53 @@ export default async function schedulesRoutes(fastify) {
ORDER BY m.id ORDER BY m.id
`, [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 = { const result = {
id: s.id, id: s.id,
title: s.title, title: s.title,
date: s.date, datetime,
time: s.time,
category: { category: {
id: s.category_id, id: s.category_id,
name: s.category_name, name: s.category_name,
color: s.category_color, color: s.category_color,
}, },
members: members, members,
youtube: s.youtube_video_id ? { createdAt: s.created_at,
videoId: s.youtube_video_id, updatedAt: s.updated_at,
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,
}; };
// source 정보 추가 (YouTube: 2, X: 3) // 카테고리별 추가 필드
if (s.category_id === 2 && s.youtube_video_id) { 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/shorts/${s.youtube_video_id}`
: `https://www.youtube.com/watch?v=${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) { } else if (s.category_id === 3 && s.x_post_id) {
result.source = { // X (Twitter)
name: '', const username = 'realfromis_9';
url: `https://x.com/realfromis_9/status/${s.x_post_id}`, 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; return result;
}); });

View file

@ -8,6 +8,7 @@
"name": "fromis9-frontend", "name": "fromis9-frontend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.6",
"@tanstack/react-query": "^5.90.16", "@tanstack/react-query": "^5.90.16",
"@tanstack/react-virtual": "^3.13.18", "@tanstack/react-virtual": "^3.13.18",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
@ -22,6 +23,7 @@
"react-infinite-scroll-component": "^6.1.1", "react-infinite-scroll-component": "^6.1.1",
"react-intersection-observer": "^10.0.0", "react-intersection-observer": "^10.0.0",
"react-ios-time-picker": "^0.2.2", "react-ios-time-picker": "^0.2.2",
"react-linkify": "^1.0.0-alpha",
"react-photo-album": "^3.4.0", "react-photo-album": "^3.4.0",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"react-window": "^2.2.3", "react-window": "^2.2.3",
@ -285,6 +287,15 @@
"@babel/core": "^7.0.0-0" "@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": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@ -333,6 +344,25 @@
"node": ">=6.9.0" "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": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@ -1283,6 +1313,18 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@ -1986,6 +2028,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@ -2518,6 +2569,16 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT" "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": { "node_modules/react-photo-album": {
"version": "3.4.0", "version": "3.4.0",
"resolved": "https://registry.npmjs.org/react-photo-album/-/react-photo-album-3.4.0.tgz", "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" "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": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -2982,6 +3052,21 @@
"node": "*" "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": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",

View file

@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.6",
"@tanstack/react-query": "^5.90.16", "@tanstack/react-query": "^5.90.16",
"@tanstack/react-virtual": "^3.13.18", "@tanstack/react-virtual": "^3.13.18",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
@ -23,6 +24,7 @@
"react-infinite-scroll-component": "^6.1.1", "react-infinite-scroll-component": "^6.1.1",
"react-intersection-observer": "^10.0.0", "react-intersection-observer": "^10.0.0",
"react-ios-time-picker": "^0.2.2", "react-ios-time-picker": "^0.2.2",
"react-linkify": "^1.0.0-alpha",
"react-photo-album": "^3.4.0", "react-photo-album": "^3.4.0",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"react-window": "^2.2.3", "react-window": "^2.2.3",

View file

@ -3,7 +3,8 @@ 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, Check, Link2, MapPin, Navigation, ExternalLink } from 'lucide-react'; 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'; import '../../../mobile.css';
// SDK // SDK
@ -142,7 +143,7 @@ const decodeHtmlEntities = (text) => {
return textarea.value; return textarea.value;
}; };
// ID // ID (YoutubeSection)
const extractYoutubeVideoId = (url) => { const extractYoutubeVideoId = (url) => {
if (!url) return null; if (!url) return null;
const shortMatch = url.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/); const shortMatch = url.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/);
@ -154,6 +155,7 @@ const extractYoutubeVideoId = (url) => {
return null; return null;
}; };
// //
const formatFullDate = (dateStr) => { const formatFullDate = (dateStr) => {
if (!dateStr) return ''; if (!dateStr) return '';
@ -301,19 +303,39 @@ function YoutubeSection({ schedule }) {
// X() // X()
function XSection({ schedule }) { function XSection({ schedule }) {
const username = extractXUsername(schedule.source?.url); const profile = schedule.profile;
const username = profile?.username || 'realfromis_9';
// const displayName = profile?.displayName || username;
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 avatarUrl = profile?.avatarUrl; const avatarUrl = profile?.avatarUrl;
// ( )
const linkDecorator = (href, text, key) => (
<a
key={key}
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500"
>
{text}
</a>
);
// 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 ( return (
<motion.div <motion.div
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
@ -347,9 +369,7 @@ function XSection({ schedule }) {
<path d="M22.25 12c0-1.43-.88-2.67-2.19-3.34.46-1.39.2-2.9-.81-3.91s-2.52-1.27-3.91-.81c-.66-1.31-1.91-2.19-3.34-2.19s-2.67.88-3.34 2.19c-1.39-.46-2.9-.2-3.91.81s-1.27 2.52-.81 3.91C2.63 9.33 1.75 10.57 1.75 12s.88 2.67 2.19 3.34c-.46 1.39-.2 2.9.81 3.91s2.52 1.27 3.91.81c.66 1.31 1.91 2.19 3.34 2.19s2.67-.88 3.34-2.19c1.39.46 2.9.2 3.91-.81s1.27-2.52.81-3.91c1.31-.67 2.19-1.91 2.19-3.34zm-11.07 4.57l-3.84-3.84 1.27-1.27 2.57 2.57 5.39-5.39 1.27 1.27-6.66 6.66z"/> <path d="M22.25 12c0-1.43-.88-2.67-2.19-3.34.46-1.39.2-2.9-.81-3.91s-2.52-1.27-3.91-.81c-.66-1.31-1.91-2.19-3.34-2.19s-2.67.88-3.34 2.19c-1.39-.46-2.9-.2-3.91.81s-1.27 2.52-.81 3.91C2.63 9.33 1.75 10.57 1.75 12s.88 2.67 2.19 3.34c-.46 1.39-.2 2.9.81 3.91s2.52 1.27 3.91.81c.66 1.31 1.91 2.19 3.34 2.19s2.67-.88 3.34-2.19c1.39.46 2.9.2 3.91-.81s1.27-2.52.81-3.91c1.31-.67 2.19-1.91 2.19-3.34zm-11.07 4.57l-3.84-3.84 1.27-1.27 2.57 2.57 5.39-5.39 1.27 1.27-6.66 6.66z"/>
</svg> </svg>
</div> </div>
{username && (
<span className="text-xs text-gray-500">@{username}</span> <span className="text-xs text-gray-500">@{username}</span>
)}
</div> </div>
</div> </div>
</div> </div>
@ -357,32 +377,51 @@ function XSection({ schedule }) {
{/* 본문 */} {/* 본문 */}
<div className="p-4"> <div className="p-4">
<p className="text-gray-900 text-[15px] leading-relaxed whitespace-pre-wrap"> <p className="text-gray-900 text-[15px] leading-relaxed whitespace-pre-wrap">
{decodeHtmlEntities(schedule.description || schedule.title)} <Linkify componentDecorator={linkDecorator}>
{decodeHtmlEntities(schedule.content || schedule.title)}
</Linkify>
</p> </p>
</div> </div>
{/* 이미지 */} {/* 이미지 */}
{schedule.image_url && ( {schedule.imageUrls?.length > 0 && (
<div className="px-4 pb-3"> <div className="px-4 pb-3">
{schedule.imageUrls.length === 1 ? (
<img <img
src={schedule.image_url} src={schedule.imageUrls[0]}
alt="" alt=""
className="w-full rounded-xl border border-gray-100" className="w-full rounded-xl border border-gray-100"
/> />
) : (
<div className={`grid gap-1 rounded-xl overflow-hidden border border-gray-100 ${
schedule.imageUrls.length === 2 ? 'grid-cols-2' : 'grid-cols-2'
}`}>
{schedule.imageUrls.slice(0, 4).map((url, i) => (
<img
key={i}
src={url}
alt=""
className={`w-full object-cover ${
schedule.imageUrls.length === 3 && i === 0 ? 'row-span-2 h-full' : 'aspect-square'
}`}
/>
))}
</div>
)}
</div> </div>
)} )}
{/* 날짜/시간 */} {/* 날짜/시간 */}
<div className="px-4 py-3 border-t border-gray-100"> <div className="px-4 py-3 border-t border-gray-100">
<span className="text-gray-500 text-sm"> <span className="text-gray-500 text-sm">
{formatXDateTime(schedule.date, schedule.time)} {formatDateTime(schedule.datetime)}
</span> </span>
</div> </div>
{/* X에서 보기 버튼 */} {/* X에서 보기 버튼 */}
<div className="px-4 py-3 border-t border-gray-100 bg-gray-50/50"> <div className="px-4 py-3 border-t border-gray-100 bg-gray-50/50">
<a <a
href={schedule.source?.url} href={schedule.postUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full py-2.5 bg-gray-900 active:bg-black text-white rounded-full font-medium transition-colors" className="flex items-center justify-center gap-2 w-full py-2.5 bg-gray-900 active:bg-black text-white rounded-full font-medium transition-colors"
@ -870,8 +909,9 @@ function MobileScheduleDetail() {
} }
// //
const categoryId = schedule.category?.id;
const renderCategorySection = () => { const renderCategorySection = () => {
switch (schedule.category_id) { switch (categoryId) {
case CATEGORY_ID.YOUTUBE: case CATEGORY_ID.YOUTUBE:
return <YoutubeSection schedule={schedule} />; return <YoutubeSection schedule={schedule} />;
case CATEGORY_ID.X: case CATEGORY_ID.X:
@ -897,9 +937,9 @@ function MobileScheduleDetail() {
<div className="flex-1 text-center"> <div className="flex-1 text-center">
<span <span
className="text-sm font-medium" className="text-sm font-medium"
style={{ color: schedule.category_color }} style={{ color: schedule.category?.color }}
> >
{schedule.category_name} {schedule.category?.name}
</span> </span>
</div> </div>
<div className="w-10" /> <div className="w-10" />

View file

@ -118,8 +118,9 @@ function ScheduleDetail() {
} }
// //
const categoryId = schedule.category?.id;
const renderCategorySection = () => { const renderCategorySection = () => {
switch (schedule.category_id) { switch (categoryId) {
case CATEGORY_ID.YOUTUBE: case CATEGORY_ID.YOUTUBE:
return <YoutubeSection schedule={schedule} />; return <YoutubeSection schedule={schedule} />;
case CATEGORY_ID.X: case CATEGORY_ID.X:
@ -131,9 +132,9 @@ function ScheduleDetail() {
} }
}; };
const isYoutube = schedule.category_id === CATEGORY_ID.YOUTUBE; const isYoutube = categoryId === CATEGORY_ID.YOUTUBE;
const isX = schedule.category_id === CATEGORY_ID.X; const isX = categoryId === CATEGORY_ID.X;
const isConcert = schedule.category_id === CATEGORY_ID.CONCERT; const isConcert = categoryId === CATEGORY_ID.CONCERT;
const hasCustomLayout = isYoutube || isX || isConcert; const hasCustomLayout = isYoutube || isX || isConcert;
return ( return (
@ -151,9 +152,9 @@ function ScheduleDetail() {
<ChevronRight size={14} /> <ChevronRight size={14} />
<span <span
className="hover:text-primary transition-colors" className="hover:text-primary transition-colors"
style={{ color: schedule.category_color }} style={{ color: schedule.category?.color }}
> >
{schedule.category_name} {schedule.category?.name}
</span> </span>
<ChevronRight size={14} /> <ChevronRight size={14} />
<span className="text-gray-700 font-medium truncate max-w-md"> <span className="text-gray-700 font-medium truncate max-w-md">

View file

@ -1,30 +1,45 @@
import { useQuery } from '@tanstack/react-query';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { getXProfile } from '../../../../api/public/schedules'; import Linkify from 'react-linkify';
import { decodeHtmlEntities, formatXDateTime } from './utils'; import { decodeHtmlEntities } from './utils';
// X URL username // datetime (2026-01-18 19:00 7:00 · 2026 1 18)
const extractXUsername = (url) => { const formatXDateTime = (datetime) => {
if (!url) return null; if (!datetime) return '';
const match = url.match(/(?:twitter\.com|x\.com)\/([^/]+)/);
return match ? match[1] : null; 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() // X()
function XSection({ schedule }) { function XSection({ schedule }) {
const username = extractXUsername(schedule.source?.url); const profile = schedule.profile;
const username = profile?.username || 'realfromis_9';
// const displayName = profile?.displayName || username;
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 avatarUrl = profile?.avatarUrl; const avatarUrl = profile?.avatarUrl;
// ( )
const linkDecorator = (href, text, key) => (
<a
key={key}
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
{text}
</a>
);
return ( return (
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
{/* X 스타일 카드 */} {/* X 스타일 카드 */}
@ -60,9 +75,7 @@ function XSection({ schedule }) {
<path d="M22.25 12c0-1.43-.88-2.67-2.19-3.34.46-1.39.2-2.9-.81-3.91s-2.52-1.27-3.91-.81c-.66-1.31-1.91-2.19-3.34-2.19s-2.67.88-3.34 2.19c-1.39-.46-2.9-.2-3.91.81s-1.27 2.52-.81 3.91C2.63 9.33 1.75 10.57 1.75 12s.88 2.67 2.19 3.34c-.46 1.39-.2 2.9.81 3.91s2.52 1.27 3.91.81c.66 1.31 1.91 2.19 3.34 2.19s2.67-.88 3.34-2.19c1.39.46 2.9.2 3.91-.81s1.27-2.52.81-3.91c1.31-.67 2.19-1.91 2.19-3.34zm-11.07 4.57l-3.84-3.84 1.27-1.27 2.57 2.57 5.39-5.39 1.27 1.27-6.66 6.66z"/> <path d="M22.25 12c0-1.43-.88-2.67-2.19-3.34.46-1.39.2-2.9-.81-3.91s-2.52-1.27-3.91-.81c-.66-1.31-1.91-2.19-3.34-2.19s-2.67.88-3.34 2.19c-1.39-.46-2.9-.2-3.91.81s-1.27 2.52-.81 3.91C2.63 9.33 1.75 10.57 1.75 12s.88 2.67 2.19 3.34c-.46 1.39-.2 2.9.81 3.91s2.52 1.27 3.91.81c.66 1.31 1.91 2.19 3.34 2.19s2.67-.88 3.34-2.19c1.39.46 2.9.2 3.91-.81s1.27-2.52.81-3.91c1.31-.67 2.19-1.91 2.19-3.34zm-11.07 4.57l-3.84-3.84 1.27-1.27 2.57 2.57 5.39-5.39 1.27 1.27-6.66 6.66z"/>
</svg> </svg>
</div> </div>
{username && (
<span className="text-sm text-gray-500">@{username}</span> <span className="text-sm text-gray-500">@{username}</span>
)}
</div> </div>
</div> </div>
</div> </div>
@ -70,32 +83,53 @@ function XSection({ schedule }) {
{/* 본문 */} {/* 본문 */}
<div className="p-5"> <div className="p-5">
<p className="text-gray-900 text-[17px] leading-relaxed whitespace-pre-wrap"> <p className="text-gray-900 text-[17px] leading-relaxed whitespace-pre-wrap">
{decodeHtmlEntities(schedule.description || schedule.title)} <Linkify componentDecorator={linkDecorator}>
{decodeHtmlEntities(schedule.content || schedule.title)}
</Linkify>
</p> </p>
</div> </div>
{/* 이미지 */} {/* 이미지 */}
{schedule.image_url && ( {schedule.imageUrls?.length > 0 && (
<div className="px-5 pb-3"> <div className="px-5 pb-3">
{schedule.imageUrls.length === 1 ? (
<img <img
src={schedule.image_url} src={schedule.imageUrls[0]}
alt="" alt=""
className="w-full rounded-2xl border border-gray-100" className="w-full rounded-2xl border border-gray-100"
/> />
) : (
<div className={`grid gap-1 rounded-2xl overflow-hidden border border-gray-100 ${
schedule.imageUrls.length === 2 ? 'grid-cols-2' :
schedule.imageUrls.length === 3 ? 'grid-cols-2' :
'grid-cols-2'
}`}>
{schedule.imageUrls.slice(0, 4).map((url, i) => (
<img
key={i}
src={url}
alt=""
className={`w-full object-cover ${
schedule.imageUrls.length === 3 && i === 0 ? 'row-span-2 h-full' : 'aspect-square'
}`}
/>
))}
</div>
)}
</div> </div>
)} )}
{/* 날짜/시간 */} {/* 날짜/시간 */}
<div className="px-5 py-4 border-t border-gray-100"> <div className="px-5 py-4 border-t border-gray-100">
<span className="text-gray-500 text-[15px]"> <span className="text-gray-500 text-[15px]">
{formatXDateTime(schedule.date, schedule.time)} {formatXDateTime(schedule.datetime)}
</span> </span>
</div> </div>
{/* X에서 보기 버튼 */} {/* X에서 보기 버튼 */}
<div className="px-5 py-4 border-t border-gray-100 bg-gray-50/50"> <div className="px-5 py-4 border-t border-gray-100 bg-gray-50/50">
<a <a
href={schedule.source?.url} href={schedule.postUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-900 hover:bg-black text-white rounded-full font-medium transition-colors" className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-900 hover:bg-black text-white rounded-full font-medium transition-colors"