From d7872f751cff21184178e5820f1d44e8e4687f36 Mon Sep 17 00:00:00 2001 From: ddingjoo Date: Tue, 6 Jan 2026 12:48:58 +0900 Subject: [PATCH 01/70] =?UTF-8?q?[FEAT]=20=EB=A9=94=EC=9D=B8=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Header: 로고, 네비게이션, 프로필 드롭다운, 알림, 다크모드 토글 - Sidebar: 학습 모드 메뉴, 접기/펼치기, 반응형 - Footer: 저작권, 링크 - MainLayout: Header + Sidebar + Content + Footer 통합 - ThemeContext: 다크모드 지원 - Dashboard: 학습 모드 카드 UI --- src/App.jsx | 225 ++++++++++++++++--- src/contexts/ThemeContext.jsx | 79 +++++++ src/layouts/MainLayout/Footer/index.jsx | 71 ++++++ src/layouts/MainLayout/Header/index.jsx | 256 ++++++++++++++++++++++ src/layouts/MainLayout/Sidebar/index.jsx | 262 +++++++++++++++++++++++ src/layouts/MainLayout/index.jsx | 95 ++++++++ src/main.jsx | 6 +- src/theme/theme.js | 101 ++++++--- 8 files changed, 1034 insertions(+), 61 deletions(-) create mode 100644 src/contexts/ThemeContext.jsx create mode 100644 src/layouts/MainLayout/Footer/index.jsx create mode 100644 src/layouts/MainLayout/Header/index.jsx create mode 100644 src/layouts/MainLayout/Sidebar/index.jsx create mode 100644 src/layouts/MainLayout/index.jsx diff --git a/src/App.jsx b/src/App.jsx index 9ab39ae..24d742d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,40 +1,190 @@ import { Routes, Route } from 'react-router-dom' -import { Box, Typography, Container, Button, Stack } from '@mui/material' +import { Box, Typography, Container, Card, CardContent, Grid, Button } from '@mui/material' +import { + RecordVoiceOver as InterviewIcon, + Headphones as OpicIcon, + Chat as FreetalkIcon, + Edit as WritingIcon, +} from '@mui/icons-material' +import MainLayout from './layouts/MainLayout' + +// 임시 대시보드 페이지 +function Dashboard() { + const learningModes = [ + { + id: 'interview', + title: '면접 시뮬레이션', + description: 'AI 면접관과 실전처럼 연습하세요', + icon: InterviewIcon, + color: '#0124ac', + path: '/interview', + }, + { + id: 'opic', + title: 'OPIC 연습', + description: '레벨별 맞춤 문제로 실력 향상', + icon: OpicIcon, + color: '#2196f3', + path: '/opic', + }, + { + id: 'freetalk', + title: '프리토킹', + description: 'AI와 자유롭게 영어로 대화', + icon: FreetalkIcon, + color: '#4caf50', + path: '/freetalk', + }, + { + id: 'writing', + title: '작문 연습', + description: '문법 교정과 표현 피드백', + icon: WritingIcon, + color: '#ff9800', + path: '/writing', + }, + ] -function Home() { return ( - - - Welcome to FE Repository + + + 안녕하세요! 👋 - - React + Vite + MUI 프로젝트가 준비되었습니다. + + 오늘은 어떤 학습을 해볼까요? + + + + {learningModes.map((mode) => { + const Icon = mode.icon + return ( + + + + + + + + {mode.title} + + + {mode.description} + + + + + ) + })} + - - - - - - - - - Primary Color: #0124ac - - + {/* 최근 학습 */} + + + 최근 학습 + + + + + 아직 학습 기록이 없습니다. 학습을 시작해보세요! + + + + + + ) +} + +// 임시 페이지들 +function InterviewPage() { + return ( + + 면접 시뮬레이션 + AI 면접관과 실전 연습 + + ) +} + +function OpicPage() { + return ( + + OPIC 연습 + 레벨별 맞춤 연습 + + ) +} + +function FreetalkPage() { + return ( + + 프리토킹 + AI와 자유로운 대화 + + ) +} + +function WritingPage() { + return ( + + 작문 연습 + 문법 교정 & 피드백 + + ) +} + +function ReportsPage() { + return ( + + 내 리포트 + 학습 결과 분석 + + ) +} + +function SettingsPage() { + return ( + + 설정 + 계정 및 앱 설정 + + ) +} + +function NotFound() { + return ( + + + + 404 + + + 페이지를 찾을 수 없습니다 + + ) @@ -43,7 +193,20 @@ function Home() { function App() { return ( - } /> + {/* MainLayout 적용 라우트 */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + {/* 404 */} + } /> ) } diff --git a/src/contexts/ThemeContext.jsx b/src/contexts/ThemeContext.jsx new file mode 100644 index 0000000..cad8b7b --- /dev/null +++ b/src/contexts/ThemeContext.jsx @@ -0,0 +1,79 @@ +import { createContext, useContext, useState, useEffect, useMemo } from 'react' +import { ThemeProvider as MuiThemeProvider } from '@mui/material/styles' +import { CssBaseline } from '@mui/material' +import { lightTheme, darkTheme } from '../theme/theme' + +const ThemeContext = createContext({ + mode: 'light', + toggleTheme: () => {}, + setMode: () => {}, +}) + +export const useThemeMode = () => { + const context = useContext(ThemeContext) + if (!context) { + throw new Error('useThemeMode must be used within ThemeProvider') + } + return context +} + +export const ThemeProvider = ({ children }) => { + // localStorage에서 테마 모드 불러오기 (시스템 설정 기본값) + const [mode, setMode] = useState(() => { + const savedMode = localStorage.getItem('themeMode') + if (savedMode) return savedMode + + // 시스템 다크모드 감지 + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'dark' + } + return 'light' + }) + + // 시스템 테마 변경 감지 + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + const handleChange = (e) => { + const savedMode = localStorage.getItem('themeMode') + if (!savedMode) { + setMode(e.matches ? 'dark' : 'light') + } + } + + mediaQuery.addEventListener('change', handleChange) + return () => mediaQuery.removeEventListener('change', handleChange) + }, []) + + // 테마 변경 시 localStorage 저장 + useEffect(() => { + localStorage.setItem('themeMode', mode) + // body 클래스 추가 (CSS 변수 등에서 활용) + document.body.classList.remove('light-mode', 'dark-mode') + document.body.classList.add(`${mode}-mode`) + }, [mode]) + + const toggleTheme = () => { + setMode((prev) => (prev === 'light' ? 'dark' : 'light')) + } + + const theme = useMemo(() => { + return mode === 'dark' ? darkTheme : lightTheme + }, [mode]) + + const contextValue = useMemo(() => ({ + mode, + toggleTheme, + setMode, + }), [mode]) + + return ( + + + + {children} + + + ) +} + +export default ThemeContext diff --git a/src/layouts/MainLayout/Footer/index.jsx b/src/layouts/MainLayout/Footer/index.jsx new file mode 100644 index 0000000..5a54f86 --- /dev/null +++ b/src/layouts/MainLayout/Footer/index.jsx @@ -0,0 +1,71 @@ +import { Box, Typography, Link, Container, Divider } from '@mui/material' + +const Footer = () => { + return ( + + + + {/* 저작권 */} + + © 2026 AI Language Learning. All rights reserved. + + + {/* 링크 */} + + + 이용약관 + + + 개인정보처리방침 + + + 고객센터 + + + + + + ) +} + +export default Footer diff --git a/src/layouts/MainLayout/Header/index.jsx b/src/layouts/MainLayout/Header/index.jsx new file mode 100644 index 0000000..90f60d6 --- /dev/null +++ b/src/layouts/MainLayout/Header/index.jsx @@ -0,0 +1,256 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { + AppBar, + Toolbar, + Typography, + IconButton, + Box, + Button, + Avatar, + Menu, + MenuItem, + Divider, + Badge, + useMediaQuery, + useTheme, +} from '@mui/material' +import { + Menu as MenuIcon, + Notifications as NotificationsIcon, + Person as PersonIcon, + Settings as SettingsIcon, + Logout as LogoutIcon, + School as SchoolIcon, + DarkMode as DarkModeIcon, + LightMode as LightModeIcon, +} from '@mui/icons-material' +import { useThemeMode } from '../../../contexts/ThemeContext' + +const Header = ({ onMenuClick, sidebarOpen }) => { + const theme = useTheme() + const navigate = useNavigate() + const isMobile = useMediaQuery(theme.breakpoints.down('md')) + const { mode, toggleTheme } = useThemeMode() + + const [anchorEl, setAnchorEl] = useState(null) + const [notificationAnchor, setNotificationAnchor] = useState(null) + + const handleProfileMenuOpen = (event) => { + setAnchorEl(event.currentTarget) + } + + const handleProfileMenuClose = () => { + setAnchorEl(null) + } + + const handleNotificationOpen = (event) => { + setNotificationAnchor(event.currentTarget) + } + + const handleNotificationClose = () => { + setNotificationAnchor(null) + } + + const handleLogout = () => { + handleProfileMenuClose() + // TODO: 로그아웃 로직 + navigate('/login') + } + + return ( + + + {/* 햄버거 메뉴 (모바일/태블릿) */} + {isMobile && ( + + + + )} + + {/* 로고 & 서비스명 */} + navigate('/')} + > + + + AI 언어 학습 + + + + {/* 중앙 네비게이션 (데스크톱) */} + {!isMobile && ( + + + + + + )} + + + + {/* 우측 아이콘들 */} + + {/* 다크모드 토글 */} + + {mode === 'dark' ? : } + + + {/* 알림 */} + + + + + + + {/* 프로필 */} + + + U + + + + + {/* 알림 메뉴 */} + + + + 알림 + + + + + + + 면접 연습 세션이 완료되었습니다. + + + 10분 전 + + + + + + + OPIC 모의고사 결과가 도착했습니다. + + + 1시간 전 + + + + + + + 새로운 학습 리포트가 생성되었습니다. + + + 어제 + + + + + + {/* 프로필 메뉴 */} + + + + 사용자님 + + + user@example.com + + + + { handleProfileMenuClose(); navigate('/profile'); }}> + + 내 프로필 + + { handleProfileMenuClose(); navigate('/settings'); }}> + + 설정 + + + + + 로그아웃 + + + + + ) +} + +export default Header diff --git a/src/layouts/MainLayout/Sidebar/index.jsx b/src/layouts/MainLayout/Sidebar/index.jsx new file mode 100644 index 0000000..4900e66 --- /dev/null +++ b/src/layouts/MainLayout/Sidebar/index.jsx @@ -0,0 +1,262 @@ +import { useLocation, useNavigate } from 'react-router-dom' +import { + Drawer, + Box, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + IconButton, + Divider, + Typography, + useTheme, + useMediaQuery, +} from '@mui/material' +import { + ChevronLeft as ChevronLeftIcon, + ChevronRight as ChevronRightIcon, + Dashboard as DashboardIcon, + RecordVoiceOver as InterviewIcon, + Headphones as OpicIcon, + Chat as FreetalkIcon, + Edit as WritingIcon, + Assessment as ReportIcon, + Settings as SettingsIcon, +} from '@mui/icons-material' + +const DRAWER_WIDTH = 260 +const DRAWER_WIDTH_COLLAPSED = 72 + +const menuItems = [ + { + category: '학습 모드', + items: [ + { + id: 'interview', + label: '면접 시뮬레이션', + icon: InterviewIcon, + path: '/interview', + description: 'AI 면접관과 실전 연습' + }, + { + id: 'opic', + label: 'OPIC 연습', + icon: OpicIcon, + path: '/opic', + description: '레벨별 맞춤 연습' + }, + { + id: 'freetalk', + label: '프리토킹', + icon: FreetalkIcon, + path: '/freetalk', + description: 'AI와 자유로운 대화' + }, + { + id: 'writing', + label: '작문 연습', + icon: WritingIcon, + path: '/writing', + description: '문법 교정 & 피드백' + }, + ], + }, + { + category: '기타', + items: [ + { + id: 'dashboard', + label: '대시보드', + icon: DashboardIcon, + path: '/dashboard', + description: '학습 현황 요약' + }, + { + id: 'reports', + label: '내 리포트', + icon: ReportIcon, + path: '/reports', + description: '학습 결과 분석' + }, + { + id: 'settings', + label: '설정', + icon: SettingsIcon, + path: '/settings', + description: '계정 및 앱 설정' + }, + ], + }, +] + +const Sidebar = ({ open, collapsed, onToggleCollapse, onClose }) => { + const theme = useTheme() + const location = useLocation() + const navigate = useNavigate() + const isMobile = useMediaQuery(theme.breakpoints.down('md')) + + const drawerWidth = collapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH + + const handleNavigation = (path) => { + navigate(path) + if (isMobile) { + onClose() + } + } + + const isActive = (path) => location.pathname === path + + const drawerContent = ( + + {/* 헤더 영역 - Toolbar 높이만큼 여백 */} + + + {/* 접기/펼치기 버튼 */} + {!isMobile && ( + + + {collapsed ? : } + + + )} + + {/* 메뉴 리스트 */} + + {menuItems.map((category, categoryIndex) => ( + + {!collapsed && ( + + {category.category} + + )} + + + {category.items.map((item) => { + const Icon = item.icon + const active = isActive(item.path) + + return ( + + handleNavigation(item.path)} + sx={{ + borderRadius: 2, + minHeight: 48, + justifyContent: collapsed ? 'center' : 'flex-start', + px: collapsed ? 1 : 2, + backgroundColor: active ? 'primary.main' : 'transparent', + color: active ? 'white' : 'text.primary', + '&:hover': { + backgroundColor: active + ? 'primary.dark' + : 'action.hover', + }, + }} + > + + + + + {!collapsed && ( + + )} + + + ) + })} + + + {categoryIndex < menuItems.length - 1 && !collapsed && ( + + )} + + ))} + + + {/* 하단 정보 */} + {!collapsed && ( + + + 오늘의 학습 시간 + + + 1시간 23분 + + + )} + + ) + + // 모바일: 임시 Drawer + if (isMobile) { + return ( + + {drawerContent} + + ) + } + + // 데스크톱: 고정 Drawer + return ( + + {drawerContent} + + ) +} + +export default Sidebar diff --git a/src/layouts/MainLayout/index.jsx b/src/layouts/MainLayout/index.jsx new file mode 100644 index 0000000..bc3d376 --- /dev/null +++ b/src/layouts/MainLayout/index.jsx @@ -0,0 +1,95 @@ +import { useState, useEffect } from 'react' +import { Outlet } from 'react-router-dom' +import { Box, useTheme, useMediaQuery } from '@mui/material' +import Header from './Header' +import Sidebar from './Sidebar' +import Footer from './Footer' + +const DRAWER_WIDTH = 260 +const DRAWER_WIDTH_COLLAPSED = 72 + +const MainLayout = () => { + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down('md')) + + // 모바일 사이드바 열림 상태 + const [mobileOpen, setMobileOpen] = useState(false) + + // 데스크톱 사이드바 접힘 상태 (localStorage 저장) + const [collapsed, setCollapsed] = useState(() => { + const saved = localStorage.getItem('sidebarCollapsed') + return saved ? JSON.parse(saved) : false + }) + + // collapsed 상태 localStorage 저장 + useEffect(() => { + localStorage.setItem('sidebarCollapsed', JSON.stringify(collapsed)) + }, [collapsed]) + + const handleMobileToggle = () => { + setMobileOpen(!mobileOpen) + } + + const handleMobileClose = () => { + setMobileOpen(false) + } + + const handleCollapseToggle = () => { + setCollapsed(!collapsed) + } + + const drawerWidth = isMobile ? 0 : (collapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH) + + return ( + + {/* Header */} +
+ + {/* Sidebar */} + + + {/* Main Content */} + + {/* Toolbar 높이만큼 여백 */} + + + {/* 콘텐츠 영역 */} + + + + + {/* Footer */} +