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:
parent
8bcd3e881d
commit
1d17c83568
6 changed files with 263 additions and 90 deletions
|
|
@ -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,44 +130,53 @@ 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;
|
||||
});
|
||||
|
|
|
|||
85
frontend/package-lock.json
generated
85
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
<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 (
|
||||
<motion.div
|
||||
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"/>
|
||||
</svg>
|
||||
</div>
|
||||
{username && (
|
||||
<span className="text-xs text-gray-500">@{username}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -357,32 +377,51 @@ function XSection({ schedule }) {
|
|||
{/* 본문 */}
|
||||
<div className="p-4">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* 이미지 */}
|
||||
{schedule.image_url && (
|
||||
{schedule.imageUrls?.length > 0 && (
|
||||
<div className="px-4 pb-3">
|
||||
{schedule.imageUrls.length === 1 ? (
|
||||
<img
|
||||
src={schedule.image_url}
|
||||
src={schedule.imageUrls[0]}
|
||||
alt=""
|
||||
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 className="px-4 py-3 border-t border-gray-100">
|
||||
<span className="text-gray-500 text-sm">
|
||||
{formatXDateTime(schedule.date, schedule.time)}
|
||||
{formatDateTime(schedule.datetime)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* X에서 보기 버튼 */}
|
||||
<div className="px-4 py-3 border-t border-gray-100 bg-gray-50/50">
|
||||
<a
|
||||
href={schedule.source?.url}
|
||||
href={schedule.postUrl}
|
||||
target="_blank"
|
||||
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"
|
||||
|
|
@ -870,8 +909,9 @@ function MobileScheduleDetail() {
|
|||
}
|
||||
|
||||
// 카테고리별 섹션 렌더링
|
||||
const categoryId = schedule.category?.id;
|
||||
const renderCategorySection = () => {
|
||||
switch (schedule.category_id) {
|
||||
switch (categoryId) {
|
||||
case CATEGORY_ID.YOUTUBE:
|
||||
return <YoutubeSection schedule={schedule} />;
|
||||
case CATEGORY_ID.X:
|
||||
|
|
@ -897,9 +937,9 @@ function MobileScheduleDetail() {
|
|||
<div className="flex-1 text-center">
|
||||
<span
|
||||
className="text-sm font-medium"
|
||||
style={{ color: schedule.category_color }}
|
||||
style={{ color: schedule.category?.color }}
|
||||
>
|
||||
{schedule.category_name}
|
||||
{schedule.category?.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-10" />
|
||||
|
|
|
|||
|
|
@ -118,8 +118,9 @@ function ScheduleDetail() {
|
|||
}
|
||||
|
||||
// 카테고리별 섹션 렌더링
|
||||
const categoryId = schedule.category?.id;
|
||||
const renderCategorySection = () => {
|
||||
switch (schedule.category_id) {
|
||||
switch (categoryId) {
|
||||
case CATEGORY_ID.YOUTUBE:
|
||||
return <YoutubeSection schedule={schedule} />;
|
||||
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() {
|
|||
<ChevronRight size={14} />
|
||||
<span
|
||||
className="hover:text-primary transition-colors"
|
||||
style={{ color: schedule.category_color }}
|
||||
style={{ color: schedule.category?.color }}
|
||||
>
|
||||
{schedule.category_name}
|
||||
{schedule.category?.name}
|
||||
</span>
|
||||
<ChevronRight size={14} />
|
||||
<span className="text-gray-700 font-medium truncate max-w-md">
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
<a
|
||||
key={key}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* 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"/>
|
||||
</svg>
|
||||
</div>
|
||||
{username && (
|
||||
<span className="text-sm text-gray-500">@{username}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -70,32 +83,53 @@ function XSection({ schedule }) {
|
|||
{/* 본문 */}
|
||||
<div className="p-5">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* 이미지 */}
|
||||
{schedule.image_url && (
|
||||
{schedule.imageUrls?.length > 0 && (
|
||||
<div className="px-5 pb-3">
|
||||
{schedule.imageUrls.length === 1 ? (
|
||||
<img
|
||||
src={schedule.image_url}
|
||||
src={schedule.imageUrls[0]}
|
||||
alt=""
|
||||
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 className="px-5 py-4 border-t border-gray-100">
|
||||
<span className="text-gray-500 text-[15px]">
|
||||
{formatXDateTime(schedule.date, schedule.time)}
|
||||
{formatXDateTime(schedule.datetime)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* X에서 보기 버튼 */}
|
||||
<div className="px-5 py-4 border-t border-gray-100 bg-gray-50/50">
|
||||
<a
|
||||
href={schedule.source?.url}
|
||||
href={schedule.postUrl}
|
||||
target="_blank"
|
||||
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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue