feat: 모바일 하단 내비게이션·설정 패널 도입

This commit is contained in:
김성경 2026-08-11 14:57:33 +09:00
parent bb0ab91bcc
commit 54e5d14127
11 changed files with 613 additions and 375 deletions

View File

@ -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<BottomNavProps> = ({ activeItem, onNavigate, userInfo, onLogout, credits, isGuest, onLoginClick }) => {
const { t } = useTranslation();
const [settingsOpen, setSettingsOpen] = useState(false);
const settingsAnchorRef = useRef<HTMLButtonElement>(null);
const handleItemClick = (id: string) => {
if (id === SETTINGS_ID) {
setSettingsOpen(v => !v);
return;
}
onNavigate(id);
};
return (
<>
{settingsOpen && <div className="settings-backdrop" onClick={() => setSettingsOpen(false)} />}
{settingsOpen && (
<SettingsPanel
variant="bottom"
onClose={() => setSettingsOpen(false)}
onNavigate={onNavigate}
activeItem={activeItem}
userInfo={userInfo}
credits={credits}
isGuest={isGuest}
onLogout={onLogout}
onLoginClick={onLoginClick}
anchorRef={settingsAnchorRef}
/>
)}
<nav className="bottom-nav">
{NAV_ITEMS.map(item => {
const isActive = item.id === SETTINGS_ID ? settingsOpen : activeItem === item.id;
return (
<button
key={item.id}
ref={item.id === SETTINGS_ID ? settingsAnchorRef : undefined}
className={`bottom-nav-item ${isActive ? 'active' : ''}`}
onClick={() => handleItemClick(item.id)}
>
{item.icon}
<span>{t(item.labelKey)}</span>
</button>
);
})}
</nav>
</>
);
};
export default BottomNav;

View File

@ -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<HTMLElement | null>;
}
const SettingsPanel: React.FC<SettingsPanelProps> = ({
variant,
onClose,
onNavigate,
activeItem,
userInfo,
credits,
isGuest,
isLoggingOut,
onLogout,
onLoginClick,
anchorRef,
}) => {
const { t } = useTranslation();
const panelRef = useRef<HTMLDivElement>(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 (
<div ref={panelRef} className={`settings-panel settings-panel--${variant}`}>
{/* 1) 프로필 — 패널 헤더 역할 */}
{!isGuest && (
<>
<div className="profile-section settings-panel-profile">
{userInfo?.profile_image_url || userInfo?.thumbnail_image_url ? (
<img
src={userInfo.thumbnail_image_url || userInfo.profile_image_url || ''}
alt="Profile"
className="profile-avatar"
/>
) : (
<div className="profile-avatar profile-avatar-default">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
)}
<div className="flex-1 min-w-0">
<p className="profile-name">{userInfo?.nickname || t('sidebar.defaultUser')}</p>
{credits !== null && credits !== undefined && (
<p className="profile-credits">{t('sidebar.credits', { count: credits })}</p>
)}
</div>
</div>
</>
)}
{/* 2) 메뉴 그룹 — 대시보드 */}
<button
className={`settings-panel-item ${activeItem === NAV.DASHBOARD ? 'active' : ''}`}
onClick={() => {
onNavigate(NAV.DASHBOARD);
onClose();
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<rect x="3" y="3" width="7" height="9" /><rect x="14" y="3" width="7" height="5" />
<rect x="14" y="12" width="7" height="9" /><rect x="3" y="16" width="7" height="5" />
</svg>
<span>{t('sidebar.dashboard')}</span>
</button>
<div className="settings-panel-divider" />
{/* 3) 하단 그룹 — 언어 전환 / 로그아웃(로그인) / 고객의견 */}
<div className="settings-panel-language">
<LanguageSwitch isCollapsed={false} />
</div>
{isGuest ? (
<button className="settings-panel-item" onClick={onLoginClick}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" /><polyline points="10 17 15 12 10 7" /><line x1="15" y1="12" x2="3" y2="12" />
</svg>
<span>{t('sidebar.login')}</span>
</button>
) : (
<button className="settings-panel-item" onClick={onLogout} disabled={isLoggingOut}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" y1="12" x2="9" y2="12" />
</svg>
<span>{isLoggingOut ? t('sidebar.loggingOut') : t('sidebar.logout')}</span>
</button>
)}
<a
href="https://forms.gle/4a8mGebBYtdesvby9"
target="_blank"
rel="noopener noreferrer"
className="settings-panel-item"
title={t('sidebar.inquiry')}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
<span>{t('sidebar.inquiry')}</span>
</a>
</div>
);
};
export default SettingsPanel;

View File

@ -1,32 +1,30 @@
import React, { useState, useEffect } from 'react'; import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { UserMeResponse } from '../types/api'; import { UserMeResponse } from '../types/api';
import { logout } from '../utils/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 { interface SidebarItemProps {
icon: React.ReactNode; icon: React.ReactNode;
label: string; label: string;
isActive?: boolean; isActive?: boolean;
isCollapsed: boolean;
isDisabled?: boolean;
onClick?: () => void; onClick?: () => void;
id?: string;
} }
const SidebarItem: React.FC<SidebarItemProps> = ({ icon, label, isActive, isCollapsed, isDisabled, onClick, id }) => { const SidebarItem: React.FC<SidebarItemProps> = ({ icon, label, isActive, onClick }) => {
return ( return (
<div <div
id={id} onClick={onClick}
onClick={isDisabled ? undefined : onClick} className={`sidebar-item ${isActive ? 'active' : ''}`}
className={`sidebar-item ${isActive ? 'active' : ''} ${isCollapsed ? 'collapsed' : ''} ${isDisabled ? 'disabled' : ''}`}
title={isCollapsed ? label : ""}
> >
<div className="sidebar-item-icon"> <div className="sidebar-item-icon">
{icon} {icon}
</div> </div>
{!isCollapsed && <span className="sidebar-item-label">{label}</span>} <span className="sidebar-item-label">{label}</span>
</div> </div>
); );
}; };
@ -34,22 +32,20 @@ const SidebarItem: React.FC<SidebarItemProps> = ({ icon, label, isActive, isColl
interface SidebarProps { interface SidebarProps {
activeItem: string; activeItem: string;
onNavigate: (id: string) => void; onNavigate: (id: string) => void;
onHome?: () => void; onLogoClick?: () => void;
userInfo?: UserMeResponse | null; userInfo?: UserMeResponse | null;
onLogout?: () => void; onLogout?: () => void;
credits?: number | null; credits?: number | null;
tutorialAvailable?: boolean; isGuest: boolean;
tutorialEnabled?: boolean; onLoginClick: () => void;
onToggleTutorial?: () => void;
} }
const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userInfo, onLogout, credits, tutorialAvailable, tutorialEnabled, onToggleTutorial }) => { const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onLogoClick, userInfo, onLogout, credits, isGuest, onLoginClick }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isCollapsed, setIsCollapsed] = useState(false);
const [isMobileOpen, setIsMobileOpen] = useState(false);
const [isLoggingOut, setIsLoggingOut] = useState(false); const [isLoggingOut, setIsLoggingOut] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const settingsAnchorRef = useRef<HTMLDivElement>(null);
// 로그아웃 처리
const handleLogout = async () => { const handleLogout = async () => {
if (isLoggingOut) return; if (isLoggingOut) return;
setIsLoggingOut(true); setIsLoggingOut(true);
@ -58,184 +54,69 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
onLogout?.(); onLogout?.();
} catch (error) { } catch (error) {
console.error('Logout failed:', error); console.error('Logout failed:', error);
// 에러가 나도 로컬 토큰은 이미 삭제됨, 홈으로 이동
onLogout?.(); onLogout?.();
} finally { } finally {
setIsLoggingOut(false); setIsLoggingOut(false);
} }
}; };
useEffect(() => { const handleItemClick = (id: string) => {
const handleResize = () => { if (id === SETTINGS_ID) {
if (window.innerWidth < 768) { setSettingsOpen(v => !v);
setIsMobileOpen(false); return;
} }
};
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const handleNavigate = (id: string) => {
onNavigate(id); onNavigate(id);
if (window.innerWidth < 768) {
setIsMobileOpen(false);
}
}; };
const menuItems = [
{ id: '대시보드', label: t('sidebar.dashboard'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg> },
{ id: '새 프로젝트 만들기', label: t('sidebar.newProject'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> },
{ id: 'ADO2 콘텐츠', label: t('sidebar.ado2Contents'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> },
{ id: '내 콘텐츠', label: t('sidebar.myContents'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> },
{ id: '콘텐츠 캘린더', label: t('contentCalendar.title'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> },
{ id: '내 정보', label: t('sidebar.myInfo'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> },
];
return ( return (
<> <div className="sidebar expanded">
{/* Mobile Menu Button */} <div className="sidebar-header">
<button
onClick={() => setIsMobileOpen(true)}
className="mobile-menu-btn"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
{/* Mobile Overlay */}
{isMobileOpen && (
<div
className="mobile-overlay"
onClick={() => setIsMobileOpen(false)}
/>
)}
{/* Sidebar */}
<div className={`sidebar ${isCollapsed ? 'collapsed' : 'expanded'} ${isMobileOpen ? 'mobile-open' : 'mobile-closed'}`}>
<div className={`sidebar-header ${isCollapsed ? 'collapsed' : ''}`}>
{!isCollapsed && (
<img <img
onClick={onHome} onClick={onLogoClick}
src="/assets/images/ado2-sidebar-logo.svg" src="/assets/images/ado2-sidebar-logo.svg"
alt="ADO2" alt="ADO2"
className="sidebar-logo" className="sidebar-logo"
/> />
)}
<button
onClick={() => {
if (window.innerWidth < 768) {
setIsMobileOpen(false);
} else {
setIsCollapsed(!isCollapsed);
}
}}
className="p-1.5 text-gray-400 hover:text-white"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
</div> </div>
<div className="sidebar-menu no-scrollbar"> <div className="sidebar-menu no-scrollbar">
{menuItems.map(item => ( {TOP_NAV_ITEMS.map(item => (
<SidebarItem <SidebarItem
key={item.id} key={item.id}
id={
item.id === '내 정보'
? 'sidebar-my-info'
: item.id === 'ADO2 콘텐츠'
? 'sidebar-ado2-contents'
: undefined
}
icon={item.icon} icon={item.icon}
label={item.label} label={t(item.labelKey)}
isCollapsed={isCollapsed}
isActive={activeItem === item.id} isActive={activeItem === item.id}
isDisabled={item.disabled} onClick={() => handleItemClick(item.id)}
onClick={() => handleNavigate(item.id)}
/> />
))} ))}
</div> </div>
<div className="sidebar-footer"> <div className="sidebar-footer">
{/* 모바일 전용 튜토리얼 토글 */} <div className="sidebar-settings-anchor" ref={settingsAnchorRef}>
{tutorialAvailable && ( <SidebarItem
<button icon={SETTINGS_NAV_ITEM.icon}
className={`sidebar-tutorial-btn ${tutorialEnabled ? 'active' : ''}`} label={t(SETTINGS_NAV_ITEM.labelKey)}
onClick={() => onToggleTutorial?.()} isActive={settingsOpen}
title={tutorialEnabled ? t('sidebar.tutorialOff') : t('sidebar.tutorialOn')} onClick={() => handleItemClick(SETTINGS_NAV_ITEM.id)}
> />
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> {settingsOpen && (
<circle cx="12" cy="12" r="10"/> <SettingsPanel
<path d="M12 8v4l3 3"/> variant="sidebar"
</svg> onClose={() => setSettingsOpen(false)}
<span className="sidebar-tutorial-label">{t('sidebar.tutorial')}</span> onNavigate={onNavigate}
<span className={`sidebar-tutorial-badge ${tutorialEnabled ? 'on' : 'off'}`}> activeItem={activeItem}
{tutorialEnabled ? 'ON' : 'OFF'} userInfo={userInfo}
</span> credits={credits}
</button> isGuest={isGuest}
)} isLoggingOut={isLoggingOut}
onLogout={handleLogout}
<div className="sidebar-language-switch"> onLoginClick={onLoginClick}
<LanguageSwitch isCollapsed={isCollapsed} /> anchorRef={settingsAnchorRef}
</div>
<div className={`profile-section ${isCollapsed ? 'collapsed' : ''}`}>
{userInfo?.profile_image_url || userInfo?.thumbnail_image_url ? (
<img
src={userInfo.thumbnail_image_url || userInfo.profile_image_url || ''}
alt="Profile"
className="profile-avatar"
/> />
) : (
<div className="profile-avatar profile-avatar-default">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
</div>
)}
{!isCollapsed && (
<div className="flex-1 min-w-0">
<p className="profile-name">{userInfo?.nickname || t('sidebar.defaultUser')}</p>
{credits !== null && credits !== undefined && (
<p className="profile-credits">{t('sidebar.credits', { count: credits })}</p>
)} )}
</div> </div>
)}
</div>
<div className="sidebar-footer-actions">
<button
className={`logout-btn ${isCollapsed ? 'collapsed' : ''}`}
onClick={handleLogout}
disabled={isLoggingOut}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
</svg>
{!isCollapsed && <span className="logout-btn-label">{isLoggingOut ? t('sidebar.loggingOut') : t('sidebar.logout')}</span>}
</button>
<a
href="https://forms.gle/4a8mGebBYtdesvby9"
target="_blank"
rel="noopener noreferrer"
className={`sidebar-inquiry-btn ${isCollapsed ? 'collapsed' : ''}`}
title={t('sidebar.inquiry')}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
{!isCollapsed && <span>{t('sidebar.inquiry')}</span>}
</a>
</div> </div>
</div> </div>
</div>
</>
); );
}; };

View File

@ -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: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
),
},
{
id: NAV.NEW,
labelKey: 'sidebar.newProject',
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
),
},
{
id: NAV.MY,
labelKey: 'sidebar.myInfo',
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
),
},
{
id: SETTINGS_ID,
labelKey: 'sidebar.settings',
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
),
},
];
/** 구 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 };
}
}

View File

@ -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<HTMLElement>) => {
mouseDownOnSelfRef.current = e.target === e.currentTarget;
};
const onClick = (e: React.MouseEvent<HTMLElement>) => {
if (mouseDownOnSelfRef.current && e.target === e.currentTarget) {
onClose();
}
mouseDownOnSelfRef.current = false;
};
return { onMouseDown, onClick };
}

View File

@ -67,16 +67,17 @@ const statusLabelKey = (status: UploadStatus) => {
interface ContentCalendarContentProps { interface ContentCalendarContentProps {
onNavigate?: (id: string) => void; onNavigate?: (id: string) => void;
/** 내 정보 탭 안에 임베드될 때: 탭 라벨과 중복되는 자체 제목을 숨기고 여백을 줄인다 */
embedded?: boolean;
} }
const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavigate }) => { const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavigate, embedded }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const today = new Date(); const today = new Date();
const [year, setYear] = useState(today.getFullYear()); const [year, setYear] = useState(today.getFullYear());
const [month, setMonth] = useState(today.getMonth()); const [month, setMonth] = useState(today.getMonth());
const [activeTab, setActiveTab] = useState<TabType>('전체'); const [activeTab, setActiveTab] = useState<TabType>('전체');
const [isMobile, setIsMobile] = useState(window.innerWidth < MOBILE_BREAKPOINT); const [isMobile, setIsMobile] = useState(window.innerWidth < MOBILE_BREAKPOINT);
const [sheetOpen, setSheetOpen] = useState(false);
// allItems: 캘린더 도트용 (전체) // allItems: 캘린더 도트용 (전체)
// panelItems: 오른쪽 패널용 (탭 필터) // panelItems: 오른쪽 패널용 (탭 필터)
const [allItems, setAllItems] = useState<UploadHistoryItem[]>([]); const [allItems, setAllItems] = useState<UploadHistoryItem[]>([]);
@ -194,11 +195,16 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
setTimeout(() => { setTimeout(() => {
const el = dateRefs.current[dateKey]; const el = dateRefs.current[dateKey];
const panel = panelRef.current; const panel = panelRef.current;
if (el && panel) { if (!el) return;
// 패널이 자체 스크롤을 가진 경우(데스크톱 사이드 패널)만 내부 스크롤.
// 모바일은 목록이 캘린더 아래에 그대로 펼쳐지므로 페이지 스크롤로 이동한다.
if (panel && panel.scrollHeight > panel.clientHeight) {
const panelTop = panel.getBoundingClientRect().top; const panelTop = panel.getBoundingClientRect().top;
const elTop = el.getBoundingClientRect().top; const elTop = el.getBoundingClientRect().top;
const offset = elTop - panelTop + panel.scrollTop - 12; // 12px 여백 const offset = elTop - panelTop + panel.scrollTop - 12; // 12px 여백
panel.scrollTo({ top: offset, behavior: 'smooth' }); panel.scrollTo({ top: offset, behavior: 'smooth' });
} else {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
} }
}, 50); }, 50);
}; };
@ -616,10 +622,11 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
}; };
// ── 패널 콘텐츠 ──────────────────────────────────────────── // ── 패널 콘텐츠 ────────────────────────────────────────────
const renderPanelContent = () => { // inline=true (모바일): 자체 스크롤 없이 페이지 흐름에 그대로 펼친다
const renderPanelContent = (inline = false) => {
if (panelLoading) { if (panelLoading) {
return ( return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: inline ? 120 : undefined }}>
<span style={{ color: '#9bcacc', fontFamily: 'Pretendard, sans-serif', fontSize: 14 }}>{t('contentCalendar.loading')}</span> <span style={{ color: '#9bcacc', fontFamily: 'Pretendard, sans-serif', fontSize: 14 }}>{t('contentCalendar.loading')}</span>
</div> </div>
); );
@ -629,6 +636,7 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
<div style={{ <div style={{
flex: 1, display: 'flex', flexDirection: 'column', flex: 1, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', gap: 24, padding: 16, alignItems: 'center', justifyContent: 'center', gap: 24, padding: 16,
minHeight: inline ? 160 : undefined,
}}> }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center', textAlign: 'center' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center', textAlign: 'center' }}>
<p style={{ <p style={{
@ -662,7 +670,11 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
<div <div
ref={panelRef} ref={panelRef}
className="calendar-panel-scroll" className="calendar-panel-scroll"
style={{ flex: 1, overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 20, maxHeight: 700 }} style={{
flex: 1, padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 20,
overflowY: inline ? 'visible' : 'auto',
maxHeight: inline ? undefined : 700,
}}
> >
{sortedDateKeys.map(dateKey => ( {sortedDateKeys.map(dateKey => (
<div <div
@ -716,34 +728,24 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
{renderCalendarGrid()} {renderCalendarGrid()}
</div> </div>
{/* 바텀시트 */} {/* 업로드 목록 — 캘린더 바로 아래에 펼쳐 둔다(바텀시트로 감추지 않음).
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 10, display: 'flex', flexDirection: 'column' }}> 좌우 여백 없음: 위 캘린더 그리드와 가로 폭을 맞춘다 */}
<div style={{ width: '100%', marginTop: 16, boxSizing: 'border-box' }}>
<div style={{ <div style={{
backgroundColor: '#01393b', backgroundColor: '#01393b',
borderTop: '1px solid #046266', border: '1px solid #046266',
borderRadius: '20px 20px 0 0', borderRadius: 20,
overflow: 'hidden', overflow: 'hidden',
}}> }}>
<div
onClick={() => setSheetOpen(o => !o)}
style={{ display: 'flex', justifyContent: 'center', padding: '8px 0', cursor: 'pointer' }}
>
<div style={{ width: 49, height: 4, backgroundColor: '#9bcacc', borderRadius: 999 }} />
</div>
<div style={{ <div style={{
borderBottom: sheetOpen ? '1px solid #046266' : 'none', borderBottom: '1px solid #046266',
height: 50, display: 'flex', alignItems: 'center', padding: '0 16px', height: 50, display: 'flex', alignItems: 'center', padding: '0 16px',
}}> }}>
{renderTabs()} {renderTabs()}
</div> </div>
{sheetOpen && ( {renderPanelContent(true)}
<div style={{ height: 400, overflowY: 'auto', backgroundColor: '#01393b', display: 'flex', flexDirection: 'column' }}>
{renderPanelContent()}
</div>
)}
</div> </div>
</div> </div>
<div style={{ height: sheetOpen ? 516 : 116 }} />
</div> </div>
); );
} }
@ -754,11 +756,12 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
return ( return (
<div style={{ <div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center',
padding: '32px', padding: embedded ? '0' : '32px',
width: '100%', width: '100%',
minWidth: 1400, minWidth: 1400,
height: '100%', boxSizing: 'border-box', height: embedded ? 'auto' : '100%', boxSizing: 'border-box',
}}> }}>
{!embedded && (
<div style={{ width: '100%', maxWidth: 1440, paddingBottom: 32, flexShrink: 0 }}> <div style={{ width: '100%', maxWidth: 1440, paddingBottom: 32, flexShrink: 0 }}>
<p style={{ <p style={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30, fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
@ -767,13 +770,14 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
{t('contentCalendar.title')} {t('contentCalendar.title')}
</p> </p>
</div> </div>
)}
<div style={{ <div style={{
display: 'grid', display: 'grid',
gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) 340px', gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) minmax(0,1fr) 340px',
gap: 16, gap: 16,
width: '100%', maxWidth: 1440, width: '100%', maxWidth: 1440,
flex: 1, minHeight: 0, flex: 1, minHeight: embedded ? 'calc(100vh - 220px)' : 0,
}}> }}>
{/* 캘린더 영역 */} {/* 캘린더 영역 */}
<div className="calendar-grid-area" style={{ <div className="calendar-grid-area" style={{

View File

@ -3,16 +3,21 @@ import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getSocialAccounts, getYouTubeConnectUrl, disconnectSocialAccount, TokenExpiredError, handleSocialReconnect, getUserCredits, requestCreditCharge } from '../../utils/api'; import { getSocialAccounts, getYouTubeConnectUrl, disconnectSocialAccount, TokenExpiredError, handleSocialReconnect, getUserCredits, requestCreditCharge } from '../../utils/api';
import { SocialAccount } from '../../types/api'; import { SocialAccount } from '../../types/api';
import { MyInfoTab } from '../../components/navItems';
import MyContentsPage from './MyContentsPage';
import ContentCalendarContent from './ContentCalendarContent';
import { useOverlayClose } from '../../hooks/useOverlayClose';
type TabType = 'basic' | 'payment' | 'business'; type TabType = MyInfoTab;
interface MyInfoContentProps { interface MyInfoContentProps {
initialTab?: TabType; initialTab?: TabType;
onNavigate?: (id: string) => void;
} }
const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => { const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab, onNavigate }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<TabType>(initialTab || 'business'); const [activeTab, setActiveTab] = useState<TabType>(initialTab || 'contents');
const [businessUrl, setBusinessUrl] = useState(''); const [businessUrl, setBusinessUrl] = useState('');
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]); const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
const [isLoadingAccounts, setIsLoadingAccounts] = useState(false); const [isLoadingAccounts, setIsLoadingAccounts] = useState(false);
@ -113,20 +118,33 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
const hasConnectedAccounts = youtubeAccounts.length > 0 || instagramAccounts.length > 0; const hasConnectedAccounts = youtubeAccounts.length > 0 || instagramAccounts.length > 0;
const tabs = [ 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: 'payment' as TabType, label: t('myInfo.tabPayment') },
{ id: 'business' as TabType, label: t('myInfo.tabBusiness') }, { 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 ( return (
<> <>
{showChargePopup && ( {showChargePopup && (
<div className="myinfo-popup-overlay" onClick={() => { setShowChargePopup(false); setChargeSuccess(false); setChargeAmount(''); setChargeNote(''); }}> <div className="myinfo-popup-overlay" {...chargePopupOverlayHandlers}>
<div className="myinfo-popup" onClick={e => e.stopPropagation()}> <div className="myinfo-popup" onClick={e => e.stopPropagation()}>
{chargeSuccess ? ( {chargeSuccess ? (
<> <>
<p className="myinfo-popup-message">{t('myInfo.chargeSuccess')}</p> <p className="myinfo-popup-message">{t('myInfo.chargeSuccess')}</p>
<button className="myinfo-popup-close" onClick={() => { setShowChargePopup(false); setChargeSuccess(false); setChargeAmount(''); setChargeNote(''); }}>{t('myInfo.chargeConfirm')}</button> <button className="myinfo-popup-close" onClick={closeChargePopup}>{t('myInfo.chargeConfirm')}</button>
</> </>
) : ( ) : (
<> <>
@ -187,7 +205,7 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
</div> </div>
</div> </div>
)} )}
<main className="myinfo-page"> <main className={`myinfo-page ${isWideTab ? 'myinfo-page--wide' : ''}`}>
<h1 className="myinfo-title">{t('myInfo.title')}</h1> <h1 className="myinfo-title">{t('myInfo.title')}</h1>
{/* 탭 네비게이션 */} {/* 탭 네비게이션 */}
@ -205,11 +223,13 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
{/* 탭 컨텐츠 */} {/* 탭 컨텐츠 */}
<div className="myinfo-content"> <div className="myinfo-content">
{/* {activeTab === 'basic' && ( {activeTab === 'contents' && (
<div className="myinfo-section"> <MyContentsPage onNavigate={handleChildNavigate} embedded />
<p className="myinfo-placeholder">{t('myInfo.basicPlaceholder')}</p> )}
</div>
)} */} {activeTab === 'calendar' && (
<ContentCalendarContent onNavigate={handleChildNavigate} embedded />
)}
{activeTab === 'payment' && ( {activeTab === 'payment' && (
<div className="myinfo-section"> <div className="myinfo-section">

View File

@ -458,27 +458,33 @@
} }
} }
/* 모바일: 하단 탭바에 가리지 않도록 띄운다 */
@media (max-width: 767px) {
.bottom-button-container {
bottom: calc(var(--bottom-nav-height) + 16px);
}
}
/* ===================================================== /* =====================================================
Sidebar Components Sidebar Components
===================================================== */ ===================================================== */
/* Sidebar Container */ /* Sidebar Container */
.sidebar { .sidebar {
position: fixed; position: sticky;
top: 0;
height: 100vh; height: 100vh;
flex-shrink: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: var(--color-bg-dark); background-color: var(--color-bg-dark);
border-right: 1px solid var(--color-border-white-5); border-right: 1px solid var(--color-border-white-5);
transition: all var(--transition-slow);
z-index: 50; z-index: 50;
} }
@media (min-width: 768px) { @media (max-width: 767px) {
.sidebar { .sidebar {
position: sticky; display: none;
top: 0;
flex-shrink: 0;
} }
} }
@ -486,24 +492,6 @@
width: 15rem; 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 */
.sidebar-header { .sidebar-header {
padding: 1.25rem; padding: 1.25rem;
@ -512,11 +500,6 @@
justify-content: space-between; justify-content: space-between;
} }
.sidebar-header.collapsed {
flex-direction: column;
gap: 1rem;
}
/* Sidebar Logo */ /* Sidebar Logo */
.sidebar-logo { .sidebar-logo {
font-family: 'Playfair Display', serif; font-family: 'Playfair Display', serif;
@ -555,15 +538,6 @@
overflow: hidden; overflow: hidden;
} }
.sidebar-item.collapsed {
justify-content: center;
width: 3rem;
height: 3rem;
margin-left: auto;
margin-right: auto;
padding: 0;
}
.sidebar-item.active { .sidebar-item.active {
background-color: var(--color-mint); background-color: var(--color-mint);
color: var(--color-bg-dark); color: var(--color-bg-dark);
@ -677,10 +651,6 @@
padding: 0 0.5rem; padding: 0 0.5rem;
} }
.profile-section.collapsed {
flex-direction: column;
}
.profile-avatar { .profile-avatar {
width: 2.5rem; width: 2.5rem;
height: 2.5rem; height: 2.5rem;
@ -778,25 +748,6 @@
transform: translateX(calc(100% + 2px)); 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 */
.sidebar-footer-actions { .sidebar-footer-actions {
display: flex; display: flex;
@ -825,11 +776,6 @@
color: var(--color-text-white); color: var(--color-text-white);
} }
.sidebar-inquiry-btn.collapsed {
justify-content: center;
padding: 0.75rem;
}
/* Logout Button */ /* Logout Button */
.logout-btn { .logout-btn {
width: 100%; width: 100%;
@ -848,100 +794,143 @@
color: var(--color-text-white); color: var(--color-text-white);
} }
.logout-btn.collapsed {
justify-content: center;
}
.logout-btn-label { .logout-btn-label {
font-size: var(--text-sm); font-size: var(--text-sm);
font-weight: 700; font-weight: 700;
} }
/* 모바일 전용 사이드바 튜토리얼 토글 */ /* Settings Panel (Sidebar 4번째 메뉴 / BottomNav 설정 버튼 공용) */
.sidebar-tutorial-btn { .settings-panel {
display: none; 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;
} }
@media (max-width: 767px) { .settings-panel-profile {
.sidebar-tutorial-btn { 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; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
width: 100%; padding: 0.625rem 0.75rem;
margin-top: 0.5rem;
padding: 0.75rem;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
color: var(--color-text-gray-400); color: var(--color-text-gray-400);
border: none;
background: none; background: none;
border: none;
cursor: pointer; cursor: pointer;
font-size: var(--text-sm); font-size: var(--text-sm);
font-weight: 500; font-weight: 700;
text-decoration: none;
text-align: left;
width: 100%;
transition: color var(--transition-normal), background-color var(--transition-normal); transition: color var(--transition-normal), background-color var(--transition-normal);
} }
.sidebar-tutorial-btn:hover { .settings-panel-item:hover {
color: var(--color-text-white); color: var(--color-text-white);
background-color: rgba(255, 255, 255, 0.05); background-color: rgba(255, 255, 255, 0.05);
} }
.sidebar-tutorial-btn.active { .settings-panel-item.active {
color: var(--color-mint); color: var(--color-mint);
} }
.sidebar-tutorial-label { .settings-panel-item svg {
text-align: left; flex-shrink: 0;
} }
.sidebar-tutorial-badge { .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) {
.bottom-nav {
display: flex;
}
.has-bottom-nav {
padding-bottom: var(--bottom-nav-height);
}
}
.bottom-nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
}
.sidebar-tutorial-badge.on {
color: var(--color-mint);
}
.sidebar-tutorial-badge.off {
color: var(--color-text-gray-400);
}
}
/* 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);
color: var(--color-text-gray-400); color: var(--color-text-gray-400);
background: none;
border: none;
cursor: pointer; cursor: pointer;
} }
.mobile-menu-btn:hover { .bottom-nav-item.active {
color: var(--color-text-white); color: var(--color-mint);
}
@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;
}
} }

View File

@ -933,13 +933,12 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
}
body:has(.sidebar.collapsed) .asset-sticky-footer { /* 모바일: 하단 탭바에 가리지 않도록 띄운다 */
left: 5rem; @media (max-width: 767px) {
right: 0; .asset-sticky-footer {
width: auto; bottom: calc(var(--bottom-nav-height) + 16px);
display: flex;
justify-content: center;
} }
} }

View File

@ -6,6 +6,10 @@
CSS Variables - Design Tokens CSS Variables - Design Tokens
===================================================== */ ===================================================== */
:root { :root {
/* 모바일 하단 탭바 높이 (safe-area 포함).
탭바 자신과, 탭바에 가리면 안 되는 fixed 요소(캘린더 바텀시트 등)가 공유한다 */
--bottom-nav-height: calc(64px + env(safe-area-inset-bottom));
/* Primary Colors */ /* Primary Colors */
--color-mint: #a6ffea; --color-mint: #a6ffea;
--color-mint-hover: #8affda; --color-mint-hover: #8affda;

16
src/utils/useIsMobile.ts Normal file
View File

@ -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;
}