모바일 일정 상세 페이지 X 섹션 구현
- X 스타일 카드 UI (프로필, 본문, 이미지, 날짜) - 프로필 정보 API 연동 (getXProfile) - X 스타일 날짜/시간 포맷팅 - X에서 보기 버튼 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e4859471ba
commit
8ec2b1d6be
1 changed files with 127 additions and 0 deletions
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue