fix(schedule): X 일정에서 단축 URL 인식 개선
- react-linkify 대신 커스텀 linkifyText 함수 사용 - bit.ly, t.co, youtu.be 등 단축 URL 도메인 지원 - PC, 모바일 양쪽 수정 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d01f7e60dc
commit
406098d1b9
2 changed files with 80 additions and 17 deletions
|
|
@ -3,11 +3,47 @@ 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, Link2, X, ChevronRight } from 'lucide-react';
|
import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight } from 'lucide-react';
|
||||||
import Linkify from 'react-linkify';
|
|
||||||
import { getSchedule } from '@/api';
|
import { getSchedule } from '@/api';
|
||||||
import { decodeHtmlEntities, formatFullDate, formatTime, formatXDateTimeWithTime } from '@/utils';
|
import { decodeHtmlEntities, formatFullDate, formatTime, formatXDateTimeWithTime } from '@/utils';
|
||||||
import Birthday from './Birthday';
|
import Birthday from './Birthday';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL을 링크로 변환하는 함수
|
||||||
|
*/
|
||||||
|
function linkifyText(text) {
|
||||||
|
if (!text) return null;
|
||||||
|
|
||||||
|
// URL 패턴: http(s)://로 시작하거나 일반적인 단축 URL 도메인
|
||||||
|
const urlPattern = /(https?:\/\/[^\s]+|(?:bit\.ly|youtu\.be|t\.co|goo\.gl|tinyurl\.com)\/[^\s]+)/gi;
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = urlPattern.exec(text)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parts.push(text.slice(lastIndex, match.index));
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = match[0];
|
||||||
|
const href = url.startsWith('http') ? url : `https://${url}`;
|
||||||
|
|
||||||
|
parts.push(
|
||||||
|
<a key={match.index} href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">
|
||||||
|
{url}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
|
||||||
|
lastIndex = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parts.push(text.slice(lastIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts : text;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특수 일정 ID 파싱
|
* 특수 일정 ID 파싱
|
||||||
* @param {string} id - 일정 ID
|
* @param {string} id - 일정 ID
|
||||||
|
|
@ -228,12 +264,6 @@ function MobileXSection({ schedule }) {
|
||||||
return () => window.removeEventListener('popstate', handlePopState);
|
return () => window.removeEventListener('popstate', handlePopState);
|
||||||
}, [lightboxOpen]);
|
}, [lightboxOpen]);
|
||||||
|
|
||||||
// 링크 데코레이터
|
|
||||||
const linkDecorator = (href, text, key) => (
|
|
||||||
<a key={key} href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500">
|
|
||||||
{text}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -268,7 +298,7 @@ function MobileXSection({ 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">
|
||||||
<Linkify componentDecorator={linkDecorator}>{decodeHtmlEntities(schedule.content || schedule.title)}</Linkify>
|
{linkifyText(decodeHtmlEntities(schedule.content || schedule.title))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,49 @@
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import Linkify from 'react-linkify';
|
|
||||||
import { decodeHtmlEntities, formatXDateTimeWithTime } from './utils';
|
import { decodeHtmlEntities, formatXDateTimeWithTime } from './utils';
|
||||||
import { Lightbox } from '@/components/common';
|
import { Lightbox } from '@/components/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL을 링크로 변환하는 함수
|
||||||
|
*/
|
||||||
|
function linkifyText(text) {
|
||||||
|
if (!text) return null;
|
||||||
|
|
||||||
|
// URL 패턴: http(s)://로 시작하거나 일반적인 도메인 패턴
|
||||||
|
const urlPattern = /(https?:\/\/[^\s]+|(?:bit\.ly|youtu\.be|t\.co|goo\.gl|tinyurl\.com)\/[^\s]+)/gi;
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = urlPattern.exec(text)) !== null) {
|
||||||
|
// 매치 전 텍스트
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parts.push(text.slice(lastIndex, match.index));
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL
|
||||||
|
let url = match[0];
|
||||||
|
// http(s)://가 없으면 추가
|
||||||
|
const href = url.startsWith('http') ? url : `https://${url}`;
|
||||||
|
|
||||||
|
parts.push(
|
||||||
|
<a key={match.index} href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">
|
||||||
|
{url}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
|
||||||
|
lastIndex = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 나머지 텍스트
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parts.push(text.slice(lastIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts : text;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PC X(트위터) 섹션 컴포넌트
|
* PC X(트위터) 섹션 컴포넌트
|
||||||
*/
|
*/
|
||||||
|
|
@ -46,13 +86,6 @@ function XSection({ schedule }) {
|
||||||
return () => window.removeEventListener('popstate', handlePopState);
|
return () => window.removeEventListener('popstate', handlePopState);
|
||||||
}, [lightboxOpen]);
|
}, [lightboxOpen]);
|
||||||
|
|
||||||
// 링크 데코레이터 (새 탭에서 열기)
|
|
||||||
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 스타일 카드 */}
|
||||||
|
|
@ -88,7 +121,7 @@ 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">
|
||||||
<Linkify componentDecorator={linkDecorator}>{decodeHtmlEntities(schedule.content || schedule.title)}</Linkify>
|
{linkifyText(decodeHtmlEntities(schedule.content || schedule.title))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue