From 8e5f0e20d288ca9b92f9f33a4a251513ca5f057b Mon Sep 17 00:00:00 2001 From: Jeongeun Lee Date: Tue, 10 Mar 2026 17:23:34 +0900 Subject: [PATCH 01/17] =?UTF-8?q?hotfix:=20ios=EC=9D=BC=20=EB=95=8C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=ED=83=AD=EC=9D=B4=20=EB=B3=B4=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/routes/_bombom/_main/my.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/routes/_bombom/_main/my.tsx b/web/src/routes/_bombom/_main/my.tsx index 6d91c77da..25b50cb52 100644 --- a/web/src/routes/_bombom/_main/my.tsx +++ b/web/src/routes/_bombom/_main/my.tsx @@ -15,6 +15,7 @@ import NotificationSettingsSection from '@/pages/MyPage/NotificationSettingsSect import ProfileSection from '@/pages/MyPage/ProfileSection'; import RewardsSection from '@/pages/MyPage/RewardsSection'; import SubscribedNewslettersSection from '@/pages/MyPage/SubscribedNewslettersSection'; +import { isIOS } from '@/utils/device'; import type { Device } from '@/hooks/useDevice'; import type { CSSObject, Theme } from '@emotion/react'; import AvatarIcon from '#/assets/svg/avatar.svg'; @@ -63,7 +64,7 @@ function MyPage() { }, }); - const tabs = [...DEFAULT_TABS, ...WEBVIEW_TABS]; + const tabs = isIOS() ? [...DEFAULT_TABS] : [...DEFAULT_TABS, ...WEBVIEW_TABS]; if (!userInfo) return null; From cf5beb4117a4b161f4577987c6d8c4699a458e50 Mon Sep 17 00:00:00 2001 From: Jeongeun Lee Date: Tue, 10 Mar 2026 18:59:29 +0900 Subject: [PATCH 02/17] =?UTF-8?q?hotfix:=20=EC=9B=B9=EC=9D=BC=EB=95=8C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=84=A4=EC=A0=95=EC=9D=B4=20=EB=B3=B4?= =?UTF-8?q?=EC=9D=B4=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/routes/_bombom/_main/my.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/web/src/routes/_bombom/_main/my.tsx b/web/src/routes/_bombom/_main/my.tsx index 25b50cb52..2d7d10515 100644 --- a/web/src/routes/_bombom/_main/my.tsx +++ b/web/src/routes/_bombom/_main/my.tsx @@ -15,7 +15,7 @@ import NotificationSettingsSection from '@/pages/MyPage/NotificationSettingsSect import ProfileSection from '@/pages/MyPage/ProfileSection'; import RewardsSection from '@/pages/MyPage/RewardsSection'; import SubscribedNewslettersSection from '@/pages/MyPage/SubscribedNewslettersSection'; -import { isIOS } from '@/utils/device'; +import { isIOS, isWebView } from '@/utils/device'; import type { Device } from '@/hooks/useDevice'; import type { CSSObject, Theme } from '@emotion/react'; import AvatarIcon from '#/assets/svg/avatar.svg'; @@ -64,7 +64,10 @@ function MyPage() { }, }); - const tabs = isIOS() ? [...DEFAULT_TABS] : [...DEFAULT_TABS, ...WEBVIEW_TABS]; + const tabs = + isIOS() || !isWebView() + ? [...DEFAULT_TABS] + : [...DEFAULT_TABS, ...WEBVIEW_TABS]; if (!userInfo) return null; From 32f60d344293c86b3088a9232ce7bad83f1559e1 Mon Sep 17 00:00:00 2001 From: Jeongeun Lee Date: Thu, 12 Mar 2026 14:45:51 +0900 Subject: [PATCH 03/17] =?UTF-8?q?hotfix:=20ios=EC=9D=BC=EB=95=8C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=84=A4=EC=A0=95=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EB=A1=9C=20=EC=A0=91=EA=B7=BC=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EB=AA=BB=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/Header/ProfileDetail.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/Header/ProfileDetail.tsx b/web/src/components/Header/ProfileDetail.tsx index a2dbd55ac..95d7e16ea 100644 --- a/web/src/components/Header/ProfileDetail.tsx +++ b/web/src/components/Header/ProfileDetail.tsx @@ -7,7 +7,7 @@ import { postLogout } from '@/apis/auth/auth.api'; import { useDevice } from '@/hooks/useDevice'; import { showMessenger } from '@/libs/channelTalk/channelTalk.utils'; import { copyToClipboard } from '@/utils/copy'; -import { isWebView } from '@/utils/device'; +import { isIOS, isWebView } from '@/utils/device'; import type { UserProfile } from '@/types/me'; import ChatIcon from '#/assets/svg/chat.svg'; import CopyIcon from '#/assets/svg/copy.svg'; @@ -93,7 +93,7 @@ const ProfileDetail = ({ userProfile, onClose }: ProfileDetailProps) => { 선물함 - {isWebView() && ( + {isWebView() && !isIOS() && ( 알림 설정 From 14de406bb2036f581e1e1898dfe9f1a1ac2cd273 Mon Sep 17 00:00:00 2001 From: Jeongeun Lee Date: Fri, 13 Mar 2026 23:30:16 +0900 Subject: [PATCH 04/17] =?UTF-8?q?hotfix:=20ios=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=84=A4=EC=A0=95=20=ED=83=AD=EC=9D=B4=20?= =?UTF-8?q?=EB=B3=B4=EC=9D=B4=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/Header/ProfileDetail.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/Header/ProfileDetail.tsx b/web/src/components/Header/ProfileDetail.tsx index 95d7e16ea..a2dbd55ac 100644 --- a/web/src/components/Header/ProfileDetail.tsx +++ b/web/src/components/Header/ProfileDetail.tsx @@ -7,7 +7,7 @@ import { postLogout } from '@/apis/auth/auth.api'; import { useDevice } from '@/hooks/useDevice'; import { showMessenger } from '@/libs/channelTalk/channelTalk.utils'; import { copyToClipboard } from '@/utils/copy'; -import { isIOS, isWebView } from '@/utils/device'; +import { isWebView } from '@/utils/device'; import type { UserProfile } from '@/types/me'; import ChatIcon from '#/assets/svg/chat.svg'; import CopyIcon from '#/assets/svg/copy.svg'; @@ -93,7 +93,7 @@ const ProfileDetail = ({ userProfile, onClose }: ProfileDetailProps) => { 선물함 - {isWebView() && !isIOS() && ( + {isWebView() && ( 알림 설정 From 549bf78cb1d885aa8e58a459b7c3cf408f149171 Mon Sep 17 00:00:00 2001 From: Lee Jeongeun <106021313+JeLee-river@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:21:39 +0900 Subject: [PATCH 05/17] =?UTF-8?q?[BOM-1042]=20refactor:=20=EB=9E=9C?= =?UTF-8?q?=EB=94=A9=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=9D=B8=EB=8D=B1?= =?UTF-8?q?=EC=8B=B1=20=EC=A0=95=EC=B1=85=20=EC=88=98=EC=A0=95=20(#173)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 랜딩페이지 robots 태그 수정 * chore: 사이트맵 업데이트 --- web/public/sitemap.xml | 16 ++++------------ web/src/routes/landing.tsx | 2 +- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/web/public/sitemap.xml b/web/public/sitemap.xml index ca316dbe9..1bb1f16c9 100644 --- a/web/public/sitemap.xml +++ b/web/public/sitemap.xml @@ -4,10 +4,10 @@ xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"> - + https://www.bombom.news/ - 2025-12-26T00:00:00+00:00 + 2026-03-25T00:00:00+00:00 daily 1.0 @@ -15,16 +15,8 @@ https://www.bombom.news/landing - 2025-12-26T00:00:00+00:00 + 2026-03-25T00:00:00+00:00 monthly - 0.3 - - - - - https://www.bombom.news/login - 2025-12-26T00:00:00+00:00 - monthly - 0.5 + 0.8 \ No newline at end of file diff --git a/web/src/routes/landing.tsx b/web/src/routes/landing.tsx index 5198476af..8d97c1f16 100644 --- a/web/src/routes/landing.tsx +++ b/web/src/routes/landing.tsx @@ -17,7 +17,7 @@ export const Route = createFileRoute('/landing')({ meta: [ { name: 'robots', - content: 'noindex, follow', + content: 'index, follow', }, { title: '봄봄 | 읽고 남기고 쌓는 뉴스레터 리딩 플랫폼', From 8a80c7178f8165b954f49abe0e33e5a6f645f7e1 Mon Sep 17 00:00:00 2001 From: Jeongeun Lee Date: Fri, 27 Mar 2026 18:31:19 +0900 Subject: [PATCH 06/17] =?UTF-8?q?hotfix:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C=20pro?= =?UTF-8?q?file=20api=EA=B0=80=20=ED=98=B8=EC=B6=9C=EB=90=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/contexts/AuthContext.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 738549779..f0ad6fb27 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -1,4 +1,5 @@ import { useQuery } from '@tanstack/react-query'; +import { useRouterState } from '@tanstack/react-router'; import { createContext, useContext, useMemo } from 'react'; import { queries } from '@/apis/queries'; import type { UserProfile } from '@/types/me'; @@ -13,7 +14,15 @@ export interface AuthContextType { const AuthContext = createContext(null); export const AuthProvider = ({ children }: PropsWithChildren) => { - const { data: userProfile, isLoading } = useQuery(queries.userProfile()); + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }); + const isSignupPage = pathname === '/signup'; + + const { data: userProfile, isLoading } = useQuery({ + ...queries.userProfile(), + enabled: !isSignupPage, + }); const isLoggedIn = useMemo(() => Boolean(userProfile), [userProfile]); From f906d1772bee36d7a48d839e42f93934c7295e52 Mon Sep 17 00:00:00 2001 From: Jeongeun Lee Date: Wed, 1 Apr 2026 16:46:05 +0900 Subject: [PATCH 07/17] =?UTF-8?q?Revert=20"[BOM-1053]=20fix:=20=EC=95=88?= =?UTF-8?q?=EB=93=9C=EB=A1=9C=EC=9D=B4=EB=93=9C=20highlight=20toolbar=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0=20(#180)"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit a83ca25ab683319e41ad1be1def77c91c1324073. --- app/.gitignore | 7 +--- app/components/main/MainScreen.tsx | 2 +- app/hooks/useNotification.ts | 19 +++------ app/tsconfig.json | 21 ++-------- app/utils/notification.ts | 26 +++++------- .../useFloatingToolbarSelection.ts | 40 ++++--------------- web/src/utils/device.ts | 4 +- 7 files changed, 31 insertions(+), 88 deletions(-) diff --git a/app/.gitignore b/app/.gitignore index 1f204e379..6b1de0d8a 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -5,9 +5,4 @@ bombom-fe426-firebase-adminsdk-fbsvc-3ac4862ca7.json google-services.json # GoogleService-Info.plist: Firebase iOS 설정 파일 -GoogleService-Info.plist -# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb -# The following patterns were generated by expo-cli - -expo-env.d.ts -# @end expo-cli \ No newline at end of file +GoogleService-Info.plist \ No newline at end of file diff --git a/app/components/main/MainScreen.tsx b/app/components/main/MainScreen.tsx index 51c26d9b8..7e593db37 100644 --- a/app/components/main/MainScreen.tsx +++ b/app/components/main/MainScreen.tsx @@ -156,7 +156,7 @@ export const MainScreen = () => { { // 앱 종료 상태에서 알림을 탭한 경우 const coldStartNotificationOpen = useCallback(async () => { try { - const message = await getInitialNotification(getMessaging()); + const message = await messaging().getInitialNotification(); if (!message) return; const url = getNotificationUrl(message.data ?? {}); @@ -80,7 +75,7 @@ const useNotification = () => { coldStartNotificationOpen(); // FCM 토큰 갱신 리스너: 토큰이 변경되면 자동으로 서버에 업데이트 - const unsubscribeTokenRefresh = onTokenRefresh(getMessaging(), async () => { + const unsubscribeTokenRefresh = messaging().onTokenRefresh(async () => { console.log('FCM 토큰이 갱신되었습니다'); try { await registerFCMToken(); @@ -90,8 +85,7 @@ const useNotification = () => { }); // FCM 포그라운드 메시지 리스너: 앱이 열려있을 때 FCM 메시지를 받으면 즉시 로컬 알림으로 표시 - const unsubscribe = onMessage( - getMessaging(), + const unsubscribe = messaging().onMessage( async (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => { // FCM에서 메시지를 받으면 Expo Notifications로 로컬 알림 표시 if (remoteMessage.notification) { @@ -108,8 +102,7 @@ const useNotification = () => { ); // 백그라운드에서 알림을 탭한 경우 - const unsubscribeNotificationOpened = onNotificationOpenedApp( - getMessaging(), + const unsubscribeNotificationOpened = messaging().onNotificationOpenedApp( (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => { const url = getNotificationUrl(remoteMessage.data ?? {}); if (url) { diff --git a/app/tsconfig.json b/app/tsconfig.json index 3eac845f6..d0c35f0da 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -3,11 +3,7 @@ "compilerOptions": { "target": "ES2020", "module": "NodeNext", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "rootDir": ".", "strict": true, "esModuleInterop": true, @@ -16,19 +12,10 @@ "forceConsistentCasingInFileNames": true, "moduleResolution": "NodeNext", "resolveJsonModule": true, - "types": [ - "./emotion.d.ts" - ], + "types": ["./emotion.d.ts"], "paths": { - "@/*": [ - "./*" - ] + "@/*": ["./*"] } }, - "include": [ - "**/*.ts", - "**/*.tsx", - ".expo/types/**/*.ts", - "expo-env.d.ts" - ] + "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts"] } diff --git a/app/utils/notification.ts b/app/utils/notification.ts index 0df4b4e27..d4c0647d7 100644 --- a/app/utils/notification.ts +++ b/app/utils/notification.ts @@ -1,10 +1,4 @@ -import { - getMessaging, - requestPermission, - hasPermission, - getToken, - AuthorizationStatus, -} from '@react-native-firebase/messaging'; +import messaging from '@react-native-firebase/messaging'; import * as Device from 'expo-device'; import * as Notifications from 'expo-notifications'; import { Linking, Platform } from 'react-native'; @@ -27,11 +21,11 @@ export const createAndroidChannel = async () => { export const requestNotificationPermission = async () => { try { if (Platform.OS === 'ios') { - const auth = await requestPermission(getMessaging()); + const auth = await messaging().requestPermission(); return ( - auth === AuthorizationStatus.AUTHORIZED || - auth === AuthorizationStatus.PROVISIONAL + auth === messaging.AuthorizationStatus.AUTHORIZED || + auth === messaging.AuthorizationStatus.PROVISIONAL ); } @@ -54,10 +48,10 @@ export const checkNotificationPermission = async () => { } if (Platform.OS === 'ios') { - const iosGrantedStatus = await hasPermission(getMessaging()); + const iosGrantedStatus = await messaging().hasPermission(); return ( - iosGrantedStatus === AuthorizationStatus.AUTHORIZED || - iosGrantedStatus === AuthorizationStatus.PROVISIONAL + iosGrantedStatus === messaging.AuthorizationStatus.AUTHORIZED || + iosGrantedStatus === messaging.AuthorizationStatus.PROVISIONAL ); } @@ -90,12 +84,12 @@ export const goToSystemPermission = async () => { export const getFCMToken = async () => { try { - const isPermissionGranted = await checkNotificationPermission(); - if (!isPermissionGranted) { + const hasPermission = await checkNotificationPermission(); + if (!hasPermission) { throw new Error('푸시 알림 권한이 없습니다.'); } - const token = await getToken(getMessaging()); + const token = await messaging().getToken(); return token; } catch (error) { console.error('FCM 토큰을 가져오는데 실패했습니다.', error); diff --git a/web/src/pages/detail/components/ArticleBody/useFloatingToolbarSelection.ts b/web/src/pages/detail/components/ArticleBody/useFloatingToolbarSelection.ts index 8d2e5aa21..27e348656 100644 --- a/web/src/pages/detail/components/ArticleBody/useFloatingToolbarSelection.ts +++ b/web/src/pages/detail/components/ArticleBody/useFloatingToolbarSelection.ts @@ -21,7 +21,6 @@ export const useFloatingToolbarSelection = ({ const device = useDevice(); const activeSelectionRangeRef = useRef(null); const activeHighlightIdRef = useRef(null); - const selectionChangeStart = useRef(false); const isPC = device === 'pc'; @@ -78,29 +77,13 @@ export const useFloatingToolbarSelection = ({ [openToolbarFromHighlight, onHide], ); - const handleSelectionComplete = useCallback( - (e: Event) => { - e.preventDefault(); - e.stopPropagation(); - - const selection = window.getSelection(); - if ( - selection && - !selection.isCollapsed && - selection.rangeCount > 0 && - selectionChangeStart.current - ) { - selectionChangeStart.current = false; - openToolbarFromSelection(selection); - return; - } - }, - [openToolbarFromSelection], - ); - - const handleSelectionChangeStart = useCallback(() => { - selectionChangeStart.current = true; - }, []); + const handleSelectionComplete = useCallback(() => { + const selection = window.getSelection(); + if (selection && !selection.isCollapsed && selection.rangeCount > 0) { + openToolbarFromSelection(selection); + return; + } + }, [openToolbarFromSelection]); const handleSelectionClear = useCallback(() => { const selection = window.getSelection(); @@ -111,9 +94,6 @@ export const useFloatingToolbarSelection = ({ const handleHighlightClickOrSelection = useCallback( (e: PointerEvent | MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - const target = e.target as HTMLElement; if (target.tagName === 'MARK') { @@ -139,7 +119,6 @@ export const useFloatingToolbarSelection = ({ if (isIOS()) { contentEl.addEventListener('pointerup', handleHighlightClickOrSelection); } else if (isAndroid()) { - contentEl.addEventListener('pointercancel', handleSelectionChangeStart); contentEl.addEventListener('contextmenu', handleSelectionComplete); contentEl.addEventListener('click', handleHighlightClick); } else if (!isWebView()) { @@ -156,10 +135,6 @@ export const useFloatingToolbarSelection = ({ handleHighlightClickOrSelection, ); } else if (isAndroid()) { - contentEl.removeEventListener( - 'pointercancel', - handleSelectionChangeStart, - ); contentEl.removeEventListener('contextmenu', handleSelectionComplete); contentEl.removeEventListener('click', handleHighlightClick); } else if (!isWebView()) { @@ -174,7 +149,6 @@ export const useFloatingToolbarSelection = ({ contentRef, handleHighlightClick, handleHighlightClickOrSelection, - handleSelectionChangeStart, handleSelectionClear, handleSelectionComplete, ]); diff --git a/web/src/utils/device.ts b/web/src/utils/device.ts index 8936e934d..df3749b12 100644 --- a/web/src/utils/device.ts +++ b/web/src/utils/device.ts @@ -18,8 +18,8 @@ export const getDeviceInWebView = () => { : 'android'; } - if (navigator.userAgent.includes('android')) return 'android'; - if (navigator.userAgent.includes('ios')) return 'ios'; + if (navigator.userAgent.includes('google')) return 'android'; + if (navigator.userAgent.includes('Apple')) return 'ios'; return null; }; From 5d6b167b9bd21b37d4c50e3613a1d2b83ce740ea Mon Sep 17 00:00:00 2001 From: Jeongeun Lee Date: Wed, 1 Apr 2026 17:37:26 +0900 Subject: [PATCH 08/17] =?UTF-8?q?hotfix:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=95=8C=EB=A6=BC=EC=9D=B4=20=ED=86=A0=EA=B8=80?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NotificationSettingsSection.tsx | 5 ++--- .../NotificationSettingsSection/SettingList.tsx | 17 ++++++----------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/web/src/pages/my-page/components/NotificationSettingsSection/NotificationSettingsSection.tsx b/web/src/pages/my-page/components/NotificationSettingsSection/NotificationSettingsSection.tsx index 22987ed89..6c04e9a45 100644 --- a/web/src/pages/my-page/components/NotificationSettingsSection/NotificationSettingsSection.tsx +++ b/web/src/pages/my-page/components/NotificationSettingsSection/NotificationSettingsSection.tsx @@ -1,8 +1,7 @@ import { theme } from '@bombom/shared'; import styled from '@emotion/styled'; import { useQueries } from '@tanstack/react-query'; -import SettingList from './SettingList'; -import { CATEGORY } from '../../constants/notification'; +import SettingList, { NOTIFICATION_SETTINGS } from './SettingList'; import useCategoryNotificationMutation from '../../hooks/useCategoryNotificationMutation'; import { queries } from '@/apis/queries'; import ChevronIcon from '@/components/icons/ChevronIcon'; @@ -19,7 +18,7 @@ const NotificationSettingsSection = () => { const { hasPermission } = useWebViewNotificationPermission(); const notificationsEnabled = useQueries({ - queries: Object.values(CATEGORY).map((category) => ({ + queries: NOTIFICATION_SETTINGS.map(({ category }) => ({ ...queries.notificationSettings.category({ memberId, category }), enabled: !!hasPermission, })), diff --git a/web/src/pages/my-page/components/NotificationSettingsSection/SettingList.tsx b/web/src/pages/my-page/components/NotificationSettingsSection/SettingList.tsx index 8f3936f84..11c409188 100644 --- a/web/src/pages/my-page/components/NotificationSettingsSection/SettingList.tsx +++ b/web/src/pages/my-page/components/NotificationSettingsSection/SettingList.tsx @@ -37,17 +37,12 @@ const SettingList = ({ notificationsEnabled, onToggle, }: SettingListProps) => { - const settings = NOTIFICATION_SETTINGS.map((config) => { - const queryResult = notificationsEnabled.find( - ({ data }) => data?.category === config.category, - ); - return { - category: config.category, - label: config.label, - hint: config.hint, - enabled: queryResult?.data?.enabled ?? false, - }; - }); + const settings = NOTIFICATION_SETTINGS.map((config, index) => ({ + category: config.category, + label: config.label, + hint: config.hint, + enabled: notificationsEnabled[index]?.data?.enabled ?? false, + })); return ( From d91769b070c3ce036e0d9c3738893354a06bb235 Mon Sep 17 00:00:00 2001 From: Jeongeun Lee Date: Sat, 4 Apr 2026 15:29:40 +0900 Subject: [PATCH 09/17] =?UTF-8?q?Reapply=20"[BOM-1053]=20fix:=20=EC=95=88?= =?UTF-8?q?=EB=93=9C=EB=A1=9C=EC=9D=B4=EB=93=9C=20highlight=20toolbar=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0=20(#180)"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit f906d1772bee36d7a48d839e42f93934c7295e52. --- app/.gitignore | 7 +++- app/components/main/MainScreen.tsx | 2 +- app/hooks/useNotification.ts | 19 ++++++--- app/tsconfig.json | 21 ++++++++-- app/utils/notification.ts | 26 +++++++----- .../useFloatingToolbarSelection.ts | 40 +++++++++++++++---- web/src/utils/device.ts | 4 +- 7 files changed, 88 insertions(+), 31 deletions(-) diff --git a/app/.gitignore b/app/.gitignore index 6b1de0d8a..1f204e379 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -5,4 +5,9 @@ bombom-fe426-firebase-adminsdk-fbsvc-3ac4862ca7.json google-services.json # GoogleService-Info.plist: Firebase iOS 설정 파일 -GoogleService-Info.plist \ No newline at end of file +GoogleService-Info.plist +# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb +# The following patterns were generated by expo-cli + +expo-env.d.ts +# @end expo-cli \ No newline at end of file diff --git a/app/components/main/MainScreen.tsx b/app/components/main/MainScreen.tsx index 7e593db37..51c26d9b8 100644 --- a/app/components/main/MainScreen.tsx +++ b/app/components/main/MainScreen.tsx @@ -156,7 +156,7 @@ export const MainScreen = () => { { // 앱 종료 상태에서 알림을 탭한 경우 const coldStartNotificationOpen = useCallback(async () => { try { - const message = await messaging().getInitialNotification(); + const message = await getInitialNotification(getMessaging()); if (!message) return; const url = getNotificationUrl(message.data ?? {}); @@ -75,7 +80,7 @@ const useNotification = () => { coldStartNotificationOpen(); // FCM 토큰 갱신 리스너: 토큰이 변경되면 자동으로 서버에 업데이트 - const unsubscribeTokenRefresh = messaging().onTokenRefresh(async () => { + const unsubscribeTokenRefresh = onTokenRefresh(getMessaging(), async () => { console.log('FCM 토큰이 갱신되었습니다'); try { await registerFCMToken(); @@ -85,7 +90,8 @@ const useNotification = () => { }); // FCM 포그라운드 메시지 리스너: 앱이 열려있을 때 FCM 메시지를 받으면 즉시 로컬 알림으로 표시 - const unsubscribe = messaging().onMessage( + const unsubscribe = onMessage( + getMessaging(), async (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => { // FCM에서 메시지를 받으면 Expo Notifications로 로컬 알림 표시 if (remoteMessage.notification) { @@ -102,7 +108,8 @@ const useNotification = () => { ); // 백그라운드에서 알림을 탭한 경우 - const unsubscribeNotificationOpened = messaging().onNotificationOpenedApp( + const unsubscribeNotificationOpened = onNotificationOpenedApp( + getMessaging(), (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => { const url = getNotificationUrl(remoteMessage.data ?? {}); if (url) { diff --git a/app/tsconfig.json b/app/tsconfig.json index d0c35f0da..3eac845f6 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -3,7 +3,11 @@ "compilerOptions": { "target": "ES2020", "module": "NodeNext", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "rootDir": ".", "strict": true, "esModuleInterop": true, @@ -12,10 +16,19 @@ "forceConsistentCasingInFileNames": true, "moduleResolution": "NodeNext", "resolveJsonModule": true, - "types": ["./emotion.d.ts"], + "types": [ + "./emotion.d.ts" + ], "paths": { - "@/*": ["./*"] + "@/*": [ + "./*" + ] } }, - "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts"] + "include": [ + "**/*.ts", + "**/*.tsx", + ".expo/types/**/*.ts", + "expo-env.d.ts" + ] } diff --git a/app/utils/notification.ts b/app/utils/notification.ts index d4c0647d7..0df4b4e27 100644 --- a/app/utils/notification.ts +++ b/app/utils/notification.ts @@ -1,4 +1,10 @@ -import messaging from '@react-native-firebase/messaging'; +import { + getMessaging, + requestPermission, + hasPermission, + getToken, + AuthorizationStatus, +} from '@react-native-firebase/messaging'; import * as Device from 'expo-device'; import * as Notifications from 'expo-notifications'; import { Linking, Platform } from 'react-native'; @@ -21,11 +27,11 @@ export const createAndroidChannel = async () => { export const requestNotificationPermission = async () => { try { if (Platform.OS === 'ios') { - const auth = await messaging().requestPermission(); + const auth = await requestPermission(getMessaging()); return ( - auth === messaging.AuthorizationStatus.AUTHORIZED || - auth === messaging.AuthorizationStatus.PROVISIONAL + auth === AuthorizationStatus.AUTHORIZED || + auth === AuthorizationStatus.PROVISIONAL ); } @@ -48,10 +54,10 @@ export const checkNotificationPermission = async () => { } if (Platform.OS === 'ios') { - const iosGrantedStatus = await messaging().hasPermission(); + const iosGrantedStatus = await hasPermission(getMessaging()); return ( - iosGrantedStatus === messaging.AuthorizationStatus.AUTHORIZED || - iosGrantedStatus === messaging.AuthorizationStatus.PROVISIONAL + iosGrantedStatus === AuthorizationStatus.AUTHORIZED || + iosGrantedStatus === AuthorizationStatus.PROVISIONAL ); } @@ -84,12 +90,12 @@ export const goToSystemPermission = async () => { export const getFCMToken = async () => { try { - const hasPermission = await checkNotificationPermission(); - if (!hasPermission) { + const isPermissionGranted = await checkNotificationPermission(); + if (!isPermissionGranted) { throw new Error('푸시 알림 권한이 없습니다.'); } - const token = await messaging().getToken(); + const token = await getToken(getMessaging()); return token; } catch (error) { console.error('FCM 토큰을 가져오는데 실패했습니다.', error); diff --git a/web/src/pages/detail/components/ArticleBody/useFloatingToolbarSelection.ts b/web/src/pages/detail/components/ArticleBody/useFloatingToolbarSelection.ts index 27e348656..8d2e5aa21 100644 --- a/web/src/pages/detail/components/ArticleBody/useFloatingToolbarSelection.ts +++ b/web/src/pages/detail/components/ArticleBody/useFloatingToolbarSelection.ts @@ -21,6 +21,7 @@ export const useFloatingToolbarSelection = ({ const device = useDevice(); const activeSelectionRangeRef = useRef(null); const activeHighlightIdRef = useRef(null); + const selectionChangeStart = useRef(false); const isPC = device === 'pc'; @@ -77,13 +78,29 @@ export const useFloatingToolbarSelection = ({ [openToolbarFromHighlight, onHide], ); - const handleSelectionComplete = useCallback(() => { - const selection = window.getSelection(); - if (selection && !selection.isCollapsed && selection.rangeCount > 0) { - openToolbarFromSelection(selection); - return; - } - }, [openToolbarFromSelection]); + const handleSelectionComplete = useCallback( + (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + + const selection = window.getSelection(); + if ( + selection && + !selection.isCollapsed && + selection.rangeCount > 0 && + selectionChangeStart.current + ) { + selectionChangeStart.current = false; + openToolbarFromSelection(selection); + return; + } + }, + [openToolbarFromSelection], + ); + + const handleSelectionChangeStart = useCallback(() => { + selectionChangeStart.current = true; + }, []); const handleSelectionClear = useCallback(() => { const selection = window.getSelection(); @@ -94,6 +111,9 @@ export const useFloatingToolbarSelection = ({ const handleHighlightClickOrSelection = useCallback( (e: PointerEvent | MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + const target = e.target as HTMLElement; if (target.tagName === 'MARK') { @@ -119,6 +139,7 @@ export const useFloatingToolbarSelection = ({ if (isIOS()) { contentEl.addEventListener('pointerup', handleHighlightClickOrSelection); } else if (isAndroid()) { + contentEl.addEventListener('pointercancel', handleSelectionChangeStart); contentEl.addEventListener('contextmenu', handleSelectionComplete); contentEl.addEventListener('click', handleHighlightClick); } else if (!isWebView()) { @@ -135,6 +156,10 @@ export const useFloatingToolbarSelection = ({ handleHighlightClickOrSelection, ); } else if (isAndroid()) { + contentEl.removeEventListener( + 'pointercancel', + handleSelectionChangeStart, + ); contentEl.removeEventListener('contextmenu', handleSelectionComplete); contentEl.removeEventListener('click', handleHighlightClick); } else if (!isWebView()) { @@ -149,6 +174,7 @@ export const useFloatingToolbarSelection = ({ contentRef, handleHighlightClick, handleHighlightClickOrSelection, + handleSelectionChangeStart, handleSelectionClear, handleSelectionComplete, ]); diff --git a/web/src/utils/device.ts b/web/src/utils/device.ts index df3749b12..8936e934d 100644 --- a/web/src/utils/device.ts +++ b/web/src/utils/device.ts @@ -18,8 +18,8 @@ export const getDeviceInWebView = () => { : 'android'; } - if (navigator.userAgent.includes('google')) return 'android'; - if (navigator.userAgent.includes('Apple')) return 'ios'; + if (navigator.userAgent.includes('android')) return 'android'; + if (navigator.userAgent.includes('ios')) return 'ios'; return null; }; From 39525db3a78e91c3fe086661125fbd17012cb879 Mon Sep 17 00:00:00 2001 From: Deokbin Kim <162319857+Db0111@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:39:53 +0900 Subject: [PATCH 10/17] =?UTF-8?q?[BOM-1066]=20nanumgothic=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=8F=B0=ED=8A=B8=20=EB=B3=80=EA=B2=BD=20(#188)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: nanumgothic으로 폰트 변경 * feat: 800-> 700 font weight 변경 --- shared/src/theme.ts | 14 +++++++------- web/src/styles/reset.ts | 3 ++- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/shared/src/theme.ts b/shared/src/theme.ts index f0e65465f..5c1ec615f 100644 --- a/shared/src/theme.ts +++ b/shared/src/theme.ts @@ -1,13 +1,13 @@ const fontFamily = - '"NanumSquareRound", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"'; + '"NanumGothic", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"'; const fonts = { - heading1: `800 48px/60px ${fontFamily}`, - heading2: `800 34px/50px ${fontFamily}`, - heading3: `800 28px/38px ${fontFamily}`, - heading4: `800 24px/32px ${fontFamily}`, - heading5: `800 18px/28px ${fontFamily}`, - heading6: `800 16px/24px ${fontFamily}`, + heading1: `700 48px/60px ${fontFamily}`, + heading2: `700 34px/50px ${fontFamily}`, + heading3: `700 28px/38px ${fontFamily}`, + heading4: `700 24px/32px ${fontFamily}`, + heading5: `700 18px/28px ${fontFamily}`, + heading6: `700 16px/24px ${fontFamily}`, bodyLarge: `400 18px/28px ${fontFamily}`, body1: `400 16px/24px ${fontFamily}`, body2: `400 14px/22px ${fontFamily}`, diff --git a/web/src/styles/reset.ts b/web/src/styles/reset.ts index 0834809a2..05f2dbd94 100644 --- a/web/src/styles/reset.ts +++ b/web/src/styles/reset.ts @@ -1,7 +1,7 @@ import { css } from '@emotion/react'; const reset = css` - @import url('https://cdn.rawgit.com/innks/NanumSquareRound/master/nanumsquareround.min.css'); + @import url('http://fonts.googleapis.com/earlyaccess/nanumgothic.css'); /* Reset 기본 스타일 */ html, @@ -49,6 +49,7 @@ const reset = css` body { font-family: + 'NanumGothic', 'Pretendard Variable', Pretendard, -apple-system, From 03b23e155e82af9ec30bed620cfdd56cc60dae15 Mon Sep 17 00:00:00 2001 From: Jeongeun Lee Date: Wed, 29 Apr 2026 14:47:56 +0900 Subject: [PATCH 11/17] chore: trigger frontend deploy --- .github/workflows/frontend-deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/frontend-deploy.yml b/.github/workflows/frontend-deploy.yml index f858487d3..7e7dc6fc1 100644 --- a/.github/workflows/frontend-deploy.yml +++ b/.github/workflows/frontend-deploy.yml @@ -1,4 +1,5 @@ name: CI/CD - Frontend Deploy +# redeploy trigger: 2026-04-29 on: push: From c4209317e8c7e1b2af286ffb14a4e61a8333d58d Mon Sep 17 00:00:00 2001 From: Jeongeun Lee Date: Wed, 29 Apr 2026 23:09:41 +0900 Subject: [PATCH 12/17] chore: trigger maeil-mail deploy --- .github/workflows/frontend-deploy.yml | 1 - .github/workflows/maeil-mail-deploy.yml | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/frontend-deploy.yml b/.github/workflows/frontend-deploy.yml index 7e7dc6fc1..f858487d3 100644 --- a/.github/workflows/frontend-deploy.yml +++ b/.github/workflows/frontend-deploy.yml @@ -1,5 +1,4 @@ name: CI/CD - Frontend Deploy -# redeploy trigger: 2026-04-29 on: push: diff --git a/.github/workflows/maeil-mail-deploy.yml b/.github/workflows/maeil-mail-deploy.yml index 6fcbabf97..7a46e55b8 100644 --- a/.github/workflows/maeil-mail-deploy.yml +++ b/.github/workflows/maeil-mail-deploy.yml @@ -1,4 +1,5 @@ name: CI/CD - Maeil Mail Deploy +# redeploy trigger: 2026-04-29 on: push: From 4164e5c631267078ed7bf208ddfba7d58fa98e50 Mon Sep 17 00:00:00 2001 From: Cheol Won <76567238+Ryan-Dia@users.noreply.github.com> Date: Tue, 16 Jun 2026 09:43:21 +0900 Subject: [PATCH 13/17] =?UTF-8?q?feat:=20Flyway=20=EB=B7=B0=EC=96=B4=20UI?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0=20=E2=80=94=20=EC=88=9C=EC=84=9C=EC=97=AD?= =?UTF-8?q?=EC=A0=84=20=ED=91=9C=EC=8B=9C=20/=20dev=C2=B7prod=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20(#255)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 어드민에 Flyway 버전 형상 뷰어 추가 - /flyway 라우트 + 사이드바 메뉴 - flyway API/쿼리 모듈 (overview/script/wip) - FlywayViewer: 4단계 파이프라인, 같은번호 충돌·순서역전 배너, 검색/상태필터, 마스터-디테일(CodeMirror SQL), 다음 안전 번호 - WipRegisterForm: 작업중 등록(새/기존 테이블, 기존 테이블 검색) → 백엔드 이슈 생성 * feat: Flyway 뷰어 UI 개선 — 순서역전 행 표시 / dev·prod 파이프라인 레이블 - STATUS_META: MERGE_PENDING→'dev 반영', DB_APPLIED→'prod 반영' - 순서역전 행: 좌측 컬러 세로선(컬럼=빨강, 테이블=주황) + 인라인 배지 ('⚠ 컬럼 역전' / '🔀 순서 역전') - 상단 배너 severity 표기: TABLE→'테이블', COLUMN→'컬럼'으로 한글화 - SQL 패널: 선택 마이그레이션이 순서역전 대상이면 경고 블록 표시 (공유 테이블/컬럼명 하이라이트, 강약 구분 색상) --- admin/src/apis/flyway/flyway.api.ts | 88 +++ admin/src/apis/flyway/flyway.query.ts | 34 ++ admin/src/components/Sidebar.tsx | 5 + admin/src/pages/flyway/FlywayViewer.tsx | 677 +++++++++++++++++++++ admin/src/pages/flyway/WipRegisterForm.tsx | 223 +++++++ admin/src/routeTree.gen.ts | 21 + admin/src/routes/_admin/flyway.tsx | 6 + 7 files changed, 1054 insertions(+) create mode 100644 admin/src/apis/flyway/flyway.api.ts create mode 100644 admin/src/apis/flyway/flyway.query.ts create mode 100644 admin/src/pages/flyway/FlywayViewer.tsx create mode 100644 admin/src/pages/flyway/WipRegisterForm.tsx create mode 100644 admin/src/routes/_admin/flyway.tsx diff --git a/admin/src/apis/flyway/flyway.api.ts b/admin/src/apis/flyway/flyway.api.ts new file mode 100644 index 000000000..88a28d1ae --- /dev/null +++ b/admin/src/apis/flyway/flyway.api.ts @@ -0,0 +1,88 @@ +import { fetcher } from '@bombom/shared/apis'; + +export type MigrationStatus = + | 'LOCAL_WIP' + | 'PR_REVIEW' + | 'MERGE_PENDING' + | 'DB_APPLIED'; + +export type WorkKind = 'NEW_TABLE' | 'EXISTING_TABLE'; + +export type ConflictSeverity = 'TABLE' | 'COLUMN'; + +export type MigrationItem = { + version: string; + description: string; + fileName: string; + status: MigrationStatus; + createsNewTable: boolean; + tables: string[]; + sourceLabel: string; + sourceUrl: string; + author: string; +}; + +export type FlywayConflict = { + version: string; + sources: string[]; + suggestedVersion: string; +}; + +export type FlywayLeapfrog = { + mineVersion: string; + aheadVersion: string; + sharedTables: string[]; + severity: ConflictSeverity; +}; + +export type FlywayOverview = { + deployBranch: string; + integrationBranch: string; + latestVersion: string; + appliedCount: number; + pendingCount: number; + nextSafeMinor: string; + nextSafeMajor: string; + migrations: MigrationItem[]; + conflicts: FlywayConflict[]; + leapfrogWarnings: FlywayLeapfrog[]; +}; + +export type MigrationScript = { + fileName: string; + content: string; + sourceUrl: string; +}; + +export type CreateWipIssuePayload = { + workKind: WorkKind; + targetTable?: string; + plannedVersion: string; + description: string; + assignee: string; +}; + +export type CreateWipIssueResponse = { + issueNumber: number; + issueUrl: string; +}; + +export const getFlywayOverview = async () => { + return fetcher.get({ + path: '/flyway/overview', + }); +}; + +export const getMigrationScript = async (fileName: string) => { + return fetcher.get({ + path: '/flyway/script', + query: { fileName }, + }); +}; + +export const createWipIssue = async (payload: CreateWipIssuePayload) => { + return fetcher.post({ + path: '/flyway/wip', + body: payload, + }); +}; diff --git a/admin/src/apis/flyway/flyway.query.ts b/admin/src/apis/flyway/flyway.query.ts new file mode 100644 index 000000000..aa16d13ca --- /dev/null +++ b/admin/src/apis/flyway/flyway.query.ts @@ -0,0 +1,34 @@ +import { queryOptions } from '@tanstack/react-query'; +import { + getFlywayOverview, + getMigrationScript, + createWipIssue, +} from './flyway.api'; + +const OVERVIEW_STALE_TIME = 1000 * 30; // 30s +const SCRIPT_STALE_TIME = 1000 * 60 * 10; // 10m + +export const flywayQueries = { + all: ['flyway'] as const, + + overview: () => + queryOptions({ + queryKey: ['flyway', 'overview'] as const, + queryFn: getFlywayOverview, + staleTime: OVERVIEW_STALE_TIME, + }), + + script: (fileName: string) => + queryOptions({ + queryKey: ['flyway', 'script', fileName] as const, + queryFn: () => getMigrationScript(fileName), + staleTime: SCRIPT_STALE_TIME, + enabled: fileName.length > 0, + }), + + mutation: { + createWip: () => ({ + mutationFn: createWipIssue, + }), + }, +}; diff --git a/admin/src/components/Sidebar.tsx b/admin/src/components/Sidebar.tsx index eebfb5789..fa29515b0 100644 --- a/admin/src/components/Sidebar.tsx +++ b/admin/src/components/Sidebar.tsx @@ -4,6 +4,7 @@ import { FiBell, FiCalendar, FiCode, + FiDatabase, FiEdit, FiFlag, FiHome, @@ -40,6 +41,10 @@ export const Sidebar = () => { 공지사항 + + + Flyway 형상 + = { + LOCAL_WIP: { label: '로컬 작업중', dot: '🟣', order: 0 }, + PR_REVIEW: { label: 'PR 리뷰중', dot: '🟠', order: 1 }, + MERGE_PENDING: { label: 'dev 반영', dot: '🔵', order: 2 }, + DB_APPLIED: { label: 'prod 반영', dot: '🟢', order: 3 }, +}; + +const STATUS_ORDER: MigrationStatus[] = [ + 'LOCAL_WIP', + 'PR_REVIEW', + 'MERGE_PENDING', + 'DB_APPLIED', +]; + +const compareVersionDesc = (left: string, right: string) => { + const leftParts = parseVersion(left); + const rightParts = parseVersion(right); + const size = Math.max(leftParts.length, rightParts.length); + for (let index = 0; index < size; index += 1) { + const diff = (rightParts[index] ?? 0) - (leftParts[index] ?? 0); + if (diff !== 0) { + return diff; + } + } + return 0; +}; + +const parseVersion = (version: string) => + version + .replace(/^V/, '') + .split('.') + .map((token) => Number(token) || 0); + +export const FlywayViewer = () => { + const { data, isLoading, isError } = useQuery(flywayQueries.overview()); + const [keyword, setKeyword] = useState(''); + const [statusFilter, setStatusFilter] = useState<'ALL' | MigrationStatus>( + 'ALL', + ); + const [selectedFileName, setSelectedFileName] = useState(''); + const [showForm, setShowForm] = useState(false); + + const migrations = useMemo(() => data?.migrations ?? [], [data]); + const selected = migrations.find( + (item) => item.fileName === selectedFileName, + ); + const conflictVersions = new Set( + (data?.conflicts ?? []).map((conflict) => conflict.version), + ); + + // version → 가장 심각한 severity (COLUMN > TABLE) + const leapfrogSeverityMap = useMemo(() => { + const map = new Map(); + for (const warning of data?.leapfrogWarnings ?? []) { + const current = map.get(warning.mineVersion); + if (!current || warning.severity === 'COLUMN') { + map.set(warning.mineVersion, warning.severity); + } + } + return map; + }, [data]); + + const selectedLeapfrogs = useMemo( + () => + (data?.leapfrogWarnings ?? []).filter( + (w) => w.mineVersion === selected?.version, + ), + [data, selected], + ); + + const filtered = useMemo( + () => filterMigrations(migrations, keyword, statusFilter), + [migrations, keyword, statusFilter], + ); + + const existingTables = useMemo(() => collectTables(migrations), [migrations]); + + if (isLoading) { + return ( + + 로딩 중... + + ); + } + + if (isError || !data) { + return ( + + 형상 정보를 불러오지 못했습니다. + + ); + } + + return ( + +
+ + 적용기준 {data.deployBranch} · 통합{' '} + {data.integrationBranch} + + + +
+ + {showForm ? ( + setShowForm(false)} + /> + ) : null} + + + {STATUS_ORDER.map((status) => ( + + + {STATUS_META[status].dot} {STATUS_META[status].label} + + {countByStatus(migrations, status)} + + ))} + + + {data.conflicts.length > 0 ? ( + + ⛔ 버전 번호 충돌{' '} + {data.conflicts + .map( + (conflict) => + `${conflict.version} (${conflict.sources.join(', ')}) → ${conflict.suggestedVersion} 권장`, + ) + .join(' · ')} + + ) : null} + + {data.leapfrogWarnings.length > 0 ? ( + + 🔀 순서 역전{' '} + {data.leapfrogWarnings + .map( + (warning) => + `${warning.mineVersion} ↔ ${warning.aheadVersion} (${warning.sharedTables.join(', ')}, ${warning.severity === 'COLUMN' ? '컬럼' : '테이블'})`, + ) + .join(' · ')} + + ) : null} + + + setKeyword(event.target.value)} + /> + + + 다음 안전 {data.nextSafeMinor} /{' '} + {data.nextSafeMajor} + + + + + + {filtered.map((item) => { + const leapfrogSeverity = + leapfrogSeverityMap.get(item.version) ?? null; + return ( + setSelectedFileName(item.fileName)} + > + {item.version} + {item.description} + {leapfrogSeverity ? ( + + {leapfrogSeverity === 'COLUMN' + ? '⚠ 컬럼 역전' + : '🔀 순서 역전'} + + ) : null} + {item.tables.slice(0, 1).map((table) => ( + + {item.createsNewTable ? '🆕' : '🗂'} {table} + + ))} + {item.sourceLabel ? {item.sourceLabel} : null} + + {STATUS_META[item.status].label} + + + ); + })} + {filtered.length === 0 ? ( + 조건에 맞는 마이그레이션이 없습니다. + ) : null} + + + + {selected ? ( + + ) : ( + 왼쪽에서 마이그레이션을 선택하세요. + )} + + +
+ ); +}; + +interface DetailContentProps { + item: MigrationItem; + leapfrogs: FlywayLeapfrog[]; +} + +const DetailContent = ({ item, leapfrogs }: DetailContentProps) => { + const hasFile = + item.status === 'DB_APPLIED' || item.status === 'MERGE_PENDING'; + const { data, isLoading, isError } = useQuery({ + ...flywayQueries.script(hasFile ? item.fileName : ''), + }); + + return ( + <> + + {item.fileName} + + {item.sourceUrl ? ( + + ↗ 열기 + + ) : null} + + + {STATUS_META[item.status].dot} {STATUS_META[item.status].label} + {item.author ? ` · 담당 ${item.author}` : ''} + {item.tables.length > 0 ? ` · ${item.tables.join(', ')}` : ''} + + + {leapfrogs.map((leapfrog) => ( + + + {leapfrog.severity === 'COLUMN' + ? '⚠ 순서 역전 — 같은 컬럼 접근 (강한 경고)' + : '🔀 순서 역전 — 같은 테이블 접근'} + + + 이 버전({leapfrog.mineVersion})보다 높은{' '} + {leapfrog.aheadVersion}이 이미 반영되어 있습니다. + {leapfrog.severity === 'COLUMN' + ? ' out-of-order로 끼워 넣으면 같은 컬럼이 덮어씌워질 수 있습니다.' + : ' out-of-order로 끼워 넣어도 컬럼 충돌은 없지만, 같은 테이블을 다룹니다.'} + + + 공유 {leapfrog.severity === 'COLUMN' ? '컬럼' : '테이블'}:{' '} + {leapfrog.sharedTables.map((name) => ( + + {name} + + ))} + + + ))} + + {hasFile ? ( + + {isLoading ? 스크립트 로딩 중... : null} + {isError ? ( + 스크립트를 불러오지 못했습니다. + ) : null} + {data ? ( + + ) : null} + + ) : ( + + 아직 dev/prod에 없는 작업중 항목입니다. 상세 SQL은{' '} + {item.sourceUrl ? 'PR/이슈' : '소스'}에서 확인하세요. + + )} + + ); +}; + +const filterMigrations = ( + migrations: MigrationItem[], + keyword: string, + statusFilter: 'ALL' | MigrationStatus, +) => { + const normalized = keyword.trim().toLowerCase(); + return migrations + .filter((item) => statusFilter === 'ALL' || item.status === statusFilter) + .filter((item) => matchesKeyword(item, normalized)) + .sort((left, right) => compareVersionDesc(left.version, right.version)); +}; + +const matchesKeyword = (item: MigrationItem, normalized: string) => { + if (normalized.length === 0) { + return true; + } + const haystack = [ + item.version, + item.description, + item.sourceLabel, + item.author, + ...item.tables, + ] + .join(' ') + .toLowerCase(); + return haystack.includes(normalized); +}; + +const countByStatus = (migrations: MigrationItem[], status: MigrationStatus) => + migrations.filter((item) => item.status === status).length; + +const collectTables = (migrations: MigrationItem[]) => { + const tables = new Set(); + migrations.forEach((item) => + item.tables.forEach((table) => tables.add(table)), + ); + return [...tables].sort(); +}; + +const Header = styled.div` + margin-bottom: ${({ theme }) => theme.spacing.md}; + display: flex; + gap: ${({ theme }) => theme.spacing.md}; + align-items: center; +`; + +const Chip = styled.span` + padding: ${({ theme }) => theme.spacing.xs} ${({ theme }) => theme.spacing.md}; + border: 1px solid ${({ theme }) => theme.colors.gray200}; + border-radius: ${({ theme }) => theme.borderRadius.full}; + color: ${({ theme }) => theme.colors.gray600}; + font-size: ${({ theme }) => theme.fontSize.sm}; +`; + +const Spacer = styled.div` + flex: 1; +`; + +const Pipeline = styled.div` + margin: ${({ theme }) => theme.spacing.md} 0; + display: flex; + gap: ${({ theme }) => theme.spacing.md}; +`; + +const Stage = styled.div` + flex: 1; + padding: ${({ theme }) => theme.spacing.md}; + border: 1px solid ${({ theme }) => theme.colors.gray200}; + border-radius: ${({ theme }) => theme.borderRadius.lg}; + background-color: ${({ theme }) => theme.colors.white}; +`; + +const StageTop = styled.div` + font-size: ${({ theme }) => theme.fontSize.sm}; + font-weight: ${({ theme }) => theme.fontWeight.semibold}; +`; + +const StageCount = styled.div` + margin-top: ${({ theme }) => theme.spacing.xs}; + font-size: ${({ theme }) => theme.fontSize['2xl']}; + font-weight: ${({ theme }) => theme.fontWeight.bold}; +`; + +const Banner = styled.div<{ $variant: 'error' | 'warning' }>` + margin-bottom: ${({ theme }) => theme.spacing.md}; + padding: ${({ theme }) => theme.spacing.md}; + border-radius: ${({ theme }) => theme.borderRadius.md}; + font-size: ${({ theme }) => theme.fontSize.sm}; + background-color: ${({ $variant }) => + $variant === 'error' ? '#FDEAEA' : '#FFF4E5'}; + color: ${({ theme, $variant }) => + $variant === 'error' ? theme.colors.error : '#B45309'}; +`; + +const Toolbar = styled.div` + margin-bottom: ${({ theme }) => theme.spacing.md}; + display: flex; + gap: ${({ theme }) => theme.spacing.md}; + align-items: center; +`; + +const SearchInput = styled.input` + flex: 1; + padding: ${({ theme }) => theme.spacing.sm}; + border: 1px solid ${({ theme }) => theme.colors.gray300}; + border-radius: ${({ theme }) => theme.borderRadius.md}; + font-size: ${({ theme }) => theme.fontSize.sm}; +`; + +const Select = styled.select` + padding: ${({ theme }) => theme.spacing.sm}; + border: 1px solid ${({ theme }) => theme.colors.gray300}; + border-radius: ${({ theme }) => theme.borderRadius.md}; + font-size: ${({ theme }) => theme.fontSize.sm}; +`; + +const NextSafe = styled.span` + color: ${({ theme }) => theme.colors.gray500}; + font-size: ${({ theme }) => theme.fontSize.sm}; + + code { + color: ${({ theme }) => theme.colors.primary}; + font-weight: ${({ theme }) => theme.fontWeight.semibold}; + } +`; + +const Panes = styled.div` + display: flex; + gap: ${({ theme }) => theme.spacing.md}; + height: 540px; +`; + +const List = styled.div` + width: 46%; + overflow: auto; + border: 1px solid ${({ theme }) => theme.colors.gray200}; + border-radius: ${({ theme }) => theme.borderRadius.lg}; + background-color: ${({ theme }) => theme.colors.white}; +`; + +const leapfrogBorderColor = (severity: ConflictSeverity | null) => { + if (severity === 'COLUMN') return '#EF4444'; + if (severity === 'TABLE') return '#F59E0B'; + return 'transparent'; +}; + +const leapfrogBackground = ( + severity: ConflictSeverity | null, + active: boolean, +) => { + if (severity === 'COLUMN') return '#FFF5F5'; + if (severity === 'TABLE') return active ? '#FFFBEB' : '#FFFEF5'; + return null; +}; + +const RowButton = styled.button<{ + $active: boolean; + $conflict: boolean; + $leapfrogSeverity: ConflictSeverity | null; +}>` + width: 100%; + padding: ${({ theme }) => theme.spacing.sm} ${({ theme }) => theme.spacing.md}; + border-bottom: 1px solid ${({ theme }) => theme.colors.gray100}; + + display: flex; + gap: ${({ theme }) => theme.spacing.sm}; + align-items: center; + + cursor: pointer; + text-align: left; + background-color: ${({ theme, $active, $conflict, $leapfrogSeverity }) => { + if ($conflict) return '#FDEAEA'; + const leap = leapfrogBackground($leapfrogSeverity, $active); + if (leap) return leap; + return $active ? theme.colors.gray50 : theme.colors.white; + }}; + box-shadow: ${({ theme, $active, $conflict, $leapfrogSeverity }) => + $conflict + ? `inset 3px 0 0 ${theme.colors.error}` + : $leapfrogSeverity + ? `inset 3px 0 0 ${leapfrogBorderColor($leapfrogSeverity)}` + : $active + ? `inset 3px 0 0 ${theme.colors.primary}` + : 'none'}; + + &:hover { + background-color: ${({ theme }) => theme.colors.gray50}; + } +`; + +const Version = styled.span` + min-width: 64px; + font-family: monospace; + font-weight: ${({ theme }) => theme.fontWeight.semibold}; + font-size: ${({ theme }) => theme.fontSize.sm}; +`; + +const Desc = styled.span` + flex: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: ${({ theme }) => theme.colors.gray500}; + font-size: ${({ theme }) => theme.fontSize.sm}; +`; + +const LeapfrogBadge = styled.span<{ $severity: ConflictSeverity }>` + flex-shrink: 0; + padding: 1px ${({ theme }) => theme.spacing.xs}; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + font-size: ${({ theme }) => theme.fontSize.xs}; + font-weight: ${({ theme }) => theme.fontWeight.semibold}; + white-space: nowrap; + color: ${({ $severity }) => ($severity === 'COLUMN' ? '#B91C1C' : '#92400E')}; + background-color: ${({ $severity }) => + $severity === 'COLUMN' ? '#FEE2E2' : '#FEF3C7'}; +`; + +const Tag = styled.span` + padding: 0 ${({ theme }) => theme.spacing.xs}; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + background-color: ${({ theme }) => theme.colors.gray100}; + color: ${({ theme }) => theme.colors.gray600}; + font-size: ${({ theme }) => theme.fontSize.xs}; +`; + +const Source = styled.span` + font-family: monospace; + font-size: ${({ theme }) => theme.fontSize.xs}; + color: ${({ theme }) => theme.colors.gray500}; +`; + +const StatusBadge = styled.span<{ $status: MigrationStatus }>` + padding: 0 ${({ theme }) => theme.spacing.sm}; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + font-size: ${({ theme }) => theme.fontSize.xs}; + font-weight: ${({ theme }) => theme.fontWeight.semibold}; + white-space: nowrap; + color: ${({ theme, $status }) => statusColor(theme, $status)}; + background-color: ${({ $status }) => statusBackground($status)}; +`; + +const statusColor = ( + theme: { colors: Record }, + status: MigrationStatus, +) => { + if (status === 'DB_APPLIED') return theme.colors.success; + if (status === 'PR_REVIEW') return '#B45309'; + if (status === 'MERGE_PENDING') return theme.colors.primary; + return theme.colors.secondary; +}; + +const statusBackground = (status: MigrationStatus) => { + if (status === 'DB_APPLIED') return '#E7F7EF'; + if (status === 'PR_REVIEW') return '#FFF4E5'; + if (status === 'MERGE_PENDING') return '#E8F1FF'; + return '#F0EDFF'; +}; + +const Detail = styled.div` + flex: 1; + overflow: auto; + border: 1px solid ${({ theme }) => theme.colors.gray200}; + border-radius: ${({ theme }) => theme.borderRadius.lg}; + background-color: ${({ theme }) => theme.colors.white}; +`; + +const DetailBar = styled.div` + padding: ${({ theme }) => theme.spacing.md}; + border-bottom: 1px solid ${({ theme }) => theme.colors.gray200}; + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing.md}; +`; + +const FileName = styled.span` + font-family: monospace; + font-size: ${({ theme }) => theme.fontSize.sm}; + font-weight: ${({ theme }) => theme.fontWeight.semibold}; +`; + +const DetailMeta = styled.div` + padding: ${({ theme }) => theme.spacing.md}; + border-bottom: 1px solid ${({ theme }) => theme.colors.gray100}; + color: ${({ theme }) => theme.colors.gray600}; + font-size: ${({ theme }) => theme.fontSize.sm}; +`; + +const LeapfrogWarning = styled.div<{ $severity: ConflictSeverity }>` + margin: ${({ theme }) => theme.spacing.md} ${({ theme }) => theme.spacing.md} + 0; + padding: ${({ theme }) => theme.spacing.md}; + border-radius: ${({ theme }) => theme.borderRadius.md}; + border: 1px solid + ${({ $severity }) => ($severity === 'COLUMN' ? '#FCA5A5' : '#FCD34D')}; + background-color: ${({ $severity }) => + $severity === 'COLUMN' ? '#FFF5F5' : '#FFFBEB'}; +`; + +const LeapfrogWarningTitle = styled.div` + font-size: ${({ theme }) => theme.fontSize.sm}; + font-weight: ${({ theme }) => theme.fontWeight.semibold}; + margin-bottom: ${({ theme }) => theme.spacing.xs}; +`; + +const LeapfrogWarningBody = styled.p` + font-size: ${({ theme }) => theme.fontSize.sm}; + color: ${({ theme }) => theme.colors.gray700}; + margin-bottom: ${({ theme }) => theme.spacing.xs}; + line-height: 1.5; +`; + +const LeapfrogWarningDetail = styled.div` + font-size: ${({ theme }) => theme.fontSize.xs}; + color: ${({ theme }) => theme.colors.gray600}; + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing.xs}; + flex-wrap: wrap; +`; + +const LeapfrogTag = styled.code<{ $severity: ConflictSeverity }>` + padding: 1px 5px; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + background-color: ${({ $severity }) => + $severity === 'COLUMN' ? '#FEE2E2' : '#FEF3C7'}; + font-size: ${({ theme }) => theme.fontSize.xs}; +`; + +const ScriptArea = styled.div` + padding: ${({ theme }) => theme.spacing.sm}; +`; + +const PendingNote = styled.p` + padding: ${({ theme }) => theme.spacing.lg}; + color: ${({ theme }) => theme.colors.gray500}; + font-size: ${({ theme }) => theme.fontSize.sm}; +`; + +const StateText = styled.p` + padding: ${({ theme }) => theme.spacing.lg}; + color: ${({ theme }) => theme.colors.gray500}; +`; + +const Empty = styled.p` + padding: ${({ theme }) => theme.spacing.lg}; + color: ${({ theme }) => theme.colors.gray400}; + font-size: ${({ theme }) => theme.fontSize.sm}; +`; diff --git a/admin/src/pages/flyway/WipRegisterForm.tsx b/admin/src/pages/flyway/WipRegisterForm.tsx new file mode 100644 index 000000000..4c9ecb2c1 --- /dev/null +++ b/admin/src/pages/flyway/WipRegisterForm.tsx @@ -0,0 +1,223 @@ +import styled from '@emotion/styled'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import { flywayQueries } from '@/apis/flyway/flyway.query'; +import { Button } from '@/components/Button'; +import type { WorkKind } from '@/apis/flyway/flyway.api'; + +interface WipRegisterFormProps { + existingTables: string[]; + defaultVersion: string; + onClose: () => void; +} + +export const WipRegisterForm = ({ + existingTables, + defaultVersion, + onClose, +}: WipRegisterFormProps) => { + const queryClient = useQueryClient(); + const [workKind, setWorkKind] = useState('EXISTING_TABLE'); + const [targetTable, setTargetTable] = useState(''); + const [plannedVersion, setPlannedVersion] = useState(defaultVersion); + const [description, setDescription] = useState(''); + const [assignee, setAssignee] = useState(''); + + const { mutate, isPending, isError, error, data } = useMutation({ + ...flywayQueries.mutation.createWip(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: flywayQueries.all }); + }, + }); + + const isExistingTable = workKind === 'EXISTING_TABLE'; + const canSubmit = + plannedVersion.trim().length > 0 && + description.trim().length > 0 && + assignee.trim().length > 0 && + (isExistingTable === false || targetTable.trim().length > 0); + + const handleSubmit = () => { + mutate({ + workKind, + targetTable: isExistingTable ? targetTable.trim() : undefined, + plannedVersion: plannedVersion.trim(), + description: description.trim(), + assignee: assignee.trim(), + }); + }; + + return ( + + + 작업중 등록 + 새 마이그레이션을 미리 예약해 번호 충돌을 막아요 + + ✕ + + + + + + + + setWorkKind('EXISTING_TABLE')} + > + 기존 테이블 + + setWorkKind('NEW_TABLE')} + > + 새로운 테이블 + + + + + {isExistingTable ? ( + + + setTargetTable(event.target.value)} + /> + + {existingTables.map((table) => ( + + + ) : null} + + + + setPlannedVersion(event.target.value)} + /> + + + + setDescription(event.target.value)} + /> + + + + setAssignee(event.target.value)} + /> + + + + + {isError ? ( + + 등록 실패: {(error as Error)?.message ?? '알 수 없는 오류'} + + ) : null} + {data ? ( + + 이슈 #{data.issueNumber} 생성됨 —{' '} + + GitHub에서 보기 + + + ) : null} + + ); +}; + +const Panel = styled.div` + padding: ${({ theme }) => theme.spacing.lg}; + border: 1px solid ${({ theme }) => theme.colors.warning}; + border-radius: ${({ theme }) => theme.borderRadius.lg}; + background-color: #fffdf7; +`; + +const PanelHeader = styled.div` + margin-bottom: ${({ theme }) => theme.spacing.md}; + display: flex; + gap: ${({ theme }) => theme.spacing.md}; + align-items: center; + + span { + color: ${({ theme }) => theme.colors.gray500}; + font-size: ${({ theme }) => theme.fontSize.sm}; + } +`; + +const CloseButton = styled.button` + margin-left: auto; + color: ${({ theme }) => theme.colors.gray500}; + cursor: pointer; +`; + +const Row = styled.div` + display: flex; + gap: ${({ theme }) => theme.spacing.md}; + align-items: flex-end; + flex-wrap: wrap; +`; + +const Field = styled.div<{ $grow?: boolean }>` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing.xs}; + flex: ${({ $grow }) => ($grow ? 1 : 'none')}; + min-width: 140px; +`; + +const Label = styled.span` + color: ${({ theme }) => theme.colors.gray500}; + font-size: ${({ theme }) => theme.fontSize.xs}; + font-weight: ${({ theme }) => theme.fontWeight.semibold}; +`; + +const Input = styled.input` + padding: ${({ theme }) => theme.spacing.sm}; + border: 1px solid ${({ theme }) => theme.colors.gray300}; + border-radius: ${({ theme }) => theme.borderRadius.md}; + font-size: ${({ theme }) => theme.fontSize.sm}; +`; + +const Segment = styled.div` + display: inline-flex; + border: 1px solid ${({ theme }) => theme.colors.gray300}; + border-radius: ${({ theme }) => theme.borderRadius.md}; + overflow: hidden; +`; + +const SegmentButton = styled.button<{ $active: boolean }>` + padding: ${({ theme }) => theme.spacing.sm} ${({ theme }) => theme.spacing.md}; + font-size: ${({ theme }) => theme.fontSize.sm}; + cursor: pointer; + background-color: ${({ theme, $active }) => + $active ? theme.colors.primary : theme.colors.white}; + color: ${({ theme, $active }) => + $active ? theme.colors.white : theme.colors.gray600}; +`; + +const Notice = styled.p<{ $error?: boolean }>` + margin-top: ${({ theme }) => theme.spacing.md}; + font-size: ${({ theme }) => theme.fontSize.sm}; + color: ${({ theme, $error }) => + $error ? theme.colors.error : theme.colors.success}; +`; diff --git a/admin/src/routeTree.gen.ts b/admin/src/routeTree.gen.ts index cdf9b4478..c9dbe19bf 100644 --- a/admin/src/routeTree.gen.ts +++ b/admin/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as AdminIndexRouteImport } from './routes/_admin/index'; import { Route as AdminResourcesRouteImport } from './routes/_admin/resources'; import { Route as AdminNoticesRouteImport } from './routes/_admin/notices'; import { Route as AdminMembersRouteImport } from './routes/_admin/members'; +import { Route as AdminFlywayRouteImport } from './routes/_admin/flyway'; import { Route as AdminEventsRouteImport } from './routes/_admin/events'; import { Route as AdminChallengesRouteImport } from './routes/_admin/challenges'; import { Route as AdminBlogRouteImport } from './routes/_admin/blog'; @@ -77,6 +78,11 @@ const AdminMembersRoute = AdminMembersRouteImport.update({ path: '/members', getParentRoute: () => AdminRoute, } as any); +const AdminFlywayRoute = AdminFlywayRouteImport.update({ + id: '/flyway', + path: '/flyway', + getParentRoute: () => AdminRoute, +} as any); const AdminEventsRoute = AdminEventsRouteImport.update({ id: '/events', path: '/events', @@ -261,6 +267,7 @@ export interface FileRoutesByFullPath { '/blog': typeof AdminBlogRouteWithChildren; '/challenges': typeof AdminChallengesRouteWithChildren; '/events': typeof AdminEventsRouteWithChildren; + '/flyway': typeof AdminFlywayRoute; '/members': typeof AdminMembersRoute; '/notices': typeof AdminNoticesRouteWithChildren; '/resources': typeof AdminResourcesRouteWithChildren; @@ -297,6 +304,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/403': typeof R403Route; + '/flyway': typeof AdminFlywayRoute; '/members': typeof AdminMembersRoute; '/': typeof AdminIndexRoute; '/blog/$postId': typeof AdminBlogPostIdRoute; @@ -331,6 +339,7 @@ export interface FileRoutesById { '/_admin/blog': typeof AdminBlogRouteWithChildren; '/_admin/challenges': typeof AdminChallengesRouteWithChildren; '/_admin/events': typeof AdminEventsRouteWithChildren; + '/_admin/flyway': typeof AdminFlywayRoute; '/_admin/members': typeof AdminMembersRoute; '/_admin/notices': typeof AdminNoticesRouteWithChildren; '/_admin/resources': typeof AdminResourcesRouteWithChildren; @@ -372,6 +381,7 @@ export interface FileRouteTypes { | '/blog' | '/challenges' | '/events' + | '/flyway' | '/members' | '/notices' | '/resources' @@ -408,6 +418,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo; to: | '/403' + | '/flyway' | '/members' | '/' | '/blog/$postId' @@ -441,6 +452,7 @@ export interface FileRouteTypes { | '/_admin/blog' | '/_admin/challenges' | '/_admin/events' + | '/_admin/flyway' | '/_admin/members' | '/_admin/notices' | '/_admin/resources' @@ -525,6 +537,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminMembersRouteImport; parentRoute: typeof AdminRoute; }; + '/_admin/flyway': { + id: '/_admin/flyway'; + path: '/flyway'; + fullPath: '/flyway'; + preLoaderRoute: typeof AdminFlywayRouteImport; + parentRoute: typeof AdminRoute; + }; '/_admin/events': { id: '/_admin/events'; path: '/events'; @@ -922,6 +941,7 @@ interface AdminRouteChildren { AdminBlogRoute: typeof AdminBlogRouteWithChildren; AdminChallengesRoute: typeof AdminChallengesRouteWithChildren; AdminEventsRoute: typeof AdminEventsRouteWithChildren; + AdminFlywayRoute: typeof AdminFlywayRoute; AdminMembersRoute: typeof AdminMembersRoute; AdminNoticesRoute: typeof AdminNoticesRouteWithChildren; AdminResourcesRoute: typeof AdminResourcesRouteWithChildren; @@ -936,6 +956,7 @@ const AdminRouteChildren: AdminRouteChildren = { AdminBlogRoute: AdminBlogRouteWithChildren, AdminChallengesRoute: AdminChallengesRouteWithChildren, AdminEventsRoute: AdminEventsRouteWithChildren, + AdminFlywayRoute: AdminFlywayRoute, AdminMembersRoute: AdminMembersRoute, AdminNoticesRoute: AdminNoticesRouteWithChildren, AdminResourcesRoute: AdminResourcesRouteWithChildren, diff --git a/admin/src/routes/_admin/flyway.tsx b/admin/src/routes/_admin/flyway.tsx new file mode 100644 index 000000000..3ae9399ab --- /dev/null +++ b/admin/src/routes/_admin/flyway.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { FlywayViewer } from '@/pages/flyway/FlywayViewer'; + +export const Route = createFileRoute('/_admin/flyway')({ + component: FlywayViewer, +}); From 5e0b351521a13b0e66ece920d0e45d40df990440 Mon Sep 17 00:00:00 2001 From: Cheol Won <76567238+Ryan-Dia@users.noreply.github.com> Date: Tue, 16 Jun 2026 09:50:17 +0900 Subject: [PATCH 14/17] =?UTF-8?q?Revert=20"feat:=20Flyway=20=EB=B7=B0?= =?UTF-8?q?=EC=96=B4=20UI=20=EA=B0=9C=EC=84=A0=20=E2=80=94=20=EC=88=9C?= =?UTF-8?q?=EC=84=9C=EC=97=AD=EC=A0=84=20=ED=91=9C=EC=8B=9C=20/=20dev?= =?UTF-8?q?=C2=B7prod=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20(#?= =?UTF-8?q?255)"=20(#257)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 4164e5c631267078ed7bf208ddfba7d58fa98e50. --- admin/src/apis/flyway/flyway.api.ts | 88 --- admin/src/apis/flyway/flyway.query.ts | 34 -- admin/src/components/Sidebar.tsx | 5 - admin/src/pages/flyway/FlywayViewer.tsx | 677 --------------------- admin/src/pages/flyway/WipRegisterForm.tsx | 223 ------- admin/src/routeTree.gen.ts | 21 - admin/src/routes/_admin/flyway.tsx | 6 - 7 files changed, 1054 deletions(-) delete mode 100644 admin/src/apis/flyway/flyway.api.ts delete mode 100644 admin/src/apis/flyway/flyway.query.ts delete mode 100644 admin/src/pages/flyway/FlywayViewer.tsx delete mode 100644 admin/src/pages/flyway/WipRegisterForm.tsx delete mode 100644 admin/src/routes/_admin/flyway.tsx diff --git a/admin/src/apis/flyway/flyway.api.ts b/admin/src/apis/flyway/flyway.api.ts deleted file mode 100644 index 88a28d1ae..000000000 --- a/admin/src/apis/flyway/flyway.api.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { fetcher } from '@bombom/shared/apis'; - -export type MigrationStatus = - | 'LOCAL_WIP' - | 'PR_REVIEW' - | 'MERGE_PENDING' - | 'DB_APPLIED'; - -export type WorkKind = 'NEW_TABLE' | 'EXISTING_TABLE'; - -export type ConflictSeverity = 'TABLE' | 'COLUMN'; - -export type MigrationItem = { - version: string; - description: string; - fileName: string; - status: MigrationStatus; - createsNewTable: boolean; - tables: string[]; - sourceLabel: string; - sourceUrl: string; - author: string; -}; - -export type FlywayConflict = { - version: string; - sources: string[]; - suggestedVersion: string; -}; - -export type FlywayLeapfrog = { - mineVersion: string; - aheadVersion: string; - sharedTables: string[]; - severity: ConflictSeverity; -}; - -export type FlywayOverview = { - deployBranch: string; - integrationBranch: string; - latestVersion: string; - appliedCount: number; - pendingCount: number; - nextSafeMinor: string; - nextSafeMajor: string; - migrations: MigrationItem[]; - conflicts: FlywayConflict[]; - leapfrogWarnings: FlywayLeapfrog[]; -}; - -export type MigrationScript = { - fileName: string; - content: string; - sourceUrl: string; -}; - -export type CreateWipIssuePayload = { - workKind: WorkKind; - targetTable?: string; - plannedVersion: string; - description: string; - assignee: string; -}; - -export type CreateWipIssueResponse = { - issueNumber: number; - issueUrl: string; -}; - -export const getFlywayOverview = async () => { - return fetcher.get({ - path: '/flyway/overview', - }); -}; - -export const getMigrationScript = async (fileName: string) => { - return fetcher.get({ - path: '/flyway/script', - query: { fileName }, - }); -}; - -export const createWipIssue = async (payload: CreateWipIssuePayload) => { - return fetcher.post({ - path: '/flyway/wip', - body: payload, - }); -}; diff --git a/admin/src/apis/flyway/flyway.query.ts b/admin/src/apis/flyway/flyway.query.ts deleted file mode 100644 index aa16d13ca..000000000 --- a/admin/src/apis/flyway/flyway.query.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { queryOptions } from '@tanstack/react-query'; -import { - getFlywayOverview, - getMigrationScript, - createWipIssue, -} from './flyway.api'; - -const OVERVIEW_STALE_TIME = 1000 * 30; // 30s -const SCRIPT_STALE_TIME = 1000 * 60 * 10; // 10m - -export const flywayQueries = { - all: ['flyway'] as const, - - overview: () => - queryOptions({ - queryKey: ['flyway', 'overview'] as const, - queryFn: getFlywayOverview, - staleTime: OVERVIEW_STALE_TIME, - }), - - script: (fileName: string) => - queryOptions({ - queryKey: ['flyway', 'script', fileName] as const, - queryFn: () => getMigrationScript(fileName), - staleTime: SCRIPT_STALE_TIME, - enabled: fileName.length > 0, - }), - - mutation: { - createWip: () => ({ - mutationFn: createWipIssue, - }), - }, -}; diff --git a/admin/src/components/Sidebar.tsx b/admin/src/components/Sidebar.tsx index fa29515b0..eebfb5789 100644 --- a/admin/src/components/Sidebar.tsx +++ b/admin/src/components/Sidebar.tsx @@ -4,7 +4,6 @@ import { FiBell, FiCalendar, FiCode, - FiDatabase, FiEdit, FiFlag, FiHome, @@ -41,10 +40,6 @@ export const Sidebar = () => { 공지사항
- - - Flyway 형상 - = { - LOCAL_WIP: { label: '로컬 작업중', dot: '🟣', order: 0 }, - PR_REVIEW: { label: 'PR 리뷰중', dot: '🟠', order: 1 }, - MERGE_PENDING: { label: 'dev 반영', dot: '🔵', order: 2 }, - DB_APPLIED: { label: 'prod 반영', dot: '🟢', order: 3 }, -}; - -const STATUS_ORDER: MigrationStatus[] = [ - 'LOCAL_WIP', - 'PR_REVIEW', - 'MERGE_PENDING', - 'DB_APPLIED', -]; - -const compareVersionDesc = (left: string, right: string) => { - const leftParts = parseVersion(left); - const rightParts = parseVersion(right); - const size = Math.max(leftParts.length, rightParts.length); - for (let index = 0; index < size; index += 1) { - const diff = (rightParts[index] ?? 0) - (leftParts[index] ?? 0); - if (diff !== 0) { - return diff; - } - } - return 0; -}; - -const parseVersion = (version: string) => - version - .replace(/^V/, '') - .split('.') - .map((token) => Number(token) || 0); - -export const FlywayViewer = () => { - const { data, isLoading, isError } = useQuery(flywayQueries.overview()); - const [keyword, setKeyword] = useState(''); - const [statusFilter, setStatusFilter] = useState<'ALL' | MigrationStatus>( - 'ALL', - ); - const [selectedFileName, setSelectedFileName] = useState(''); - const [showForm, setShowForm] = useState(false); - - const migrations = useMemo(() => data?.migrations ?? [], [data]); - const selected = migrations.find( - (item) => item.fileName === selectedFileName, - ); - const conflictVersions = new Set( - (data?.conflicts ?? []).map((conflict) => conflict.version), - ); - - // version → 가장 심각한 severity (COLUMN > TABLE) - const leapfrogSeverityMap = useMemo(() => { - const map = new Map(); - for (const warning of data?.leapfrogWarnings ?? []) { - const current = map.get(warning.mineVersion); - if (!current || warning.severity === 'COLUMN') { - map.set(warning.mineVersion, warning.severity); - } - } - return map; - }, [data]); - - const selectedLeapfrogs = useMemo( - () => - (data?.leapfrogWarnings ?? []).filter( - (w) => w.mineVersion === selected?.version, - ), - [data, selected], - ); - - const filtered = useMemo( - () => filterMigrations(migrations, keyword, statusFilter), - [migrations, keyword, statusFilter], - ); - - const existingTables = useMemo(() => collectTables(migrations), [migrations]); - - if (isLoading) { - return ( - - 로딩 중... - - ); - } - - if (isError || !data) { - return ( - - 형상 정보를 불러오지 못했습니다. - - ); - } - - return ( - -
- - 적용기준 {data.deployBranch} · 통합{' '} - {data.integrationBranch} - - - -
- - {showForm ? ( - setShowForm(false)} - /> - ) : null} - - - {STATUS_ORDER.map((status) => ( - - - {STATUS_META[status].dot} {STATUS_META[status].label} - - {countByStatus(migrations, status)} - - ))} - - - {data.conflicts.length > 0 ? ( - - ⛔ 버전 번호 충돌{' '} - {data.conflicts - .map( - (conflict) => - `${conflict.version} (${conflict.sources.join(', ')}) → ${conflict.suggestedVersion} 권장`, - ) - .join(' · ')} - - ) : null} - - {data.leapfrogWarnings.length > 0 ? ( - - 🔀 순서 역전{' '} - {data.leapfrogWarnings - .map( - (warning) => - `${warning.mineVersion} ↔ ${warning.aheadVersion} (${warning.sharedTables.join(', ')}, ${warning.severity === 'COLUMN' ? '컬럼' : '테이블'})`, - ) - .join(' · ')} - - ) : null} - - - setKeyword(event.target.value)} - /> - - - 다음 안전 {data.nextSafeMinor} /{' '} - {data.nextSafeMajor} - - - - - - {filtered.map((item) => { - const leapfrogSeverity = - leapfrogSeverityMap.get(item.version) ?? null; - return ( - setSelectedFileName(item.fileName)} - > - {item.version} - {item.description} - {leapfrogSeverity ? ( - - {leapfrogSeverity === 'COLUMN' - ? '⚠ 컬럼 역전' - : '🔀 순서 역전'} - - ) : null} - {item.tables.slice(0, 1).map((table) => ( - - {item.createsNewTable ? '🆕' : '🗂'} {table} - - ))} - {item.sourceLabel ? {item.sourceLabel} : null} - - {STATUS_META[item.status].label} - - - ); - })} - {filtered.length === 0 ? ( - 조건에 맞는 마이그레이션이 없습니다. - ) : null} - - - - {selected ? ( - - ) : ( - 왼쪽에서 마이그레이션을 선택하세요. - )} - - -
- ); -}; - -interface DetailContentProps { - item: MigrationItem; - leapfrogs: FlywayLeapfrog[]; -} - -const DetailContent = ({ item, leapfrogs }: DetailContentProps) => { - const hasFile = - item.status === 'DB_APPLIED' || item.status === 'MERGE_PENDING'; - const { data, isLoading, isError } = useQuery({ - ...flywayQueries.script(hasFile ? item.fileName : ''), - }); - - return ( - <> - - {item.fileName} - - {item.sourceUrl ? ( - - ↗ 열기 - - ) : null} - - - {STATUS_META[item.status].dot} {STATUS_META[item.status].label} - {item.author ? ` · 담당 ${item.author}` : ''} - {item.tables.length > 0 ? ` · ${item.tables.join(', ')}` : ''} - - - {leapfrogs.map((leapfrog) => ( - - - {leapfrog.severity === 'COLUMN' - ? '⚠ 순서 역전 — 같은 컬럼 접근 (강한 경고)' - : '🔀 순서 역전 — 같은 테이블 접근'} - - - 이 버전({leapfrog.mineVersion})보다 높은{' '} - {leapfrog.aheadVersion}이 이미 반영되어 있습니다. - {leapfrog.severity === 'COLUMN' - ? ' out-of-order로 끼워 넣으면 같은 컬럼이 덮어씌워질 수 있습니다.' - : ' out-of-order로 끼워 넣어도 컬럼 충돌은 없지만, 같은 테이블을 다룹니다.'} - - - 공유 {leapfrog.severity === 'COLUMN' ? '컬럼' : '테이블'}:{' '} - {leapfrog.sharedTables.map((name) => ( - - {name} - - ))} - - - ))} - - {hasFile ? ( - - {isLoading ? 스크립트 로딩 중... : null} - {isError ? ( - 스크립트를 불러오지 못했습니다. - ) : null} - {data ? ( - - ) : null} - - ) : ( - - 아직 dev/prod에 없는 작업중 항목입니다. 상세 SQL은{' '} - {item.sourceUrl ? 'PR/이슈' : '소스'}에서 확인하세요. - - )} - - ); -}; - -const filterMigrations = ( - migrations: MigrationItem[], - keyword: string, - statusFilter: 'ALL' | MigrationStatus, -) => { - const normalized = keyword.trim().toLowerCase(); - return migrations - .filter((item) => statusFilter === 'ALL' || item.status === statusFilter) - .filter((item) => matchesKeyword(item, normalized)) - .sort((left, right) => compareVersionDesc(left.version, right.version)); -}; - -const matchesKeyword = (item: MigrationItem, normalized: string) => { - if (normalized.length === 0) { - return true; - } - const haystack = [ - item.version, - item.description, - item.sourceLabel, - item.author, - ...item.tables, - ] - .join(' ') - .toLowerCase(); - return haystack.includes(normalized); -}; - -const countByStatus = (migrations: MigrationItem[], status: MigrationStatus) => - migrations.filter((item) => item.status === status).length; - -const collectTables = (migrations: MigrationItem[]) => { - const tables = new Set(); - migrations.forEach((item) => - item.tables.forEach((table) => tables.add(table)), - ); - return [...tables].sort(); -}; - -const Header = styled.div` - margin-bottom: ${({ theme }) => theme.spacing.md}; - display: flex; - gap: ${({ theme }) => theme.spacing.md}; - align-items: center; -`; - -const Chip = styled.span` - padding: ${({ theme }) => theme.spacing.xs} ${({ theme }) => theme.spacing.md}; - border: 1px solid ${({ theme }) => theme.colors.gray200}; - border-radius: ${({ theme }) => theme.borderRadius.full}; - color: ${({ theme }) => theme.colors.gray600}; - font-size: ${({ theme }) => theme.fontSize.sm}; -`; - -const Spacer = styled.div` - flex: 1; -`; - -const Pipeline = styled.div` - margin: ${({ theme }) => theme.spacing.md} 0; - display: flex; - gap: ${({ theme }) => theme.spacing.md}; -`; - -const Stage = styled.div` - flex: 1; - padding: ${({ theme }) => theme.spacing.md}; - border: 1px solid ${({ theme }) => theme.colors.gray200}; - border-radius: ${({ theme }) => theme.borderRadius.lg}; - background-color: ${({ theme }) => theme.colors.white}; -`; - -const StageTop = styled.div` - font-size: ${({ theme }) => theme.fontSize.sm}; - font-weight: ${({ theme }) => theme.fontWeight.semibold}; -`; - -const StageCount = styled.div` - margin-top: ${({ theme }) => theme.spacing.xs}; - font-size: ${({ theme }) => theme.fontSize['2xl']}; - font-weight: ${({ theme }) => theme.fontWeight.bold}; -`; - -const Banner = styled.div<{ $variant: 'error' | 'warning' }>` - margin-bottom: ${({ theme }) => theme.spacing.md}; - padding: ${({ theme }) => theme.spacing.md}; - border-radius: ${({ theme }) => theme.borderRadius.md}; - font-size: ${({ theme }) => theme.fontSize.sm}; - background-color: ${({ $variant }) => - $variant === 'error' ? '#FDEAEA' : '#FFF4E5'}; - color: ${({ theme, $variant }) => - $variant === 'error' ? theme.colors.error : '#B45309'}; -`; - -const Toolbar = styled.div` - margin-bottom: ${({ theme }) => theme.spacing.md}; - display: flex; - gap: ${({ theme }) => theme.spacing.md}; - align-items: center; -`; - -const SearchInput = styled.input` - flex: 1; - padding: ${({ theme }) => theme.spacing.sm}; - border: 1px solid ${({ theme }) => theme.colors.gray300}; - border-radius: ${({ theme }) => theme.borderRadius.md}; - font-size: ${({ theme }) => theme.fontSize.sm}; -`; - -const Select = styled.select` - padding: ${({ theme }) => theme.spacing.sm}; - border: 1px solid ${({ theme }) => theme.colors.gray300}; - border-radius: ${({ theme }) => theme.borderRadius.md}; - font-size: ${({ theme }) => theme.fontSize.sm}; -`; - -const NextSafe = styled.span` - color: ${({ theme }) => theme.colors.gray500}; - font-size: ${({ theme }) => theme.fontSize.sm}; - - code { - color: ${({ theme }) => theme.colors.primary}; - font-weight: ${({ theme }) => theme.fontWeight.semibold}; - } -`; - -const Panes = styled.div` - display: flex; - gap: ${({ theme }) => theme.spacing.md}; - height: 540px; -`; - -const List = styled.div` - width: 46%; - overflow: auto; - border: 1px solid ${({ theme }) => theme.colors.gray200}; - border-radius: ${({ theme }) => theme.borderRadius.lg}; - background-color: ${({ theme }) => theme.colors.white}; -`; - -const leapfrogBorderColor = (severity: ConflictSeverity | null) => { - if (severity === 'COLUMN') return '#EF4444'; - if (severity === 'TABLE') return '#F59E0B'; - return 'transparent'; -}; - -const leapfrogBackground = ( - severity: ConflictSeverity | null, - active: boolean, -) => { - if (severity === 'COLUMN') return '#FFF5F5'; - if (severity === 'TABLE') return active ? '#FFFBEB' : '#FFFEF5'; - return null; -}; - -const RowButton = styled.button<{ - $active: boolean; - $conflict: boolean; - $leapfrogSeverity: ConflictSeverity | null; -}>` - width: 100%; - padding: ${({ theme }) => theme.spacing.sm} ${({ theme }) => theme.spacing.md}; - border-bottom: 1px solid ${({ theme }) => theme.colors.gray100}; - - display: flex; - gap: ${({ theme }) => theme.spacing.sm}; - align-items: center; - - cursor: pointer; - text-align: left; - background-color: ${({ theme, $active, $conflict, $leapfrogSeverity }) => { - if ($conflict) return '#FDEAEA'; - const leap = leapfrogBackground($leapfrogSeverity, $active); - if (leap) return leap; - return $active ? theme.colors.gray50 : theme.colors.white; - }}; - box-shadow: ${({ theme, $active, $conflict, $leapfrogSeverity }) => - $conflict - ? `inset 3px 0 0 ${theme.colors.error}` - : $leapfrogSeverity - ? `inset 3px 0 0 ${leapfrogBorderColor($leapfrogSeverity)}` - : $active - ? `inset 3px 0 0 ${theme.colors.primary}` - : 'none'}; - - &:hover { - background-color: ${({ theme }) => theme.colors.gray50}; - } -`; - -const Version = styled.span` - min-width: 64px; - font-family: monospace; - font-weight: ${({ theme }) => theme.fontWeight.semibold}; - font-size: ${({ theme }) => theme.fontSize.sm}; -`; - -const Desc = styled.span` - flex: 1; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - color: ${({ theme }) => theme.colors.gray500}; - font-size: ${({ theme }) => theme.fontSize.sm}; -`; - -const LeapfrogBadge = styled.span<{ $severity: ConflictSeverity }>` - flex-shrink: 0; - padding: 1px ${({ theme }) => theme.spacing.xs}; - border-radius: ${({ theme }) => theme.borderRadius.sm}; - font-size: ${({ theme }) => theme.fontSize.xs}; - font-weight: ${({ theme }) => theme.fontWeight.semibold}; - white-space: nowrap; - color: ${({ $severity }) => ($severity === 'COLUMN' ? '#B91C1C' : '#92400E')}; - background-color: ${({ $severity }) => - $severity === 'COLUMN' ? '#FEE2E2' : '#FEF3C7'}; -`; - -const Tag = styled.span` - padding: 0 ${({ theme }) => theme.spacing.xs}; - border-radius: ${({ theme }) => theme.borderRadius.sm}; - background-color: ${({ theme }) => theme.colors.gray100}; - color: ${({ theme }) => theme.colors.gray600}; - font-size: ${({ theme }) => theme.fontSize.xs}; -`; - -const Source = styled.span` - font-family: monospace; - font-size: ${({ theme }) => theme.fontSize.xs}; - color: ${({ theme }) => theme.colors.gray500}; -`; - -const StatusBadge = styled.span<{ $status: MigrationStatus }>` - padding: 0 ${({ theme }) => theme.spacing.sm}; - border-radius: ${({ theme }) => theme.borderRadius.sm}; - font-size: ${({ theme }) => theme.fontSize.xs}; - font-weight: ${({ theme }) => theme.fontWeight.semibold}; - white-space: nowrap; - color: ${({ theme, $status }) => statusColor(theme, $status)}; - background-color: ${({ $status }) => statusBackground($status)}; -`; - -const statusColor = ( - theme: { colors: Record }, - status: MigrationStatus, -) => { - if (status === 'DB_APPLIED') return theme.colors.success; - if (status === 'PR_REVIEW') return '#B45309'; - if (status === 'MERGE_PENDING') return theme.colors.primary; - return theme.colors.secondary; -}; - -const statusBackground = (status: MigrationStatus) => { - if (status === 'DB_APPLIED') return '#E7F7EF'; - if (status === 'PR_REVIEW') return '#FFF4E5'; - if (status === 'MERGE_PENDING') return '#E8F1FF'; - return '#F0EDFF'; -}; - -const Detail = styled.div` - flex: 1; - overflow: auto; - border: 1px solid ${({ theme }) => theme.colors.gray200}; - border-radius: ${({ theme }) => theme.borderRadius.lg}; - background-color: ${({ theme }) => theme.colors.white}; -`; - -const DetailBar = styled.div` - padding: ${({ theme }) => theme.spacing.md}; - border-bottom: 1px solid ${({ theme }) => theme.colors.gray200}; - display: flex; - align-items: center; - gap: ${({ theme }) => theme.spacing.md}; -`; - -const FileName = styled.span` - font-family: monospace; - font-size: ${({ theme }) => theme.fontSize.sm}; - font-weight: ${({ theme }) => theme.fontWeight.semibold}; -`; - -const DetailMeta = styled.div` - padding: ${({ theme }) => theme.spacing.md}; - border-bottom: 1px solid ${({ theme }) => theme.colors.gray100}; - color: ${({ theme }) => theme.colors.gray600}; - font-size: ${({ theme }) => theme.fontSize.sm}; -`; - -const LeapfrogWarning = styled.div<{ $severity: ConflictSeverity }>` - margin: ${({ theme }) => theme.spacing.md} ${({ theme }) => theme.spacing.md} - 0; - padding: ${({ theme }) => theme.spacing.md}; - border-radius: ${({ theme }) => theme.borderRadius.md}; - border: 1px solid - ${({ $severity }) => ($severity === 'COLUMN' ? '#FCA5A5' : '#FCD34D')}; - background-color: ${({ $severity }) => - $severity === 'COLUMN' ? '#FFF5F5' : '#FFFBEB'}; -`; - -const LeapfrogWarningTitle = styled.div` - font-size: ${({ theme }) => theme.fontSize.sm}; - font-weight: ${({ theme }) => theme.fontWeight.semibold}; - margin-bottom: ${({ theme }) => theme.spacing.xs}; -`; - -const LeapfrogWarningBody = styled.p` - font-size: ${({ theme }) => theme.fontSize.sm}; - color: ${({ theme }) => theme.colors.gray700}; - margin-bottom: ${({ theme }) => theme.spacing.xs}; - line-height: 1.5; -`; - -const LeapfrogWarningDetail = styled.div` - font-size: ${({ theme }) => theme.fontSize.xs}; - color: ${({ theme }) => theme.colors.gray600}; - display: flex; - align-items: center; - gap: ${({ theme }) => theme.spacing.xs}; - flex-wrap: wrap; -`; - -const LeapfrogTag = styled.code<{ $severity: ConflictSeverity }>` - padding: 1px 5px; - border-radius: ${({ theme }) => theme.borderRadius.sm}; - background-color: ${({ $severity }) => - $severity === 'COLUMN' ? '#FEE2E2' : '#FEF3C7'}; - font-size: ${({ theme }) => theme.fontSize.xs}; -`; - -const ScriptArea = styled.div` - padding: ${({ theme }) => theme.spacing.sm}; -`; - -const PendingNote = styled.p` - padding: ${({ theme }) => theme.spacing.lg}; - color: ${({ theme }) => theme.colors.gray500}; - font-size: ${({ theme }) => theme.fontSize.sm}; -`; - -const StateText = styled.p` - padding: ${({ theme }) => theme.spacing.lg}; - color: ${({ theme }) => theme.colors.gray500}; -`; - -const Empty = styled.p` - padding: ${({ theme }) => theme.spacing.lg}; - color: ${({ theme }) => theme.colors.gray400}; - font-size: ${({ theme }) => theme.fontSize.sm}; -`; diff --git a/admin/src/pages/flyway/WipRegisterForm.tsx b/admin/src/pages/flyway/WipRegisterForm.tsx deleted file mode 100644 index 4c9ecb2c1..000000000 --- a/admin/src/pages/flyway/WipRegisterForm.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import styled from '@emotion/styled'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useState } from 'react'; -import { flywayQueries } from '@/apis/flyway/flyway.query'; -import { Button } from '@/components/Button'; -import type { WorkKind } from '@/apis/flyway/flyway.api'; - -interface WipRegisterFormProps { - existingTables: string[]; - defaultVersion: string; - onClose: () => void; -} - -export const WipRegisterForm = ({ - existingTables, - defaultVersion, - onClose, -}: WipRegisterFormProps) => { - const queryClient = useQueryClient(); - const [workKind, setWorkKind] = useState('EXISTING_TABLE'); - const [targetTable, setTargetTable] = useState(''); - const [plannedVersion, setPlannedVersion] = useState(defaultVersion); - const [description, setDescription] = useState(''); - const [assignee, setAssignee] = useState(''); - - const { mutate, isPending, isError, error, data } = useMutation({ - ...flywayQueries.mutation.createWip(), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: flywayQueries.all }); - }, - }); - - const isExistingTable = workKind === 'EXISTING_TABLE'; - const canSubmit = - plannedVersion.trim().length > 0 && - description.trim().length > 0 && - assignee.trim().length > 0 && - (isExistingTable === false || targetTable.trim().length > 0); - - const handleSubmit = () => { - mutate({ - workKind, - targetTable: isExistingTable ? targetTable.trim() : undefined, - plannedVersion: plannedVersion.trim(), - description: description.trim(), - assignee: assignee.trim(), - }); - }; - - return ( - - - 작업중 등록 - 새 마이그레이션을 미리 예약해 번호 충돌을 막아요 - - ✕ - - - - - - - - setWorkKind('EXISTING_TABLE')} - > - 기존 테이블 - - setWorkKind('NEW_TABLE')} - > - 새로운 테이블 - - - - - {isExistingTable ? ( - - - setTargetTable(event.target.value)} - /> - - {existingTables.map((table) => ( - - - ) : null} - - - - setPlannedVersion(event.target.value)} - /> - - - - setDescription(event.target.value)} - /> - - - - setAssignee(event.target.value)} - /> - - - - - {isError ? ( - - 등록 실패: {(error as Error)?.message ?? '알 수 없는 오류'} - - ) : null} - {data ? ( - - 이슈 #{data.issueNumber} 생성됨 —{' '} - - GitHub에서 보기 - - - ) : null} - - ); -}; - -const Panel = styled.div` - padding: ${({ theme }) => theme.spacing.lg}; - border: 1px solid ${({ theme }) => theme.colors.warning}; - border-radius: ${({ theme }) => theme.borderRadius.lg}; - background-color: #fffdf7; -`; - -const PanelHeader = styled.div` - margin-bottom: ${({ theme }) => theme.spacing.md}; - display: flex; - gap: ${({ theme }) => theme.spacing.md}; - align-items: center; - - span { - color: ${({ theme }) => theme.colors.gray500}; - font-size: ${({ theme }) => theme.fontSize.sm}; - } -`; - -const CloseButton = styled.button` - margin-left: auto; - color: ${({ theme }) => theme.colors.gray500}; - cursor: pointer; -`; - -const Row = styled.div` - display: flex; - gap: ${({ theme }) => theme.spacing.md}; - align-items: flex-end; - flex-wrap: wrap; -`; - -const Field = styled.div<{ $grow?: boolean }>` - display: flex; - flex-direction: column; - gap: ${({ theme }) => theme.spacing.xs}; - flex: ${({ $grow }) => ($grow ? 1 : 'none')}; - min-width: 140px; -`; - -const Label = styled.span` - color: ${({ theme }) => theme.colors.gray500}; - font-size: ${({ theme }) => theme.fontSize.xs}; - font-weight: ${({ theme }) => theme.fontWeight.semibold}; -`; - -const Input = styled.input` - padding: ${({ theme }) => theme.spacing.sm}; - border: 1px solid ${({ theme }) => theme.colors.gray300}; - border-radius: ${({ theme }) => theme.borderRadius.md}; - font-size: ${({ theme }) => theme.fontSize.sm}; -`; - -const Segment = styled.div` - display: inline-flex; - border: 1px solid ${({ theme }) => theme.colors.gray300}; - border-radius: ${({ theme }) => theme.borderRadius.md}; - overflow: hidden; -`; - -const SegmentButton = styled.button<{ $active: boolean }>` - padding: ${({ theme }) => theme.spacing.sm} ${({ theme }) => theme.spacing.md}; - font-size: ${({ theme }) => theme.fontSize.sm}; - cursor: pointer; - background-color: ${({ theme, $active }) => - $active ? theme.colors.primary : theme.colors.white}; - color: ${({ theme, $active }) => - $active ? theme.colors.white : theme.colors.gray600}; -`; - -const Notice = styled.p<{ $error?: boolean }>` - margin-top: ${({ theme }) => theme.spacing.md}; - font-size: ${({ theme }) => theme.fontSize.sm}; - color: ${({ theme, $error }) => - $error ? theme.colors.error : theme.colors.success}; -`; diff --git a/admin/src/routeTree.gen.ts b/admin/src/routeTree.gen.ts index c9dbe19bf..cdf9b4478 100644 --- a/admin/src/routeTree.gen.ts +++ b/admin/src/routeTree.gen.ts @@ -15,7 +15,6 @@ import { Route as AdminIndexRouteImport } from './routes/_admin/index'; import { Route as AdminResourcesRouteImport } from './routes/_admin/resources'; import { Route as AdminNoticesRouteImport } from './routes/_admin/notices'; import { Route as AdminMembersRouteImport } from './routes/_admin/members'; -import { Route as AdminFlywayRouteImport } from './routes/_admin/flyway'; import { Route as AdminEventsRouteImport } from './routes/_admin/events'; import { Route as AdminChallengesRouteImport } from './routes/_admin/challenges'; import { Route as AdminBlogRouteImport } from './routes/_admin/blog'; @@ -78,11 +77,6 @@ const AdminMembersRoute = AdminMembersRouteImport.update({ path: '/members', getParentRoute: () => AdminRoute, } as any); -const AdminFlywayRoute = AdminFlywayRouteImport.update({ - id: '/flyway', - path: '/flyway', - getParentRoute: () => AdminRoute, -} as any); const AdminEventsRoute = AdminEventsRouteImport.update({ id: '/events', path: '/events', @@ -267,7 +261,6 @@ export interface FileRoutesByFullPath { '/blog': typeof AdminBlogRouteWithChildren; '/challenges': typeof AdminChallengesRouteWithChildren; '/events': typeof AdminEventsRouteWithChildren; - '/flyway': typeof AdminFlywayRoute; '/members': typeof AdminMembersRoute; '/notices': typeof AdminNoticesRouteWithChildren; '/resources': typeof AdminResourcesRouteWithChildren; @@ -304,7 +297,6 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/403': typeof R403Route; - '/flyway': typeof AdminFlywayRoute; '/members': typeof AdminMembersRoute; '/': typeof AdminIndexRoute; '/blog/$postId': typeof AdminBlogPostIdRoute; @@ -339,7 +331,6 @@ export interface FileRoutesById { '/_admin/blog': typeof AdminBlogRouteWithChildren; '/_admin/challenges': typeof AdminChallengesRouteWithChildren; '/_admin/events': typeof AdminEventsRouteWithChildren; - '/_admin/flyway': typeof AdminFlywayRoute; '/_admin/members': typeof AdminMembersRoute; '/_admin/notices': typeof AdminNoticesRouteWithChildren; '/_admin/resources': typeof AdminResourcesRouteWithChildren; @@ -381,7 +372,6 @@ export interface FileRouteTypes { | '/blog' | '/challenges' | '/events' - | '/flyway' | '/members' | '/notices' | '/resources' @@ -418,7 +408,6 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo; to: | '/403' - | '/flyway' | '/members' | '/' | '/blog/$postId' @@ -452,7 +441,6 @@ export interface FileRouteTypes { | '/_admin/blog' | '/_admin/challenges' | '/_admin/events' - | '/_admin/flyway' | '/_admin/members' | '/_admin/notices' | '/_admin/resources' @@ -537,13 +525,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminMembersRouteImport; parentRoute: typeof AdminRoute; }; - '/_admin/flyway': { - id: '/_admin/flyway'; - path: '/flyway'; - fullPath: '/flyway'; - preLoaderRoute: typeof AdminFlywayRouteImport; - parentRoute: typeof AdminRoute; - }; '/_admin/events': { id: '/_admin/events'; path: '/events'; @@ -941,7 +922,6 @@ interface AdminRouteChildren { AdminBlogRoute: typeof AdminBlogRouteWithChildren; AdminChallengesRoute: typeof AdminChallengesRouteWithChildren; AdminEventsRoute: typeof AdminEventsRouteWithChildren; - AdminFlywayRoute: typeof AdminFlywayRoute; AdminMembersRoute: typeof AdminMembersRoute; AdminNoticesRoute: typeof AdminNoticesRouteWithChildren; AdminResourcesRoute: typeof AdminResourcesRouteWithChildren; @@ -956,7 +936,6 @@ const AdminRouteChildren: AdminRouteChildren = { AdminBlogRoute: AdminBlogRouteWithChildren, AdminChallengesRoute: AdminChallengesRouteWithChildren, AdminEventsRoute: AdminEventsRouteWithChildren, - AdminFlywayRoute: AdminFlywayRoute, AdminMembersRoute: AdminMembersRoute, AdminNoticesRoute: AdminNoticesRouteWithChildren, AdminResourcesRoute: AdminResourcesRouteWithChildren, diff --git a/admin/src/routes/_admin/flyway.tsx b/admin/src/routes/_admin/flyway.tsx deleted file mode 100644 index 3ae9399ab..000000000 --- a/admin/src/routes/_admin/flyway.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router'; -import { FlywayViewer } from '@/pages/flyway/FlywayViewer'; - -export const Route = createFileRoute('/_admin/flyway')({ - component: FlywayViewer, -}); From a6f9052c0fbd8e6d80d345f4b55eb147ee0af419 Mon Sep 17 00:00:00 2001 From: Jeongeun Lee Date: Tue, 30 Jun 2026 19:26:19 +0900 Subject: [PATCH 15/17] =?UTF-8?q?hotfix:=20=EB=A7=A4=EC=9D=BC=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=EC=97=90=EC=84=9C=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=ED=9B=84=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=A1=9C=20?= =?UTF-8?q?=EB=8F=8C=EC=95=84=EA=B0=80=EC=A7=80=20=EB=AA=BB=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- maeil-mail/src/constants/urls.ts | 1 + maeil-mail/src/routeTree.gen.ts | 24 +++++++++++++++++++++--- maeil-mail/src/routes/signup.tsx | 27 +++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 maeil-mail/src/routes/signup.tsx diff --git a/maeil-mail/src/constants/urls.ts b/maeil-mail/src/constants/urls.ts index acc9c22f2..2221d104d 100644 --- a/maeil-mail/src/constants/urls.ts +++ b/maeil-mail/src/constants/urls.ts @@ -1 +1,2 @@ export const MAEIL_MAIL_URL = 'https://maeilmail.bombom.news'; +export const WEB_URL = 'https://bombom.news'; diff --git a/maeil-mail/src/routeTree.gen.ts b/maeil-mail/src/routeTree.gen.ts index ac3293457..e9592369b 100644 --- a/maeil-mail/src/routeTree.gen.ts +++ b/maeil-mail/src/routeTree.gen.ts @@ -9,9 +9,15 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root'; +import { Route as SignupRouteImport } from './routes/signup'; import { Route as IndexRouteImport } from './routes/index'; import { Route as ContentsContentIdAnswerRouteImport } from './routes/contents/$contentId/answer'; +const SignupRoute = SignupRouteImport.update({ + id: '/signup', + path: '/signup', + getParentRoute: () => rootRouteImport, +} as any); const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -25,32 +31,43 @@ const ContentsContentIdAnswerRoute = ContentsContentIdAnswerRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute; + '/signup': typeof SignupRoute; '/contents/$contentId/answer': typeof ContentsContentIdAnswerRoute; } export interface FileRoutesByTo { '/': typeof IndexRoute; + '/signup': typeof SignupRoute; '/contents/$contentId/answer': typeof ContentsContentIdAnswerRoute; } export interface FileRoutesById { __root__: typeof rootRouteImport; '/': typeof IndexRoute; + '/signup': typeof SignupRoute; '/contents/$contentId/answer': typeof ContentsContentIdAnswerRoute; } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath; - fullPaths: '/' | '/contents/$contentId/answer'; + fullPaths: '/' | '/signup' | '/contents/$contentId/answer'; fileRoutesByTo: FileRoutesByTo; - to: '/' | '/contents/$contentId/answer'; - id: '__root__' | '/' | '/contents/$contentId/answer'; + to: '/' | '/signup' | '/contents/$contentId/answer'; + id: '__root__' | '/' | '/signup' | '/contents/$contentId/answer'; fileRoutesById: FileRoutesById; } export interface RootRouteChildren { IndexRoute: typeof IndexRoute; + SignupRoute: typeof SignupRoute; ContentsContentIdAnswerRoute: typeof ContentsContentIdAnswerRoute; } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/signup': { + id: '/signup'; + path: '/signup'; + fullPath: '/signup'; + preLoaderRoute: typeof SignupRouteImport; + parentRoute: typeof rootRouteImport; + }; '/': { id: '/'; path: '/'; @@ -70,6 +87,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + SignupRoute: SignupRoute, ContentsContentIdAnswerRoute: ContentsContentIdAnswerRoute, }; export const routeTree = rootRouteImport diff --git a/maeil-mail/src/routes/signup.tsx b/maeil-mail/src/routes/signup.tsx new file mode 100644 index 000000000..a28666f94 --- /dev/null +++ b/maeil-mail/src/routes/signup.tsx @@ -0,0 +1,27 @@ +import { createFileRoute, useSearch } from '@tanstack/react-router'; +import { useEffect } from 'react'; +import { WEB_URL } from '@/constants/urls'; + +export const Route = createFileRoute('/signup')({ + head: () => ({ + meta: [{ name: 'robots', content: 'noindex, nofollow' }], + }), + component: RouteComponent, + validateSearch: (search: { email?: string; name?: string }) => ({ + email: search.email, + name: search.name, + }), +}); + +function RouteComponent() { + const { email, name } = useSearch({ from: '/signup' }); + + useEffect(() => { + const params = new URLSearchParams(); + if (email) params.set('email', email); + if (name) params.set('name', name); + window.location.replace(`${WEB_URL}/signup?${params.toString()}`); + }, [email, name]); + + return null; +} From 7f4a8f6268609a0c44b214e953a3af899800028f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=ED=9D=AC?= Date: Tue, 30 Jun 2026 20:13:46 +0900 Subject: [PATCH 16/17] =?UTF-8?q?chore:=20=EC=97=B0=EC=86=8D=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20=EB=B3=B4=ED=98=B8=EB=A7=89=20UI=20main=20=EC=B2=B4?= =?UTF-8?q?=EB=A6=AC=ED=94=BD=20=EB=B0=98=EC=98=81=20(#274)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 연속 읽기 보호막 API 타입 반영 (cherry picked from commit 338cdcced40e22baaaa2fb64a17430c78e352b6a) * design: 연속 읽기 보호막 사용 완료 아이콘 추가 (cherry picked from commit 3997771955d2481901ff54643e500093791e4d77) * feat: 연속 읽기 보호막 정보 컴포넌트 추가 (cherry picked from commit 119f0fdee87d9cecd369b7bf03452ad8bbcc7a0f) * feat: 연속 독서왕 내 순위 보호막 표시 추가 (cherry picked from commit 2f80a0943a8a5839165ed270e93996dc2309fb05) * chore: 연속 읽기 보호막 목 데이터 추가 (cherry picked from commit 834124ca05c694a7e89a3ecd3f12e1d8684a36b3) * chore: 연속 읽기 보호막 API 타입 정리 (cherry picked from commit 711b1fbdaa5cdf76f9213952eee4634d0fb1ae98) * fix: 체리픽 생성 타입 정합성 수정 --- web/public/assets/svg/shield-outline.svg | 14 +++++ web/src/mocks/handlers/members.ts | 15 +++++- .../ReadingKingLeaderboard/MyRank.styles.ts | 6 +++ .../ReadingKingLeaderboard/StreakMyRank.tsx | 9 +++- .../StreakShieldInfo.tsx | 51 +++++++++++++++++++ web/src/types/openapi.d.ts | 9 ++++ 6 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 web/public/assets/svg/shield-outline.svg create mode 100644 web/src/pages/recommend/components/ReadingKingLeaderboard/StreakShieldInfo.tsx diff --git a/web/public/assets/svg/shield-outline.svg b/web/public/assets/svg/shield-outline.svg new file mode 100644 index 000000000..a4bc5af87 --- /dev/null +++ b/web/public/assets/svg/shield-outline.svg @@ -0,0 +1,14 @@ + \ No newline at end of file diff --git a/web/src/mocks/handlers/members.ts b/web/src/mocks/handlers/members.ts index 9cc8a4744..04ccf72c0 100644 --- a/web/src/mocks/handlers/members.ts +++ b/web/src/mocks/handlers/members.ts @@ -82,8 +82,21 @@ export const membersHandlers = [ challenge: { name: '뉴스레터 한달 읽기', generation: 1, - grade: 'bronze', + grade: 'BRONZE', }, + monthlyRanking: { + grade: 'GOLD', + year: 2026, + month: 6, + }, + streak: { + tier: 'THIRTY', + }, + }, + streakShield: { + status: 'AVAILABLE', + remainingCount: 1, + monthlyLimit: 1, }, }); }), diff --git a/web/src/pages/recommend/components/ReadingKingLeaderboard/MyRank.styles.ts b/web/src/pages/recommend/components/ReadingKingLeaderboard/MyRank.styles.ts index de253171e..e59902f57 100644 --- a/web/src/pages/recommend/components/ReadingKingLeaderboard/MyRank.styles.ts +++ b/web/src/pages/recommend/components/ReadingKingLeaderboard/MyRank.styles.ts @@ -44,6 +44,12 @@ export const MyReadValue = styled.div` font: ${({ theme }) => theme.fonts.t7Bold}; `; +export const StreakValueWrapper = styled.div` + display: flex; + gap: 4px; + align-items: center; +`; + export const ProgressBox = styled.div` display: flex; gap: 4px; diff --git a/web/src/pages/recommend/components/ReadingKingLeaderboard/StreakMyRank.tsx b/web/src/pages/recommend/components/ReadingKingLeaderboard/StreakMyRank.tsx index 7be6bd1c3..6a689b307 100644 --- a/web/src/pages/recommend/components/ReadingKingLeaderboard/StreakMyRank.tsx +++ b/web/src/pages/recommend/components/ReadingKingLeaderboard/StreakMyRank.tsx @@ -6,7 +6,9 @@ import { MyRankLabel, MyRankValue, MyReadValue, + StreakValueWrapper, } from './MyRank.styles'; +import StreakShieldInfo from './StreakShieldInfo'; import UserBadgeInfo from './UserBadgeInfo'; import type { GetMyStreakReadingRankResponse } from '@/apis/members/members.api'; @@ -15,7 +17,7 @@ interface StreakMyRankProps { } const StreakMyRank = ({ userRank }: StreakMyRankProps) => { - const rankSummary = `현재 나의 순위 ${userRank.rank}위. 연속 독서 ${userRank.dayCount}일`; + const rankSummary = `현재 나의 순위 ${userRank.rank}위. 연속 독서 ${userRank.dayCount}일. 연속 읽기 보호막 ${userRank.streakShield.status === 'USED' ? '사용 완료' : '사용 가능'}`; return ( @@ -29,7 +31,10 @@ const StreakMyRank = ({ userRank }: StreakMyRankProps) => { {userRank.rank}위 - {userRank.dayCount}일 + + + {userRank.dayCount}일 + diff --git a/web/src/pages/recommend/components/ReadingKingLeaderboard/StreakShieldInfo.tsx b/web/src/pages/recommend/components/ReadingKingLeaderboard/StreakShieldInfo.tsx new file mode 100644 index 000000000..a6a041ac1 --- /dev/null +++ b/web/src/pages/recommend/components/ReadingKingLeaderboard/StreakShieldInfo.tsx @@ -0,0 +1,51 @@ +import styled from '@emotion/styled'; +import { useRef, useState } from 'react'; +import Tooltip from '@/components/Tooltip/Tooltip'; +import ShieldOutlineIcon from '#/assets/svg/shield-outline.svg'; +import ShieldIcon from '#/assets/svg/shield.svg'; + +interface StreakShieldInfoProps { + status?: 'AVAILABLE' | 'USED'; +} + +const StreakShieldInfo = ({ status }: StreakShieldInfoProps) => { + const [tooltipOpened, setTooltipOpened] = useState(false); + const shieldRef = useRef(null); + const isUsed = status === 'USED'; + const tooltipText = isUsed + ? '연속 읽기 보호막 (월 1회 제공) - 사용 완료' + : '연속 읽기 보호막 (월 1회 제공)'; + + const openTooltip = () => setTooltipOpened(true); + const closeTooltip = () => setTooltipOpened(false); + + return ( + + {isUsed ? : } + + {tooltipText} + + + ); +}; + +export default StreakShieldInfo; + +const Container = styled.div` + position: relative; + z-index: ${({ theme }) => theme.zIndex.base}; + width: 32px; + height: 32px; + + display: flex; + align-items: center; + justify-content: center; +`; diff --git a/web/src/types/openapi.d.ts b/web/src/types/openapi.d.ts index 98c7be164..745caa6a3 100644 --- a/web/src/types/openapi.d.ts +++ b/web/src/types/openapi.d.ts @@ -1834,6 +1834,15 @@ export interface components { /** Format: int32 */ dayCount: number; badges?: components['schemas']['BadgesResponse']; + streakShield: components['schemas']['StreakShieldResponse']; + }; + StreakShieldResponse: { + /** @enum {string} */ + status?: 'AVAILABLE' | 'USED'; + /** Format: int32 */ + remainingCount: number; + /** Format: int32 */ + monthlyLimit: number; }; MemberMonthlyReadingCountResponse: { /** Format: int32 */ From ac27786d4f24f9b2f3478cebb61be6508daa7545 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaeo=20=28=EA=B7=B8=EB=A1=9C=EC=8A=A4=ED=8C=80=20FE=20?= =?UTF-8?q?=EC=9E=AC=EC=98=A4=29?= Date: Wed, 1 Jul 2026 22:13:48 +0900 Subject: [PATCH 17/17] =?UTF-8?q?fix:=20=EC=8A=A4=ED=86=A0=EB=A6=AC?= =?UTF-8?q?=EC=A7=80=20=EC=B0=A8=EB=8B=A8=20=ED=99=98=EA=B2=BD=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=9E=9C=EB=94=A9=20=EB=A6=AC=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EB=A0=89=ED=8A=B8=20=ED=81=AC=EB=9E=98=EC=8B=9C=20=EB=B0=A9?= =?UTF-8?q?=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit localStorage 접근이 SecurityError로 throw하는 환경(스토리지 차단 인앱/크롤러)에서 _bombom beforeLoad가 실패해 라우트 기본 에러 UI가 노출되던 문제 수정. createStorage를 try/catch로 감싸 실패 시 기본값 반환·noop 처리하고, 라우터에 defaultErrorComponent 안전망 추가. --- web/src/main.tsx | 1 + .../pages/landing/constants/localStorage.ts | 7 ++++++ web/src/routes/_bombom.tsx | 4 +-- web/src/routes/landing.tsx | 4 +-- web/src/utils/localStorage.test.ts | 25 +++++++++++++++++++ web/src/utils/localStorage.ts | 23 +++++++++++------ 6 files changed, 53 insertions(+), 11 deletions(-) diff --git a/web/src/main.tsx b/web/src/main.tsx index 36c90d4ed..ac448d70d 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -95,6 +95,7 @@ const router = createRouter({ queryClient, }, scrollRestoration: true, + defaultErrorComponent: PageErrorFallback, }); if (isProduction) { diff --git a/web/src/pages/landing/constants/localStorage.ts b/web/src/pages/landing/constants/localStorage.ts index 5455d06a3..49f342723 100644 --- a/web/src/pages/landing/constants/localStorage.ts +++ b/web/src/pages/landing/constants/localStorage.ts @@ -1 +1,8 @@ +import { createStorage } from '@/utils/localStorage'; + export const LANDING_VISITED_KEY = 'hasVisitedLanding'; + +export const landingVisitedStorage = createStorage( + LANDING_VISITED_KEY, + false, +); diff --git a/web/src/routes/_bombom.tsx b/web/src/routes/_bombom.tsx index bae851ec4..2014c0526 100644 --- a/web/src/routes/_bombom.tsx +++ b/web/src/routes/_bombom.tsx @@ -3,7 +3,7 @@ import { queries } from '@/apis/queries'; import AppInstallPromptModal from '@/components/AppInstallPromptModal/AppInstallPromptModal'; import BomBomPageLayout from '@/components/PageLayout/BomBomPageLayout'; import { useWebViewRegisterToken } from '@/libs/webview/useWebViewRegisterToken'; -import { LANDING_VISITED_KEY } from '@/pages/landing/constants/localStorage'; +import { landingVisitedStorage } from '@/pages/landing/constants/localStorage'; let isFirstVisit = true; @@ -13,7 +13,7 @@ export const Route = createFileRoute('/_bombom')({ context, location, }): Promise> => { - const hasVisitedLanding = localStorage.getItem(LANDING_VISITED_KEY); + const hasVisitedLanding = landingVisitedStorage.get(); if (!hasVisitedLanding) { return redirect({ to: '/landing' }); } diff --git a/web/src/routes/landing.tsx b/web/src/routes/landing.tsx index 8d97c1f16..06edeea5b 100644 --- a/web/src/routes/landing.tsx +++ b/web/src/routes/landing.tsx @@ -9,7 +9,7 @@ import LandingHeader from '@/pages/landing/components/LandingHeader'; import LandingHero from '@/pages/landing/components/LandingHero'; import LandingPopularNewsletters from '@/pages/landing/components/LandingPopularNewsletters'; import PainPoint from '@/pages/landing/components/PainPoint'; -import { LANDING_VISITED_KEY } from '@/pages/landing/constants/localStorage'; +import { landingVisitedStorage } from '@/pages/landing/constants/localStorage'; import type { Device } from '@/hooks/useDevice'; export const Route = createFileRoute('/landing')({ @@ -31,7 +31,7 @@ function LandingPage() { const device = useDevice(); useEffect(() => { - localStorage.setItem(LANDING_VISITED_KEY, 'true'); + landingVisitedStorage.set(true); }, []); return ( diff --git a/web/src/utils/localStorage.test.ts b/web/src/utils/localStorage.test.ts index 5d391ddc0..1b21c604b 100644 --- a/web/src/utils/localStorage.test.ts +++ b/web/src/utils/localStorage.test.ts @@ -51,4 +51,29 @@ describe('createStorage', () => { expect(window.localStorage.removeItem).toHaveBeenCalledWith(KEY); expect(window.localStorage.getItem(KEY)).toBeNull(); }); + + it('get: storage 접근이 예외를 던지면 defaultData를 반환한다.', () => { + jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new DOMException('Access is denied.', 'SecurityError'); + }); + + expect(() => storage.get()).not.toThrow(); + expect(storage.get()).toEqual(DEFAULT_DATA); + }); + + it('set: storage 접근이 예외를 던져도 throw하지 않는다.', () => { + jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('Access is denied.', 'SecurityError'); + }); + + expect(() => storage.set({ count: 1 })).not.toThrow(); + }); + + it('remove: storage 접근이 예외를 던져도 throw하지 않는다.', () => { + jest.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { + throw new DOMException('Access is denied.', 'SecurityError'); + }); + + expect(() => storage.remove()).not.toThrow(); + }); }); diff --git a/web/src/utils/localStorage.ts b/web/src/utils/localStorage.ts index 402b435ff..226364954 100644 --- a/web/src/utils/localStorage.ts +++ b/web/src/utils/localStorage.ts @@ -12,23 +12,32 @@ interface StorageType { remove: () => void; } -const storage = window.localStorage; - export const createStorage = ( key: string, defaultData?: T, ): StorageType => ({ get() { - const data = storage.getItem(key); - return data ? JSON.parse(data) : defaultData; + try { + const data = window.localStorage.getItem(key); + return data ? JSON.parse(data) : defaultData; + } catch { + return defaultData; + } }, set(data) { - const stringifyData = JSON.stringify(data); - storage.setItem(key, stringifyData); + try { + window.localStorage.setItem(key, JSON.stringify(data)); + } catch { + // noop + } }, remove() { - storage.removeItem(key); + try { + window.localStorage.removeItem(key); + } catch { + // noop + } }, });