feat: 모바일 하단 내비게이션·설정 패널 도입
This commit is contained in:
parent
bb0ab91bcc
commit
54e5d14127
67
src/components/BottomNav.tsx
Normal file
67
src/components/BottomNav.tsx
Normal 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;
|
||||||
141
src/components/SettingsPanel.tsx
Normal file
141
src/components/SettingsPanel.tsx
Normal 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;
|
||||||
@ -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);
|
|
||||||
if (window.innerWidth < 768) {
|
|
||||||
setIsMobileOpen(false);
|
|
||||||
}
|
}
|
||||||
|
onNavigate(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
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
|
<img
|
||||||
onClick={() => setIsMobileOpen(true)}
|
onClick={onLogoClick}
|
||||||
className="mobile-menu-btn"
|
src="/assets/images/ado2-sidebar-logo.svg"
|
||||||
>
|
alt="ADO2"
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
className="sidebar-logo"
|
||||||
<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)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Sidebar */}
|
<div className="sidebar-menu no-scrollbar">
|
||||||
<div className={`sidebar ${isCollapsed ? 'collapsed' : 'expanded'} ${isMobileOpen ? 'mobile-open' : 'mobile-closed'}`}>
|
{TOP_NAV_ITEMS.map(item => (
|
||||||
<div className={`sidebar-header ${isCollapsed ? 'collapsed' : ''}`}>
|
<SidebarItem
|
||||||
{!isCollapsed && (
|
key={item.id}
|
||||||
<img
|
icon={item.icon}
|
||||||
onClick={onHome}
|
label={t(item.labelKey)}
|
||||||
src="/assets/images/ado2-sidebar-logo.svg"
|
isActive={activeItem === item.id}
|
||||||
alt="ADO2"
|
onClick={() => handleItemClick(item.id)}
|
||||||
className="sidebar-logo"
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
<div className="sidebar-settings-anchor" ref={settingsAnchorRef}>
|
||||||
|
<SidebarItem
|
||||||
|
icon={SETTINGS_NAV_ITEM.icon}
|
||||||
|
label={t(SETTINGS_NAV_ITEM.labelKey)}
|
||||||
|
isActive={settingsOpen}
|
||||||
|
onClick={() => handleItemClick(SETTINGS_NAV_ITEM.id)}
|
||||||
|
/>
|
||||||
|
{settingsOpen && (
|
||||||
|
<SettingsPanel
|
||||||
|
variant="sidebar"
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
activeItem={activeItem}
|
||||||
|
userInfo={userInfo}
|
||||||
|
credits={credits}
|
||||||
|
isGuest={isGuest}
|
||||||
|
isLoggingOut={isLoggingOut}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
onLoginClick={onLoginClick}
|
||||||
|
anchorRef={settingsAnchorRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<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 className="sidebar-menu no-scrollbar">
|
|
||||||
{menuItems.map(item => (
|
|
||||||
<SidebarItem
|
|
||||||
key={item.id}
|
|
||||||
id={
|
|
||||||
item.id === '내 정보'
|
|
||||||
? 'sidebar-my-info'
|
|
||||||
: item.id === 'ADO2 콘텐츠'
|
|
||||||
? 'sidebar-ado2-contents'
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
icon={item.icon}
|
|
||||||
label={item.label}
|
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
isActive={activeItem === item.id}
|
|
||||||
isDisabled={item.disabled}
|
|
||||||
onClick={() => handleNavigate(item.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="sidebar-footer">
|
|
||||||
{/* 모바일 전용 튜토리얼 토글 */}
|
|
||||||
{tutorialAvailable && (
|
|
||||||
<button
|
|
||||||
className={`sidebar-tutorial-btn ${tutorialEnabled ? 'active' : ''}`}
|
|
||||||
onClick={() => onToggleTutorial?.()}
|
|
||||||
title={tutorialEnabled ? t('sidebar.tutorialOff') : t('sidebar.tutorialOn')}
|
|
||||||
>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
||||||
<circle cx="12" cy="12" r="10"/>
|
|
||||||
<path d="M12 8v4l3 3"/>
|
|
||||||
</svg>
|
|
||||||
<span className="sidebar-tutorial-label">{t('sidebar.tutorial')}</span>
|
|
||||||
<span className={`sidebar-tutorial-badge ${tutorialEnabled ? 'on' : 'off'}`}>
|
|
||||||
{tutorialEnabled ? 'ON' : 'OFF'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="sidebar-language-switch">
|
|
||||||
<LanguageSwitch isCollapsed={isCollapsed} />
|
|
||||||
</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 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>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
93
src/components/navItems.tsx
Normal file
93
src/components/navItems.tsx
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/hooks/useOverlayClose.ts
Normal file
24
src/hooks/useOverlayClose.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -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,26 +756,28 @@ 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',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ width: '100%', maxWidth: 1440, paddingBottom: 32, flexShrink: 0 }}>
|
{!embedded && (
|
||||||
<p style={{
|
<div style={{ width: '100%', maxWidth: 1440, paddingBottom: 32, flexShrink: 0 }}>
|
||||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
|
<p style={{
|
||||||
color: '#ffffff', letterSpacing: '-0.18px', lineHeight: 1.3, margin: 0,
|
fontFamily: 'Pretendard, sans-serif', fontWeight: 700, fontSize: 30,
|
||||||
}}>
|
color: '#ffffff', letterSpacing: '-0.18px', lineHeight: 1.3, margin: 0,
|
||||||
{t('contentCalendar.title')}
|
}}>
|
||||||
</p>
|
{t('contentCalendar.title')}
|
||||||
</div>
|
</p>
|
||||||
|
</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={{
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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 {
|
||||||
|
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;
|
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) {
|
@media (max-width: 767px) {
|
||||||
.sidebar-tutorial-btn {
|
.bottom-nav {
|
||||||
display: flex;
|
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 {
|
.has-bottom-nav {
|
||||||
color: var(--color-text-white);
|
padding-bottom: var(--bottom-nav-height);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Menu Button */
|
.bottom-nav-item {
|
||||||
.mobile-menu-btn {
|
flex: 1;
|
||||||
position: fixed;
|
display: flex;
|
||||||
top: 1rem;
|
flex-direction: column;
|
||||||
right: 1rem;
|
align-items: center;
|
||||||
z-index: 40;
|
justify-content: center;
|
||||||
padding: 0.625rem;
|
gap: 4px;
|
||||||
background-color: var(--color-bg-card);
|
font-size: 11px;
|
||||||
border-radius: var(--radius-lg);
|
font-weight: 700;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
16
src/utils/useIsMobile.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user