From edf6b60b4a1f968d6f2d1b8a69eb2bdb62bb7f0b Mon Sep 17 00:00:00 2001 From: caadiq Date: Thu, 15 Jan 2026 15:19:44 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EC=9D=BC=EC=A0=95=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EB=A5=BC=20zustand=20store=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Schedule.jsx에서 useState 대신 useScheduleStore 사용 - 상세 페이지 이동 후에도 선택한 날짜/카테고리/검색어 유지 - X 상세 페이지 UI 개선 (X 아이콘 제거, 날짜 형식 변경) - X 프로필 URL 디코딩 로직 수정 Co-Authored-By: Claude Opus 4.5 --- backend/services/x-bot.js | 21 +++++++---- frontend/src/pages/pc/public/Schedule.jsx | 36 ++++++++++++------- .../src/pages/pc/public/ScheduleDetail.jsx | 34 ++++++++++++------ frontend/src/stores/useScheduleStore.js | 4 +-- 4 files changed, 64 insertions(+), 31 deletions(-) diff --git a/backend/services/x-bot.js b/backend/services/x-bot.js index 0822ae7..e4722bf 100644 --- a/backend/services/x-bot.js +++ b/backend/services/x-bot.js @@ -150,17 +150,17 @@ function extractProfileFromHtml(html) { avatarUrl: null, }; - // Display name 추출: 이름 + // Display name 추출: 이름 const nameMatch = html.match( - /]*class="profile-card-fullname"[^>]*>([^<]+)<\/a>/ + /class="profile-card-fullname"[^>]*title="([^"]+)"/ ); if (nameMatch) { profile.displayName = nameMatch[1].trim(); } - // Avatar URL 추출: + // Avatar URL 추출: const avatarMatch = html.match( - /]*class="profile-card-avatar"[^>]*>[\s\S]*?]*src="([^"]+)"/ + /class="profile-card-avatar"[^>]*>[\s\S]*?]*src="([^"]+)"/ ); if (avatarMatch) { profile.avatarUrl = avatarMatch[1]; @@ -174,10 +174,17 @@ function extractProfileFromHtml(html) { */ async function cacheXProfile(username, profile, nitterUrl) { try { - // Nitter URL이 상대 경로인 경우 절대 경로로 변환 + // Nitter 프록시 URL에서 원본 Twitter 이미지 URL 추출 let avatarUrl = profile.avatarUrl; - if (avatarUrl && avatarUrl.startsWith("/")) { - avatarUrl = `${nitterUrl}${avatarUrl}`; + if (avatarUrl) { + // /pic/https%3A%2F%2Fpbs.twimg.com%2F... 형식에서 원본 URL 추출 + const encodedMatch = avatarUrl.match(/\/pic\/(.+)/); + if (encodedMatch) { + avatarUrl = decodeURIComponent(encodedMatch[1]); + } else if (avatarUrl.startsWith("/")) { + // 상대 경로인 경우 Nitter URL 추가 + avatarUrl = `${nitterUrl}${avatarUrl}`; + } } const data = { diff --git a/frontend/src/pages/pc/public/Schedule.jsx b/frontend/src/pages/pc/public/Schedule.jsx index 9585bf8..483177f 100644 --- a/frontend/src/pages/pc/public/Schedule.jsx +++ b/frontend/src/pages/pc/public/Schedule.jsx @@ -7,6 +7,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import { useInView } from 'react-intersection-observer'; import { getTodayKST } from '../../../utils/date'; import { getSchedules, getCategories, searchSchedules as searchSchedulesApi } from '../../../api/public/schedules'; +import useScheduleStore from '../../../stores/useScheduleStore'; // HTML 엔티티 디코딩 함수 const decodeHtmlEntities = (text) => { @@ -18,18 +19,32 @@ const decodeHtmlEntities = (text) => { function Schedule() { const navigate = useNavigate(); - - const [currentDate, setCurrentDate] = useState(new Date()); - const [selectedDate, setSelectedDate] = useState(getTodayKST()); // KST 기준 오늘 + + // 상태 관리 (zustand store) + const { + currentDate, + setCurrentDate, + selectedDate: storedSelectedDate, + setSelectedDate: setStoredSelectedDate, + selectedCategories, + setSelectedCategories, + isSearchMode, + setIsSearchMode, + searchInput, + setSearchInput, + searchTerm, + setSearchTerm, + } = useScheduleStore(); + + // 초기값 설정 (store에 값이 없으면 오늘 날짜) + const selectedDate = storedSelectedDate === undefined ? getTodayKST() : storedSelectedDate; + const setSelectedDate = setStoredSelectedDate; + const [showYearMonthPicker, setShowYearMonthPicker] = useState(false); const [viewMode, setViewMode] = useState('yearMonth'); const [slideDirection, setSlideDirection] = useState(0); const pickerRef = useRef(null); - - // 데이터 상태 - const [selectedCategories, setSelectedCategories] = useState([]); - // 카테고리 데이터 로드 (useQuery) const { data: categories = [] } = useQuery({ queryKey: ['scheduleCategories'], @@ -49,12 +64,9 @@ function Schedule() { const categoryRef = useRef(null); const scrollContainerRef = useRef(null); // 일정 목록 스크롤 컨테이너 const searchContainerRef = useRef(null); // 검색 컨테이너 (외부 클릭 감지용) - - // 검색 상태 - const [isSearchMode, setIsSearchMode] = useState(false); - const [searchInput, setSearchInput] = useState(''); // 입력창에 표시되는 값 + + // 검색 관련 로컬 상태 (store에서 관리하지 않는 것들) const [originalSearchQuery, setOriginalSearchQuery] = useState(''); // 사용자가 직접 입력한 원본 값 (필터링용) - const [searchTerm, setSearchTerm] = useState(''); const [showSuggestions, setShowSuggestions] = useState(false); const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); const [suggestions, setSuggestions] = useState([]); // 추천 검색어 목록 diff --git a/frontend/src/pages/pc/public/ScheduleDetail.jsx b/frontend/src/pages/pc/public/ScheduleDetail.jsx index a244b6b..5e0dfed 100644 --- a/frontend/src/pages/pc/public/ScheduleDetail.jsx +++ b/frontend/src/pages/pc/public/ScheduleDetail.jsx @@ -48,6 +48,26 @@ const formatTime = (timeStr) => { return timeStr.slice(0, 5); }; +// 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 VideoInfo({ schedule, isShorts }) { return ( @@ -242,17 +262,13 @@ function XSection({ schedule }) { @{username} )} - {/* X 로고 */} - - - {/* 본문 */}

