모바일 일정 상세 페이지 X 섹션 구현

- X 스타일 카드 UI (프로필, 본문, 이미지, 날짜)
- 프로필 정보 API 연동 (getXProfile)
- X 스타일 날짜/시간 포맷팅
- X에서 보기 버튼

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-15 21:02:48 +09:00
parent e4859471ba
commit 8ec2b1d6be

View file

@ -90,6 +90,33 @@ const formatTime = (timeStr) => {
return timeStr.slice(0, 5);
};
// X URL username
const extractXUsername = (url) => {
if (!url) return null;
const match = url.match(/(?:twitter\.com|x\.com)\/([^/]+)/);
return match ? match[1] : null;
};
// X / ( 2:30 · 2026 1 15)
const formatXDateTime = (dateStr, timeStr) => {
if (!dateStr) return '';
const date = new Date(dateStr);
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
let result = `${year}${month}${day}`;
if (timeStr) {
const [hours, minutes] = timeStr.split(':').map(Number);
const period = hours < 12 ? '오전' : '오후';
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
result = `${period} ${hour12}:${String(minutes).padStart(2, '0')} · ${result}`;
}
return result;
};
//
function YoutubeSection({ schedule }) {
const videoId = extractYoutubeVideoId(schedule.source_url);
@ -194,6 +221,104 @@ 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 avatarUrl = profile?.avatarUrl;
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-white rounded-xl border border-gray-200 overflow-hidden"
>
{/* 헤더 */}
<div className="p-4 pb-0">
<div className="flex items-center gap-3">
{/* 프로필 이미지 */}
{avatarUrl ? (
<img
src={avatarUrl}
alt={displayName}
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-gray-700 to-gray-900 flex items-center justify-center">
<span className="text-white font-bold">
{displayName.charAt(0).toUpperCase()}
</span>
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-bold text-gray-900 text-sm truncate">
{displayName}
</span>
<svg className="w-4 h-4 text-blue-500 flex-shrink-0" viewBox="0 0 24 24" fill="currentColor">
<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>
{/* 본문 */}
<div className="p-4">
<p className="text-gray-900 text-[15px] leading-relaxed whitespace-pre-wrap">
{decodeHtmlEntities(schedule.description || schedule.title)}
</p>
</div>
{/* 이미지 */}
{schedule.image_url && (
<div className="px-4 pb-3">
<img
src={schedule.image_url}
alt=""
className="w-full rounded-xl border border-gray-100"
/>
</div>
)}
{/* 날짜/시간 */}
<div className="px-4 py-3 border-t border-gray-100">
<span className="text-gray-500 text-sm">
{formatXDateTime(schedule.date, schedule.time)}
</span>
</div>
{/* X에서 보기 버튼 */}
<div className="px-4 py-3 border-t border-gray-100 bg-gray-50/50">
<a
href={schedule.source_url}
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"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
X에서 보기
</a>
</div>
</motion.div>
);
}
// ( - )
function DefaultSection({ schedule }) {
return (
@ -341,6 +466,8 @@ function MobileScheduleDetail() {
switch (schedule.category_id) {
case CATEGORY_ID.YOUTUBE:
return <YoutubeSection schedule={schedule} />;
case CATEGORY_ID.X:
return <XSection schedule={schedule} />;
default:
return <DefaultSection schedule={schedule} />;
}