From 54e5d141275dfab99d5e444475b9e53afaad38fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Tue, 11 Aug 2026 14:57:33 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20=ED=95=98?= =?UTF-8?q?=EB=8B=A8=20=EB=82=B4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=C2=B7?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=8C=A8=EB=84=90=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/BottomNav.tsx | 67 +++++ src/components/SettingsPanel.tsx | 141 +++++++++ src/components/Sidebar.tsx | 243 ++++------------ src/components/navItems.tsx | 93 ++++++ src/hooks/useOverlayClose.ts | 24 ++ .../Dashboard/ContentCalendarContent.tsx | 72 ++--- src/pages/Dashboard/MyInfoContent.tsx | 44 ++- src/styles/base-components.css | 273 +++++++++--------- src/styles/studio-assets.css | 11 +- src/styles/tokens.css | 4 + src/utils/useIsMobile.ts | 16 + 11 files changed, 613 insertions(+), 375 deletions(-) create mode 100644 src/components/BottomNav.tsx create mode 100644 src/components/SettingsPanel.tsx create mode 100644 src/components/navItems.tsx create mode 100644 src/hooks/useOverlayClose.ts create mode 100644 src/utils/useIsMobile.ts diff --git a/src/components/BottomNav.tsx b/src/components/BottomNav.tsx new file mode 100644 index 0000000..396a246 --- /dev/null +++ b/src/components/BottomNav.tsx @@ -0,0 +1,67 @@ +import React, { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { UserMeResponse } from '../types/api'; +import { NAV_ITEMS, SETTINGS_ID } from './navItems'; +import SettingsPanel from './SettingsPanel'; + +interface BottomNavProps { + activeItem: string; + onNavigate: (id: string) => void; + userInfo?: UserMeResponse | null; + onLogout?: () => void; + credits?: number | null; + isGuest: boolean; + onLoginClick: () => void; +} + +const BottomNav: React.FC = ({ activeItem, onNavigate, userInfo, onLogout, credits, isGuest, onLoginClick }) => { + const { t } = useTranslation(); + const [settingsOpen, setSettingsOpen] = useState(false); + const settingsAnchorRef = useRef(null); + + const handleItemClick = (id: string) => { + if (id === SETTINGS_ID) { + setSettingsOpen(v => !v); + return; + } + onNavigate(id); + }; + + return ( + <> + {settingsOpen &&
setSettingsOpen(false)} />} + {settingsOpen && ( + setSettingsOpen(false)} + onNavigate={onNavigate} + activeItem={activeItem} + userInfo={userInfo} + credits={credits} + isGuest={isGuest} + onLogout={onLogout} + onLoginClick={onLoginClick} + anchorRef={settingsAnchorRef} + /> + )} + + + ); +}; + +export default BottomNav; diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx new file mode 100644 index 0000000..28d508b --- /dev/null +++ b/src/components/SettingsPanel.tsx @@ -0,0 +1,141 @@ +import React, { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { UserMeResponse } from '../types/api'; +import LanguageSwitch from './LanguageSwitch'; +import { NAV } from './navItems'; + +interface SettingsPanelProps { + variant: 'sidebar' | 'bottom'; + onClose: () => void; + onNavigate: (id: string) => void; + activeItem: string; + userInfo?: UserMeResponse | null; + credits?: number | null; + isGuest: boolean; + isLoggingOut?: boolean; + onLogout?: () => void; + onLoginClick: () => void; + /** 팝업을 여는 설정 버튼 영역. 외부 클릭 닫기에서 제외해 재클릭 시 토글이 정상 동작하게 한다 */ + anchorRef?: React.RefObject; +} + +const SettingsPanel: React.FC = ({ + variant, + onClose, + onNavigate, + activeItem, + userInfo, + credits, + isGuest, + isLoggingOut, + onLogout, + onLoginClick, + anchorRef, +}) => { + const { t } = useTranslation(); + const panelRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as Node; + if (anchorRef?.current?.contains(target)) return; + if (panelRef.current && !panelRef.current.contains(target)) { + onClose(); + } + }; + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscape); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscape); + }; + }, [onClose, anchorRef]); + + return ( +
+ {/* 1) 프로필 — 패널 헤더 역할 */} + {!isGuest && ( + <> +
+ {userInfo?.profile_image_url || userInfo?.thumbnail_image_url ? ( + Profile + ) : ( +
+ + + + +
+ )} +
+

{userInfo?.nickname || t('sidebar.defaultUser')}

+ {credits !== null && credits !== undefined && ( +

{t('sidebar.credits', { count: credits })}

+ )} +
+
+ + )} + + {/* 2) 메뉴 그룹 — 대시보드 */} + + +
+ + {/* 3) 하단 그룹 — 언어 전환 / 로그아웃(로그인) / 고객의견 */} +
+ +
+ + {isGuest ? ( + + ) : ( + + )} + + + + + + {t('sidebar.inquiry')} + +
+ ); +}; + +export default SettingsPanel; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 4d3abd7..d383be0 100755 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,32 +1,30 @@ -import React, { useState, useEffect } from 'react'; +import React, { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { UserMeResponse } from '../types/api'; import { logout } from '../utils/api'; -import LanguageSwitch from './LanguageSwitch'; +import { NAV_ITEMS, SETTINGS_ID } from './navItems'; +const TOP_NAV_ITEMS = NAV_ITEMS.filter(item => item.id !== SETTINGS_ID); +const SETTINGS_NAV_ITEM = NAV_ITEMS.find(item => item.id === SETTINGS_ID)!; +import SettingsPanel from './SettingsPanel'; interface SidebarItemProps { icon: React.ReactNode; label: string; isActive?: boolean; - isCollapsed: boolean; - isDisabled?: boolean; onClick?: () => void; - id?: string; } -const SidebarItem: React.FC = ({ icon, label, isActive, isCollapsed, isDisabled, onClick, id }) => { +const SidebarItem: React.FC = ({ icon, label, isActive, onClick }) => { return (
{icon}
- {!isCollapsed && {label}} + {label}
); }; @@ -34,22 +32,20 @@ const SidebarItem: React.FC = ({ icon, label, isActive, isColl interface SidebarProps { activeItem: string; onNavigate: (id: string) => void; - onHome?: () => void; + onLogoClick?: () => void; userInfo?: UserMeResponse | null; onLogout?: () => void; credits?: number | null; - tutorialAvailable?: boolean; - tutorialEnabled?: boolean; - onToggleTutorial?: () => void; + isGuest: boolean; + onLoginClick: () => void; } -const Sidebar: React.FC = ({ activeItem, onNavigate, onHome, userInfo, onLogout, credits, tutorialAvailable, tutorialEnabled, onToggleTutorial }) => { +const Sidebar: React.FC = ({ activeItem, onNavigate, onLogoClick, userInfo, onLogout, credits, isGuest, onLoginClick }) => { const { t } = useTranslation(); - const [isCollapsed, setIsCollapsed] = useState(false); - const [isMobileOpen, setIsMobileOpen] = useState(false); const [isLoggingOut, setIsLoggingOut] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const settingsAnchorRef = useRef(null); - // 로그아웃 처리 const handleLogout = async () => { if (isLoggingOut) return; setIsLoggingOut(true); @@ -58,184 +54,69 @@ const Sidebar: React.FC = ({ activeItem, onNavigate, onHome, userI onLogout?.(); } catch (error) { console.error('Logout failed:', error); - // 에러가 나도 로컬 토큰은 이미 삭제됨, 홈으로 이동 onLogout?.(); } finally { setIsLoggingOut(false); } }; - useEffect(() => { - const handleResize = () => { - if (window.innerWidth < 768) { - setIsMobileOpen(false); - } - }; - - handleResize(); - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, []); - - const handleNavigate = (id: string) => { - onNavigate(id); - if (window.innerWidth < 768) { - setIsMobileOpen(false); + const handleItemClick = (id: string) => { + if (id === SETTINGS_ID) { + setSettingsOpen(v => !v); + return; } + onNavigate(id); }; - const menuItems = [ - { id: '대시보드', label: t('sidebar.dashboard'), disabled: false, icon: }, - { id: '새 프로젝트 만들기', label: t('sidebar.newProject'), disabled: false, icon: }, - { id: 'ADO2 콘텐츠', label: t('sidebar.ado2Contents'), disabled: false, icon: }, - { id: '내 콘텐츠', label: t('sidebar.myContents'), disabled: false, icon: }, - { id: '콘텐츠 캘린더', label: t('contentCalendar.title'), disabled: false, icon: }, - { id: '내 정보', label: t('sidebar.myInfo'), disabled: false, icon: }, - ]; - return ( - <> - {/* Mobile Menu Button */} - - - {/* Mobile Overlay */} - {isMobileOpen && ( -
setIsMobileOpen(false)} +
+
+ ADO2 - )} +
- {/* Sidebar */} -
-
- {!isCollapsed && ( - ADO2 + {TOP_NAV_ITEMS.map(item => ( + handleItemClick(item.id)} + /> + ))} +
+ +
+
+ handleItemClick(SETTINGS_NAV_ITEM.id)} + /> + {settingsOpen && ( + setSettingsOpen(false)} + onNavigate={onNavigate} + activeItem={activeItem} + userInfo={userInfo} + credits={credits} + isGuest={isGuest} + isLoggingOut={isLoggingOut} + onLogout={handleLogout} + onLoginClick={onLoginClick} + anchorRef={settingsAnchorRef} /> )} - -
- -
- {menuItems.map(item => ( - handleNavigate(item.id)} - /> - ))} -
- -
- {/* 모바일 전용 튜토리얼 토글 */} - {tutorialAvailable && ( - - )} - -
- -
- -
- {userInfo?.profile_image_url || userInfo?.thumbnail_image_url ? ( - Profile - ) : ( -
- - - - -
- )} - {!isCollapsed && ( -
-

{userInfo?.nickname || t('sidebar.defaultUser')}

- {credits !== null && credits !== undefined && ( -

{t('sidebar.credits', { count: credits })}

- )} -
- )} -
- -
- - - - - - {!isCollapsed && {t('sidebar.inquiry')}} - -
- +
); }; diff --git a/src/components/navItems.tsx b/src/components/navItems.tsx new file mode 100644 index 0000000..27ae2cb --- /dev/null +++ b/src/components/navItems.tsx @@ -0,0 +1,93 @@ +import React from 'react'; + +export const NAV = { + HOME: 'ADO2 콘텐츠', + NEW: '새 프로젝트 만들기', + MY: '내 정보', + DASHBOARD: '대시보드', +} as const; + +export const SETTINGS_ID = '__settings__'; + +export const GATED_ITEMS: string[] = [NAV.NEW, NAV.MY, NAV.DASHBOARD]; + +export type MyInfoTab = 'contents' | 'calendar' | 'payment' | 'business'; + +export interface NavItemDef { + id: string; + labelKey: string; + icon: React.ReactNode; +} + +export const NAV_ITEMS: NavItemDef[] = [ + { + id: NAV.HOME, + labelKey: 'sidebar.ado2Contents', + icon: ( + + + + + ), + }, + { + id: NAV.NEW, + labelKey: 'sidebar.newProject', + icon: ( + + + + + ), + }, + { + id: NAV.MY, + labelKey: 'sidebar.myInfo', + icon: ( + + + + + ), + }, + { + id: SETTINGS_ID, + labelKey: 'sidebar.settings', + icon: ( + + + + + ), + }, +]; + +/** 구 activeItem(6메뉴 체제) → 새 4메뉴 체계 매핑 + 비로그인 게이트 강등을 함께 처리한다. */ +export function sanitizeActiveItem( + raw: string | null, + isGuest: boolean +): { item: string; myInfoTab?: MyInfoTab } { + const gate = (result: { item: string; myInfoTab?: MyInfoTab }) => + isGuest && GATED_ITEMS.includes(result.item) ? { item: NAV.HOME } : result; + + switch (raw) { + case '대시보드': + return gate({ item: NAV.DASHBOARD }); + case '내 콘텐츠': + return gate({ item: NAV.MY, myInfoTab: 'contents' }); + case '콘텐츠 캘린더': + return gate({ item: NAV.MY, myInfoTab: 'calendar' }); + case '비즈니스 설정': + return gate({ item: NAV.MY, myInfoTab: 'business' }); + case NAV.MY: + return gate({ item: NAV.MY }); + case NAV.NEW: + return gate({ item: NAV.NEW }); + case NAV.DASHBOARD: + return gate({ item: NAV.DASHBOARD }); + case NAV.HOME: + return { item: NAV.HOME }; + default: + return { item: NAV.HOME }; + } +} diff --git a/src/hooks/useOverlayClose.ts b/src/hooks/useOverlayClose.ts new file mode 100644 index 0000000..fc320a9 --- /dev/null +++ b/src/hooks/useOverlayClose.ts @@ -0,0 +1,24 @@ +import React, { useRef } from 'react'; + +/** + * 모달 오버레이(배경) 클릭 시에만 닫히도록 하는 핸들러 쌍. + * 모달 내부에서 텍스트를 드래그 선택하다가 마우스가 오버레이 위에서 놓이면 + * click 이벤트의 타겟이 오버레이가 되어 의도치 않게 닫히는 문제를 막기 위해, + * mousedown과 click(mouseup) 타겟이 모두 오버레이 자신일 때만 닫는다. + */ +export function useOverlayClose(onClose: () => void) { + const mouseDownOnSelfRef = useRef(false); + + const onMouseDown = (e: React.MouseEvent) => { + mouseDownOnSelfRef.current = e.target === e.currentTarget; + }; + + const onClick = (e: React.MouseEvent) => { + if (mouseDownOnSelfRef.current && e.target === e.currentTarget) { + onClose(); + } + mouseDownOnSelfRef.current = false; + }; + + return { onMouseDown, onClick }; +} diff --git a/src/pages/Dashboard/ContentCalendarContent.tsx b/src/pages/Dashboard/ContentCalendarContent.tsx index 041cfd7..80a75b5 100644 --- a/src/pages/Dashboard/ContentCalendarContent.tsx +++ b/src/pages/Dashboard/ContentCalendarContent.tsx @@ -67,16 +67,17 @@ const statusLabelKey = (status: UploadStatus) => { interface ContentCalendarContentProps { onNavigate?: (id: string) => void; + /** 내 정보 탭 안에 임베드될 때: 탭 라벨과 중복되는 자체 제목을 숨기고 여백을 줄인다 */ + embedded?: boolean; } -const ContentCalendarContent: React.FC = ({ onNavigate }) => { +const ContentCalendarContent: React.FC = ({ onNavigate, embedded }) => { const { t } = useTranslation(); const today = new Date(); const [year, setYear] = useState(today.getFullYear()); const [month, setMonth] = useState(today.getMonth()); const [activeTab, setActiveTab] = useState('전체'); const [isMobile, setIsMobile] = useState(window.innerWidth < MOBILE_BREAKPOINT); - const [sheetOpen, setSheetOpen] = useState(false); // allItems: 캘린더 도트용 (전체) // panelItems: 오른쪽 패널용 (탭 필터) const [allItems, setAllItems] = useState([]); @@ -194,11 +195,16 @@ const ContentCalendarContent: React.FC = ({ onNavig setTimeout(() => { const el = dateRefs.current[dateKey]; const panel = panelRef.current; - if (el && panel) { + if (!el) return; + // 패널이 자체 스크롤을 가진 경우(데스크톱 사이드 패널)만 내부 스크롤. + // 모바일은 목록이 캘린더 아래에 그대로 펼쳐지므로 페이지 스크롤로 이동한다. + if (panel && panel.scrollHeight > panel.clientHeight) { const panelTop = panel.getBoundingClientRect().top; const elTop = el.getBoundingClientRect().top; const offset = elTop - panelTop + panel.scrollTop - 12; // 12px 여백 panel.scrollTo({ top: offset, behavior: 'smooth' }); + } else { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, 50); }; @@ -616,10 +622,11 @@ const ContentCalendarContent: React.FC = ({ onNavig }; // ── 패널 콘텐츠 ──────────────────────────────────────────── - const renderPanelContent = () => { + // inline=true (모바일): 자체 스크롤 없이 페이지 흐름에 그대로 펼친다 + const renderPanelContent = (inline = false) => { if (panelLoading) { return ( -
+
{t('contentCalendar.loading')}
); @@ -629,6 +636,7 @@ const ContentCalendarContent: React.FC = ({ onNavig

= ({ onNavig

{sortedDateKeys.map(dateKey => (
= ({ onNavig {renderCalendarGrid()}
- {/* 바텀시트 */} -
+ {/* 업로드 목록 — 캘린더 바로 아래에 펼쳐 둔다(바텀시트로 감추지 않음). + 좌우 여백 없음: 위 캘린더 그리드와 가로 폭을 맞춘다 */} +
-
setSheetOpen(o => !o)} - style={{ display: 'flex', justifyContent: 'center', padding: '8px 0', cursor: 'pointer' }} - > -
-
{renderTabs()}
- {sheetOpen && ( -
- {renderPanelContent()} -
- )} + {renderPanelContent(true)}
-
); } @@ -754,26 +756,28 @@ const ContentCalendarContent: React.FC = ({ onNavig return (
-
-

- {t('contentCalendar.title')} -

-
+ {!embedded && ( +
+

+ {t('contentCalendar.title')} +

+
+ )}
{/* 캘린더 영역 */}
void; } -const MyInfoContent: React.FC = ({ initialTab }) => { +const MyInfoContent: React.FC = ({ initialTab, onNavigate }) => { const { t } = useTranslation(); - const [activeTab, setActiveTab] = useState(initialTab || 'business'); + const [activeTab, setActiveTab] = useState(initialTab || 'contents'); const [businessUrl, setBusinessUrl] = useState(''); const [socialAccounts, setSocialAccounts] = useState([]); const [isLoadingAccounts, setIsLoadingAccounts] = useState(false); @@ -113,20 +118,33 @@ const MyInfoContent: React.FC = ({ initialTab }) => { const hasConnectedAccounts = youtubeAccounts.length > 0 || instagramAccounts.length > 0; const tabs = [ - // { id: 'basic' as TabType, label: t('myInfo.tabBasic') }, + { id: 'contents' as TabType, label: t('myInfo.tabContents') }, + { id: 'calendar' as TabType, label: t('myInfo.tabCalendar') }, { id: 'payment' as TabType, label: t('myInfo.tabPayment') }, { id: 'business' as TabType, label: t('myInfo.tabBusiness') }, ]; + const isWideTab = activeTab === 'contents' || activeTab === 'calendar'; + + const handleChildNavigate = (id: string) => { + if (id === '내 정보') { setActiveTab('business'); return; } + if (id === '콘텐츠 캘린더') { setActiveTab('calendar'); return; } + if (id === '내 콘텐츠') { setActiveTab('contents'); return; } + onNavigate?.(id); + }; + + const closeChargePopup = () => { setShowChargePopup(false); setChargeSuccess(false); setChargeAmount(''); setChargeNote(''); }; + const chargePopupOverlayHandlers = useOverlayClose(closeChargePopup); + return ( <> {showChargePopup && ( -
{ setShowChargePopup(false); setChargeSuccess(false); setChargeAmount(''); setChargeNote(''); }}> +
e.stopPropagation()}> {chargeSuccess ? ( <>

{t('myInfo.chargeSuccess')}

- + ) : ( <> @@ -187,7 +205,7 @@ const MyInfoContent: React.FC = ({ initialTab }) => {
)} -
+

{t('myInfo.title')}

{/* 탭 네비게이션 */} @@ -205,11 +223,13 @@ const MyInfoContent: React.FC = ({ initialTab }) => { {/* 탭 컨텐츠 */}
- {/* {activeTab === 'basic' && ( -
-

{t('myInfo.basicPlaceholder')}

-
- )} */} + {activeTab === 'contents' && ( + + )} + + {activeTab === 'calendar' && ( + + )} {activeTab === 'payment' && (
diff --git a/src/styles/base-components.css b/src/styles/base-components.css index 7648c4a..2de187f 100644 --- a/src/styles/base-components.css +++ b/src/styles/base-components.css @@ -458,27 +458,33 @@ } } +/* 모바일: 하단 탭바에 가리지 않도록 띄운다 */ +@media (max-width: 767px) { + .bottom-button-container { + bottom: calc(var(--bottom-nav-height) + 16px); + } +} + /* ===================================================== Sidebar Components ===================================================== */ /* Sidebar Container */ .sidebar { - position: fixed; + position: sticky; + top: 0; height: 100vh; + flex-shrink: 0; display: flex; flex-direction: column; background-color: var(--color-bg-dark); border-right: 1px solid var(--color-border-white-5); - transition: all var(--transition-slow); z-index: 50; } -@media (min-width: 768px) { +@media (max-width: 767px) { .sidebar { - position: sticky; - top: 0; - flex-shrink: 0; + display: none; } } @@ -486,24 +492,6 @@ width: 15rem; } -.sidebar.collapsed { - width: 5rem; -} - -.sidebar.mobile-open { - transform: translateX(0); -} - -.sidebar.mobile-closed { - transform: translateX(-100%); -} - -@media (min-width: 768px) { - .sidebar.mobile-closed { - transform: translateX(0); - } -} - /* Sidebar Header */ .sidebar-header { padding: 1.25rem; @@ -512,11 +500,6 @@ justify-content: space-between; } -.sidebar-header.collapsed { - flex-direction: column; - gap: 1rem; -} - /* Sidebar Logo */ .sidebar-logo { font-family: 'Playfair Display', serif; @@ -555,15 +538,6 @@ overflow: hidden; } -.sidebar-item.collapsed { - justify-content: center; - width: 3rem; - height: 3rem; - margin-left: auto; - margin-right: auto; - padding: 0; -} - .sidebar-item.active { background-color: var(--color-mint); color: var(--color-bg-dark); @@ -677,10 +651,6 @@ padding: 0 0.5rem; } -.profile-section.collapsed { - flex-direction: column; -} - .profile-avatar { width: 2.5rem; height: 2.5rem; @@ -778,25 +748,6 @@ transform: translateX(calc(100% + 2px)); } -/* Collapsed state - simple button */ -.lang-toggle-collapsed { - padding: 4px 6px; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.03em; - color: rgba(255, 255, 255, 0.5); - background: rgba(255, 255, 255, 0.08); - border: none; - border-radius: 6px; - cursor: pointer; - transition: all 0.3s ease; -} - -.lang-toggle-collapsed:hover { - color: #ffffff; - background: rgba(255, 255, 255, 0.15); -} - /* Sidebar Footer Actions */ .sidebar-footer-actions { display: flex; @@ -825,11 +776,6 @@ color: var(--color-text-white); } -.sidebar-inquiry-btn.collapsed { - justify-content: center; - padding: 0.75rem; -} - /* Logout Button */ .logout-btn { width: 100%; @@ -848,100 +794,143 @@ color: var(--color-text-white); } -.logout-btn.collapsed { - justify-content: center; -} - .logout-btn-label { font-size: var(--text-sm); font-weight: 700; } -/* 모바일 전용 사이드바 튜토리얼 토글 */ -.sidebar-tutorial-btn { +/* Settings Panel (Sidebar 4번째 메뉴 / BottomNav 설정 버튼 공용) */ +.settings-panel { + background: var(--color-bg-card); + border: 1px solid var(--color-border-white-10); + border-radius: var(--radius-2xl); + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.125rem; + z-index: 60; +} + +.settings-panel-profile { + padding: 0.625rem 0.75rem; +} + +.settings-panel-divider { + height: 1px; + background: rgba(255, 255, 255, 0.08); + margin: 0.375rem 0.25rem; + flex-shrink: 0; +} + +.sidebar-settings-anchor { + position: relative; +} + +.settings-panel--sidebar { + position: absolute; + left: 0; + bottom: calc(100% + 8px); + width: 210px; + max-height: calc(100vh - 96px); + overflow-y: auto; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); +} + +.settings-panel--bottom { + position: fixed; + /* 설정 버튼이 탭바 오른쪽 끝이므로 오른쪽에 앵커한다 */ + right: 8px; + width: 210px; + max-width: calc(100vw - 24px); + bottom: calc(var(--bottom-nav-height) - 5px); + max-height: calc(100vh - 120px); + overflow-y: auto; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); +} + +.settings-panel-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.625rem 0.75rem; + border-radius: var(--radius-lg); + color: var(--color-text-gray-400); + background: none; + border: none; + cursor: pointer; + font-size: var(--text-sm); + font-weight: 700; + text-decoration: none; + text-align: left; + width: 100%; + transition: color var(--transition-normal), background-color var(--transition-normal); +} + +.settings-panel-item:hover { + color: var(--color-text-white); + background-color: rgba(255, 255, 255, 0.05); +} + +.settings-panel-item.active { + color: var(--color-mint); +} + +.settings-panel-item svg { + flex-shrink: 0; +} + +.settings-panel-language { + display: flex; + justify-content: flex-start; + padding: 0.375rem 0.75rem; +} + +.settings-backdrop { + position: fixed; + inset: 0; + z-index: 55; +} + +/* Mobile Bottom Nav */ +.bottom-nav { display: none; + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 50; + height: var(--bottom-nav-height); + padding-bottom: env(safe-area-inset-bottom); + background: var(--color-bg-dark); + border-top: 1px solid var(--color-border-white-5); } @media (max-width: 767px) { - .sidebar-tutorial-btn { + .bottom-nav { display: flex; - align-items: center; - gap: 0.75rem; - width: 100%; - margin-top: 0.5rem; - padding: 0.75rem; - border-radius: var(--radius-lg); - color: var(--color-text-gray-400); - border: none; - background: none; - cursor: pointer; - font-size: var(--text-sm); - font-weight: 500; - transition: color var(--transition-normal), background-color var(--transition-normal); } - .sidebar-tutorial-btn:hover { - color: var(--color-text-white); - background-color: rgba(255, 255, 255, 0.05); - } - - .sidebar-tutorial-btn.active { - color: var(--color-mint); - } - - .sidebar-tutorial-label { - text-align: left; - } - - .sidebar-tutorial-badge { - font-size: 11px; - font-weight: 700; - } - - .sidebar-tutorial-badge.on { - color: var(--color-mint); - } - - .sidebar-tutorial-badge.off { - color: var(--color-text-gray-400); + .has-bottom-nav { + padding-bottom: var(--bottom-nav-height); } } -/* Mobile Menu Button */ -.mobile-menu-btn { - position: fixed; - top: 1rem; - right: 1rem; - z-index: 40; - padding: 0.625rem; - background-color: var(--color-bg-card); - border-radius: var(--radius-lg); - border: 1px solid var(--color-border-white-10); +.bottom-nav-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + font-size: 11px; + font-weight: 700; color: var(--color-text-gray-400); + background: none; + border: none; cursor: pointer; } -.mobile-menu-btn:hover { - color: var(--color-text-white); -} - -@media (min-width: 768px) { - .mobile-menu-btn { - display: none; - } -} - -/* Mobile Overlay */ -.mobile-overlay { - position: fixed; - inset: 0; - background-color: rgba(0, 0, 0, 0.5); - z-index: 40; -} - -@media (min-width: 768px) { - .mobile-overlay { - display: none; - } +.bottom-nav-item.active { + color: var(--color-mint); } diff --git a/src/styles/studio-assets.css b/src/styles/studio-assets.css index 47ea6f5..b9d71aa 100644 --- a/src/styles/studio-assets.css +++ b/src/styles/studio-assets.css @@ -933,13 +933,12 @@ display: flex; justify-content: center; } +} - body:has(.sidebar.collapsed) .asset-sticky-footer { - left: 5rem; - right: 0; - width: auto; - display: flex; - justify-content: center; +/* 모바일: 하단 탭바에 가리지 않도록 띄운다 */ +@media (max-width: 767px) { + .asset-sticky-footer { + bottom: calc(var(--bottom-nav-height) + 16px); } } diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 7b4e3d2..e45773b 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -6,6 +6,10 @@ CSS Variables - Design Tokens ===================================================== */ :root { + /* 모바일 하단 탭바 높이 (safe-area 포함). + 탭바 자신과, 탭바에 가리면 안 되는 fixed 요소(캘린더 바텀시트 등)가 공유한다 */ + --bottom-nav-height: calc(64px + env(safe-area-inset-bottom)); + /* Primary Colors */ --color-mint: #a6ffea; --color-mint-hover: #8affda; diff --git a/src/utils/useIsMobile.ts b/src/utils/useIsMobile.ts new file mode 100644 index 0000000..d792bf0 --- /dev/null +++ b/src/utils/useIsMobile.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from 'react'; + +const QUERY = '(max-width: 767px)'; + +export function useIsMobile(): boolean { + const [isMobile, setIsMobile] = useState(() => window.matchMedia(QUERY).matches); + + useEffect(() => { + const mql = window.matchMedia(QUERY); + const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches); + mql.addEventListener('change', handler); + return () => mql.removeEventListener('change', handler); + }, []); + + return isMobile; +}