- {decodeHtmlEntities(schedule.title)} + {decodeHtmlEntities(schedule.description || schedule.title)}

@@ -269,11 +285,9 @@ function XSection({ schedule }) { {/* 날짜/시간 */}
-
- {formatTime(schedule.time)} - {schedule.time && ·} - {formatFullDate(schedule.date)} -
+ + {formatXDateTime(schedule.date, schedule.time)} +
{/* X에서 보기 버튼 */} diff --git a/frontend/src/stores/useScheduleStore.js b/frontend/src/stores/useScheduleStore.js index 42caef5..ecc5c20 100644 --- a/frontend/src/stores/useScheduleStore.js +++ b/frontend/src/stores/useScheduleStore.js @@ -10,7 +10,7 @@ const useScheduleStore = create((set) => ({ // 필터 및 선택 selectedCategories: [], - selectedDate: null, // null이면 전체보기, undefined이면 getTodayKST() 사용 + selectedDate: undefined, // undefined면 오늘 날짜 사용, null이면 전체보기 currentDate: new Date(), // 스크롤 위치 @@ -32,7 +32,7 @@ const useScheduleStore = create((set) => ({ searchTerm: "", isSearchMode: false, selectedCategories: [], - selectedDate: null, + selectedDate: undefined, currentDate: new Date(), scrollPosition: 0, }),