From 1f3c2f3d9b4b967a45cb0e70d54f2b27028248a7 Mon Sep 17 00:00:00 2001 From: caadiq Date: Sun, 29 Mar 2026 14:15:58 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20X=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=ED=95=B4=EC=8B=9C=ED=83=9C=EA=B7=B8=20=ED=95=98=EC=9D=B4?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=ED=8A=B8=20=EB=B0=8F=20=ED=81=B4=EB=A6=AD=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 해시태그(#xxx): primary 색상 하이라이트, 클릭 시 x.com/hashtag/xxx로 이동 - URL: primary 색상 + 밑줄, 클릭 시 해당 URL로 이동 - 웹(모바일/PC), 앱 모두 적용 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../views/schedule/schedule_detail_view.dart | 70 +++++++++++++++++-- .../pages/mobile/schedule/ScheduleDetail.jsx | 31 +++++--- .../pc/public/schedule/sections/XSection.jsx | 33 +++++---- 3 files changed, 104 insertions(+), 30 deletions(-) diff --git a/app/lib/views/schedule/schedule_detail_view.dart b/app/lib/views/schedule/schedule_detail_view.dart index 0e43323..a5d35fc 100644 --- a/app/lib/views/schedule/schedule_detail_view.dart +++ b/app/lib/views/schedule/schedule_detail_view.dart @@ -499,13 +499,8 @@ class _ScheduleDetailViewState extends ConsumerState { // 본문 Padding( padding: const EdgeInsets.all(16), - child: Text( + child: _buildLinkedText( decodeHtmlEntities(schedule.content ?? schedule.title), - style: const TextStyle( - fontSize: 15, - height: 1.5, - color: AppColors.textPrimary, - ), ), ), // 이미지 @@ -691,6 +686,69 @@ class _ScheduleDetailViewState extends ConsumerState { ); } + /// 해시태그, URL을 감지해서 링크로 만드는 RichText + Widget _buildLinkedText(String text) { + // 해시태그 (#xxx) 또는 URL (https://...) 매칭 + final pattern = RegExp(r'(#[^\s#]+)|(https?://[^\s]+)'); + final spans = []; + int lastEnd = 0; + + for (final match in pattern.allMatches(text)) { + // 매치 앞의 일반 텍스트 + if (match.start > lastEnd) { + spans.add(TextSpan( + text: text.substring(lastEnd, match.start), + style: const TextStyle( + fontSize: 15, + height: 1.5, + color: AppColors.textPrimary, + ), + )); + } + + final matched = match.group(0)!; + final isHashtag = matched.startsWith('#'); + + final url = isHashtag + ? 'https://x.com/hashtag/${Uri.encodeComponent(matched.substring(1))}?src=hashtag_click' + : matched; + + spans.add(WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: GestureDetector( + onTap: () => _launchUrl(url), + child: Text( + matched, + style: TextStyle( + fontSize: 15, + height: 1.5, + color: AppColors.primary, + decoration: isHashtag ? TextDecoration.none : TextDecoration.underline, + decorationColor: AppColors.primary, + ), + ), + ), + )); + + lastEnd = match.end; + } + + // 남은 텍스트 + if (lastEnd < text.length) { + spans.add(TextSpan( + text: text.substring(lastEnd), + style: const TextStyle( + fontSize: 15, + height: 1.5, + color: AppColors.textPrimary, + ), + )); + } + + return RichText(text: TextSpan(children: spans)); + } + /// 인증 배지 Widget _buildVerifiedBadge() { return SizedBox( diff --git a/frontend/src/pages/mobile/schedule/ScheduleDetail.jsx b/frontend/src/pages/mobile/schedule/ScheduleDetail.jsx index 2a23863..b504a14 100644 --- a/frontend/src/pages/mobile/schedule/ScheduleDetail.jsx +++ b/frontend/src/pages/mobile/schedule/ScheduleDetail.jsx @@ -13,26 +13,37 @@ import Birthday from './Birthday'; 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; + // 해시태그 또는 URL 매칭 + const pattern = /(#[^\s#]+)|(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) { + while ((match = pattern.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}`; + const matched = match[0]; - parts.push( - - {url} - - ); + if (matched.startsWith('#')) { + // 해시태그 + const tag = matched.slice(1); + parts.push( + + {matched} + + ); + } else { + // URL + const href = matched.startsWith('http') ? matched : `https://${matched}`; + parts.push( + + {matched} + + ); + } lastIndex = match.index + match[0].length; } diff --git a/frontend/src/pages/pc/public/schedule/sections/XSection.jsx b/frontend/src/pages/pc/public/schedule/sections/XSection.jsx index 2996f36..4db823c 100644 --- a/frontend/src/pages/pc/public/schedule/sections/XSection.jsx +++ b/frontend/src/pages/pc/public/schedule/sections/XSection.jsx @@ -9,34 +9,39 @@ import { Lightbox } from '@/components/common'; 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; + // 해시태그 또는 URL 매칭 + const pattern = /(#[^\s#]+)|(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) { - // 매치 전 텍스트 + while ((match = pattern.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}`; + const matched = match[0]; - parts.push( - - {url} - - ); + if (matched.startsWith('#')) { + const tag = matched.slice(1); + parts.push( + + {matched} + + ); + } else { + const href = matched.startsWith('http') ? matched : `https://${matched}`; + parts.push( + + {matched} + + ); + } lastIndex = match.index + match[0].length; } - // 나머지 텍스트 if (lastIndex < text.length) { parts.push(text.slice(lastIndex)); }