feat(x-schedule): 링크 미리보기(OG) 카드 추가 - 하이브리드
트윗의 외부 링크에 대해 미리보기 카드 표시. - Nitter가 렌더링한 카드 우선 사용 (extractCard) - Nitter 카드가 비어있으면 본문 URL로 OG 직접 추출 (og.js) - YouTube/Instagram 등 복구, HTML 엔티티 디코딩 포함 - TikTok 등 봇 차단 사이트는 Nitter 카드로 커버 - schedule_x.card_data 컬럼 + getScheduleDetail 응답에 card 포함 - 가로 레이아웃 카드 (왼쪽 이미지 + 오른쪽 텍스트) - CardImage: 이미지 로드 실패 시 fallback 아이콘 (인스타 CDN 만료 대비) - 자체 영상/이미지가 있으면 OG 카드 숨김 (중복 방지) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
d3d3c9cf75
commit
8b8b9a7f53
6 changed files with 264 additions and 3 deletions
|
|
@ -215,6 +215,7 @@ export async function getScheduleDetail(db, id, getXProfile = null) {
|
||||||
sx.content as x_content,
|
sx.content as x_content,
|
||||||
sx.image_urls as x_image_urls,
|
sx.image_urls as x_image_urls,
|
||||||
sx.video_thumbnails as x_video_thumbnails,
|
sx.video_thumbnails as x_video_thumbnails,
|
||||||
|
sx.card_data as x_card_data,
|
||||||
sv.broadcaster as variety_broadcaster,
|
sv.broadcaster as variety_broadcaster,
|
||||||
sv.replay_url as variety_replay_url,
|
sv.replay_url as variety_replay_url,
|
||||||
svi.medium_url as variety_thumbnail_url,
|
svi.medium_url as variety_thumbnail_url,
|
||||||
|
|
@ -299,6 +300,7 @@ export async function getScheduleDetail(db, id, getXProfile = null) {
|
||||||
result.content = s.x_content || null;
|
result.content = s.x_content || null;
|
||||||
result.imageUrls = s.x_image_urls ? JSON.parse(s.x_image_urls) : [];
|
result.imageUrls = s.x_image_urls ? JSON.parse(s.x_image_urls) : [];
|
||||||
result.videoThumbnails = s.x_video_thumbnails ? JSON.parse(s.x_video_thumbnails) : [];
|
result.videoThumbnails = s.x_video_thumbnails ? JSON.parse(s.x_video_thumbnails) : [];
|
||||||
|
result.card = s.x_card_data ? JSON.parse(s.x_card_data) : null;
|
||||||
result.postUrl = `https://x.com/${username}/status/${s.x_post_id}`;
|
result.postUrl = `https://x.com/${username}/status/${s.x_post_id}`;
|
||||||
|
|
||||||
if (getXProfile) {
|
if (getXProfile) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import fp from 'fastify-plugin';
|
import fp from 'fastify-plugin';
|
||||||
import { fetchTweets, fetchAllTweets, fetchProfile as fetchNitterProfile, extractTitle, extractYoutubeVideoIds, extractProfile } from './scraper.js';
|
import { fetchTweets, fetchAllTweets, fetchProfile as fetchNitterProfile, extractTitle, extractYoutubeVideoIds, extractProfile } from './scraper.js';
|
||||||
|
import { fetchOgCard, extractFirstUrl } from './og.js';
|
||||||
import { fetchVideoInfo } from '../youtube/api.js';
|
import { fetchVideoInfo } from '../youtube/api.js';
|
||||||
import { formatDate, formatTime, nowKST } from '../../utils/date.js';
|
import { formatDate, formatTime, nowKST } from '../../utils/date.js';
|
||||||
import { withTransaction } from '../../utils/transaction.js';
|
import { withTransaction } from '../../utils/transaction.js';
|
||||||
|
|
@ -51,6 +52,24 @@ async function xBotPlugin(fastify, opts) {
|
||||||
/**
|
/**
|
||||||
* 트윗을 DB에 저장
|
* 트윗을 DB에 저장
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 트윗 카드 확정
|
||||||
|
* - Nitter가 준 카드가 유효(제목/이미지 보유)하면 그대로 사용
|
||||||
|
* - 비었거나 없으면 본문 첫 URL로 OG 직접 추출 (YouTube 등 복구)
|
||||||
|
*/
|
||||||
|
async function resolveCard(tweet) {
|
||||||
|
if (tweet.card && (tweet.card.title || tweet.card.image)) {
|
||||||
|
return tweet.card;
|
||||||
|
}
|
||||||
|
const url = extractFirstUrl(tweet.text);
|
||||||
|
if (!url) return null;
|
||||||
|
try {
|
||||||
|
return await fetchOgCard(url);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveTweet(tweet, username) {
|
async function saveTweet(tweet, username) {
|
||||||
// 중복 체크 (post_id로) - 트랜잭션 전에 수행
|
// 중복 체크 (post_id로) - 트랜잭션 전에 수행
|
||||||
const [existing] = await fastify.db.query(
|
const [existing] = await fastify.db.query(
|
||||||
|
|
@ -82,6 +101,9 @@ async function xBotPlugin(fastify, opts) {
|
||||||
// 리트윗인 경우 원본 작성자를 username으로 사용
|
// 리트윗인 경우 원본 작성자를 username으로 사용
|
||||||
const tweetUsername = tweet.originalUsername || username;
|
const tweetUsername = tweet.originalUsername || username;
|
||||||
|
|
||||||
|
// 카드 확정: Nitter 카드 우선, 비어있으면 본문 URL로 OG 직접 추출 (fallback)
|
||||||
|
const card = await resolveCard(tweet);
|
||||||
|
|
||||||
// 트랜잭션으로 INSERT 작업 수행
|
// 트랜잭션으로 INSERT 작업 수행
|
||||||
return withTransaction(fastify.db, async (connection) => {
|
return withTransaction(fastify.db, async (connection) => {
|
||||||
// schedules 테이블에 저장
|
// schedules 테이블에 저장
|
||||||
|
|
@ -93,7 +115,7 @@ async function xBotPlugin(fastify, opts) {
|
||||||
|
|
||||||
// schedule_x 테이블에 저장
|
// schedule_x 테이블에 저장
|
||||||
await connection.query(
|
await connection.query(
|
||||||
'INSERT INTO schedule_x (schedule_id, post_id, username, content, image_urls, video_thumbnails) VALUES (?, ?, ?, ?, ?, ?)',
|
'INSERT INTO schedule_x (schedule_id, post_id, username, content, image_urls, video_thumbnails, card_data) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
[
|
[
|
||||||
scheduleId,
|
scheduleId,
|
||||||
tweet.id,
|
tweet.id,
|
||||||
|
|
@ -101,6 +123,7 @@ async function xBotPlugin(fastify, opts) {
|
||||||
tweet.text,
|
tweet.text,
|
||||||
tweet.imageUrls.length > 0 ? JSON.stringify(tweet.imageUrls) : null,
|
tweet.imageUrls.length > 0 ? JSON.stringify(tweet.imageUrls) : null,
|
||||||
tweet.videoThumbnails?.length > 0 ? JSON.stringify(tweet.videoThumbnails) : null,
|
tweet.videoThumbnails?.length > 0 ? JSON.stringify(tweet.videoThumbnails) : null,
|
||||||
|
card ? JSON.stringify(card) : null,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
98
backend/src/services/x/og.js
Normal file
98
backend/src/services/x/og.js
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
/**
|
||||||
|
* URL에서 Open Graph 메타데이터 직접 추출
|
||||||
|
* Nitter가 카드를 비워서 줄 때(주로 YouTube) fallback으로 사용.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const OG_TIMEOUT = 8000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML 엔티티 디코딩 (이미지 URL의 & 등 처리)
|
||||||
|
*/
|
||||||
|
function decodeEntities(str) {
|
||||||
|
if (!str) return str;
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/�?39;/g, "'")
|
||||||
|
.replace(/'/gi, "'")
|
||||||
|
.replace(///gi, '/')
|
||||||
|
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
|
||||||
|
.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML에서 og:/twitter: 메타 태그 값 추출 (속성 순서 무관)
|
||||||
|
*/
|
||||||
|
function pickMeta(html, prop) {
|
||||||
|
const patterns = [
|
||||||
|
new RegExp(`<meta[^>]+(?:property|name)="${prop}"[^>]+content="([^"]*)"`, 'i'),
|
||||||
|
new RegExp(`<meta[^>]+content="([^"]*)"[^>]+(?:property|name)="${prop}"`, 'i'),
|
||||||
|
];
|
||||||
|
for (const re of patterns) {
|
||||||
|
const m = html.match(re);
|
||||||
|
if (m) return decodeEntities(m[1]);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL의 OG 메타데이터를 가져와 카드 형식으로 반환
|
||||||
|
* @param {string} url - 대상 URL
|
||||||
|
* @returns {Promise<object|null>} { url, title, description, destination, image } 또는 null
|
||||||
|
*/
|
||||||
|
export async function fetchOgCard(url) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), OG_TIMEOUT);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; fromis9-bot)' },
|
||||||
|
redirect: 'follow',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
|
||||||
|
const html = await res.text();
|
||||||
|
const title = pickMeta(html, 'og:title') || pickMeta(html, 'twitter:title');
|
||||||
|
const image = pickMeta(html, 'og:image') || pickMeta(html, 'twitter:image');
|
||||||
|
// 제목이나 이미지가 없으면 의미 있는 카드가 아님
|
||||||
|
if (!title && !image) return null;
|
||||||
|
|
||||||
|
const description = pickMeta(html, 'og:description') || pickMeta(html, 'twitter:description') || '';
|
||||||
|
const siteName = pickMeta(html, 'og:site_name');
|
||||||
|
// 최종 리다이렉트된 호스트를 destination으로
|
||||||
|
let destination = siteName;
|
||||||
|
if (!destination) {
|
||||||
|
try { destination = new URL(res.url).hostname.replace(/^www\./, ''); } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
title: title || '',
|
||||||
|
description: description.slice(0, 200),
|
||||||
|
destination: destination || '',
|
||||||
|
image: image || null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트윗 본문 텍스트에서 첫 외부 URL 추출 (OG fetch 대상)
|
||||||
|
* t.co / 단축 URL / 일반 http(s) 모두 대상, x.com 자기 링크는 제외
|
||||||
|
*/
|
||||||
|
export function extractFirstUrl(text) {
|
||||||
|
if (!text) return null;
|
||||||
|
const matches = text.match(/https?:\/\/[^\s]+/g);
|
||||||
|
if (!matches) return null;
|
||||||
|
for (const u of matches) {
|
||||||
|
const clean = u.replace(/[)\]}.,]+$/, '');
|
||||||
|
if (/(?:^https?:\/\/)?(?:www\.)?(?:x\.com|twitter\.com)\//i.test(clean)) continue;
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -67,6 +67,40 @@ export function extractVideoThumbnails(html) {
|
||||||
return [...new Set(urls)];
|
return [...new Set(urls)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML에서 링크 미리보기 카드(Open Graph) 추출
|
||||||
|
* Nitter가 트윗의 외부 링크에 대해 카드를 직접 렌더링하므로 그대로 파싱.
|
||||||
|
* 트윗당 최대 1개. 카드가 없으면 null.
|
||||||
|
*/
|
||||||
|
export function extractCard(html) {
|
||||||
|
if (!html.includes('card-container')) return null;
|
||||||
|
const hrefMatch = html.match(/<a class="card-container" href="([^"]+)"/);
|
||||||
|
if (!hrefMatch) return null;
|
||||||
|
|
||||||
|
const stripTags = s => (s ? s.replace(/<[^>]+>/g, '').trim() : '');
|
||||||
|
const titleMatch = html.match(/<h2 class="card-title">([\s\S]*?)<\/h2>/);
|
||||||
|
const descMatch = html.match(/<p class="card-description">([\s\S]*?)<\/p>/);
|
||||||
|
const destMatch = html.match(/<span class="card-destination">([\s\S]*?)<\/span>/);
|
||||||
|
const imgMatch = html.match(/<img src="(\/pic\/card_img[^"]+)"/);
|
||||||
|
|
||||||
|
let image = null;
|
||||||
|
if (imgMatch) {
|
||||||
|
image = `https://pbs.twimg.com/${decodeURIComponent(imgMatch[1].replace(/^\/pic\//, ''))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = stripTags(titleMatch?.[1]);
|
||||||
|
// 제목이나 이미지 중 하나는 있어야 유효한 카드
|
||||||
|
if (!title && !image) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: hrefMatch[1],
|
||||||
|
title,
|
||||||
|
description: stripTags(descMatch?.[1]),
|
||||||
|
destination: stripTags(destMatch?.[1]),
|
||||||
|
image,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 텍스트에서 유튜브 videoId 추출
|
* 텍스트에서 유튜브 videoId 추출
|
||||||
*/
|
*/
|
||||||
|
|
@ -200,9 +234,10 @@ export function parseTweets(html, username, options = {}) {
|
||||||
text = extractTextFromHtml(contentMatch[1]);
|
text = extractTextFromHtml(contentMatch[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이미지 / 영상 썸네일
|
// 이미지 / 영상 썸네일 / 링크 카드
|
||||||
const imageUrls = extractImageUrls(container);
|
const imageUrls = extractImageUrls(container);
|
||||||
const videoThumbnails = extractVideoThumbnails(container);
|
const videoThumbnails = extractVideoThumbnails(container);
|
||||||
|
const card = extractCard(container);
|
||||||
|
|
||||||
tweets.push({
|
tweets.push({
|
||||||
id,
|
id,
|
||||||
|
|
@ -210,6 +245,7 @@ export function parseTweets(html, username, options = {}) {
|
||||||
text,
|
text,
|
||||||
imageUrls,
|
imageUrls,
|
||||||
videoThumbnails,
|
videoThumbnails,
|
||||||
|
card,
|
||||||
isRetweet,
|
isRetweet,
|
||||||
originalUsername,
|
originalUsername,
|
||||||
// 긴 트윗(280자 초과)이 …로 잘렸는지 여부 (hydrate 대상 판별용)
|
// 긴 트윗(280자 초과)이 …로 잘렸는지 여부 (hydrate 대상 판별용)
|
||||||
|
|
@ -250,9 +286,10 @@ export async function fetchSingleTweet(nitterUrl, username, postId) {
|
||||||
text = extractTextFromHtml(contentMatch[1]);
|
text = extractTextFromHtml(contentMatch[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이미지 / 영상 썸네일
|
// 이미지 / 영상 썸네일 / 링크 카드
|
||||||
const imageUrls = extractImageUrls(container);
|
const imageUrls = extractImageUrls(container);
|
||||||
const videoThumbnails = extractVideoThumbnails(container);
|
const videoThumbnails = extractVideoThumbnails(container);
|
||||||
|
const card = extractCard(container);
|
||||||
|
|
||||||
// 프로필 정보
|
// 프로필 정보
|
||||||
const profile = extractProfile(html);
|
const profile = extractProfile(html);
|
||||||
|
|
@ -263,6 +300,7 @@ export async function fetchSingleTweet(nitterUrl, username, postId) {
|
||||||
text,
|
text,
|
||||||
imageUrls,
|
imageUrls,
|
||||||
videoThumbnails,
|
videoThumbnails,
|
||||||
|
card,
|
||||||
url: `https://x.com/${username}/status/${postId}`,
|
url: `https://x.com/${username}/status/${postId}`,
|
||||||
profile,
|
profile,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,25 @@ function MobileYoutubeSection({ schedule }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카드 이미지 (로드 실패 시 fallback 아이콘 — 인스타 등 CDN 만료 대비)
|
||||||
|
*/
|
||||||
|
function CardImage({ src, className }) {
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
if (!src || error) {
|
||||||
|
return (
|
||||||
|
<div className={`${className} flex items-center justify-center bg-gray-100 text-gray-300`}>
|
||||||
|
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||||
|
<path d="M21 15l-5-5L5 21" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <img src={src} alt="" className={`${className} object-cover bg-gray-100`} onError={() => setError(true)} />;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mobile X(트위터) 섹션
|
* Mobile X(트위터) 섹션
|
||||||
*/
|
*/
|
||||||
|
|
@ -418,6 +437,37 @@ function MobileXSection({ schedule }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 링크 미리보기 카드 (Open Graph) — 자체 이미지/영상이 있으면 숨김 */}
|
||||||
|
{schedule.card?.url && !(schedule.videoThumbnails?.length > 0) && !(schedule.imageUrls?.length > 0) && (
|
||||||
|
<div className="px-4 pb-3">
|
||||||
|
<a
|
||||||
|
href={schedule.card.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-stretch rounded-xl border border-gray-200 overflow-hidden active:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
{schedule.card.image && (
|
||||||
|
<CardImage src={schedule.card.image} className="w-24 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0 p-2.5 flex flex-col justify-center">
|
||||||
|
{schedule.card.destination && (
|
||||||
|
<p className="text-[11px] text-gray-400 mb-0.5">{schedule.card.destination}</p>
|
||||||
|
)}
|
||||||
|
{schedule.card.title && (
|
||||||
|
<p className="text-sm font-medium text-gray-900 line-clamp-2">
|
||||||
|
{decodeHtmlEntities(schedule.card.title)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{schedule.card.description && (
|
||||||
|
<p className="text-xs text-gray-500 line-clamp-2 mt-0.5">
|
||||||
|
{decodeHtmlEntities(schedule.card.description)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</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">{formatXDateTimeWithTime(schedule.date, schedule.time)}</span>
|
<span className="text-gray-500 text-sm">{formatXDateTimeWithTime(schedule.date, schedule.time)}</span>
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,25 @@ function linkifyText(text) {
|
||||||
return parts.length > 0 ? parts : text;
|
return parts.length > 0 ? parts : text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카드 이미지 (로드 실패 시 fallback 아이콘 — 인스타 등 CDN 만료 대비)
|
||||||
|
*/
|
||||||
|
function CardImage({ src, className }) {
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
if (!src || error) {
|
||||||
|
return (
|
||||||
|
<div className={`${className} flex items-center justify-center bg-gray-100 text-gray-300`}>
|
||||||
|
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||||
|
<path d="M21 15l-5-5L5 21" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <img src={src} alt="" className={`${className} object-cover bg-gray-100`} onError={() => setError(true)} />;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PC X(트위터) 섹션 컴포넌트
|
* PC X(트위터) 섹션 컴포넌트
|
||||||
*/
|
*/
|
||||||
|
|
@ -197,6 +216,37 @@ function XSection({ schedule }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 링크 미리보기 카드 (Open Graph) — 자체 이미지/영상이 있으면 숨김 */}
|
||||||
|
{schedule.card?.url && !(schedule.videoThumbnails?.length > 0) && !(schedule.imageUrls?.length > 0) && (
|
||||||
|
<div className="px-5 pb-3">
|
||||||
|
<a
|
||||||
|
href={schedule.card.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-stretch rounded-2xl border border-gray-200 overflow-hidden hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
{schedule.card.image && (
|
||||||
|
<CardImage src={schedule.card.image} className="w-32 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0 p-3 flex flex-col justify-center">
|
||||||
|
{schedule.card.destination && (
|
||||||
|
<p className="text-xs text-gray-400 mb-0.5">{schedule.card.destination}</p>
|
||||||
|
)}
|
||||||
|
{schedule.card.title && (
|
||||||
|
<p className="text-[15px] font-medium text-gray-900 line-clamp-2">
|
||||||
|
{decodeHtmlEntities(schedule.card.title)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{schedule.card.description && (
|
||||||
|
<p className="text-sm text-gray-500 line-clamp-2 mt-0.5">
|
||||||
|
{decodeHtmlEntities(schedule.card.description)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</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]">{formatXDateTimeWithTime(schedule.date, schedule.time)}</span>
|
<span className="text-gray-500 text-[15px]">{formatXDateTimeWithTime(schedule.date, schedule.time)}</span>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue