From 25123be80623d66d2033c7fb9abf4fbec1744217 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Sat, 25 Oct 2025 09:00:30 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94?= =?UTF-8?q?=EB=93=9C=20=EB=AA=A8=EB=93=88=ED=99=94=20=EB=B0=8F=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=20=ED=97=A4=EB=8D=94=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 권한 기반 공통 헤더 컴포넌트 구현 - 모바일 친화적 캘린더 날짜 필터 추가 - 페이지 프리로더 및 키보드 단축키 시스템 구현 - Service Worker 기반 캐싱 시스템 추가 Frontend Changes: - components/common-header.js: 권한 기반 동적 메뉴 생성 - components/mobile-calendar.js: 터치/스와이프 지원 캘린더 - core/permissions.js: 페이지 접근 권한 관리 - core/page-manager.js: 페이지 라이프사이클 관리 - core/page-preloader.js: 페이지 프리로딩 최적화 - core/keyboard-shortcuts.js: 키보드 네비게이션 - css/mobile-calendar.css: 모바일 최적화 캘린더 스타일 - sw.js: 3단계 캐싱 전략 서비스 워커 Removed: - auth-common.js, common-header.js (구버전 파일들) --- frontend/static/css/mobile-calendar.css | 297 +++++++++ frontend/static/js/api.js | 29 +- frontend/static/js/auth-common.js | 81 --- frontend/static/js/common-header.js | 137 ---- .../static/js/components/common-header.js | 421 ++++++++++++ .../static/js/components/mobile-calendar.js | 359 ++++++++++ frontend/static/js/core/keyboard-shortcuts.js | 621 ++++++++++++++++++ frontend/static/js/core/page-manager.js | 368 +++++++++++ frontend/static/js/core/page-preloader.js | 317 +++++++++ frontend/static/js/core/permissions.js | 260 ++++++++ frontend/sw.js | 335 ++++++++++ 11 files changed, 3003 insertions(+), 222 deletions(-) create mode 100644 frontend/static/css/mobile-calendar.css delete mode 100644 frontend/static/js/auth-common.js delete mode 100644 frontend/static/js/common-header.js create mode 100644 frontend/static/js/components/common-header.js create mode 100644 frontend/static/js/components/mobile-calendar.js create mode 100644 frontend/static/js/core/keyboard-shortcuts.js create mode 100644 frontend/static/js/core/page-manager.js create mode 100644 frontend/static/js/core/page-preloader.js create mode 100644 frontend/static/js/core/permissions.js create mode 100644 frontend/sw.js diff --git a/frontend/static/css/mobile-calendar.css b/frontend/static/css/mobile-calendar.css new file mode 100644 index 0000000..97360de --- /dev/null +++ b/frontend/static/css/mobile-calendar.css @@ -0,0 +1,297 @@ +/* 모바일 캘린더 스타일 */ +.mobile-calendar { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; +} + +/* 빠른 선택 버튼들 */ +.quick-select-buttons { + scrollbar-width: none; + -ms-overflow-style: none; +} + +.quick-select-buttons::-webkit-scrollbar { + display: none; +} + +.quick-btn { + flex-shrink: 0; + padding: 8px 16px; + background: #f3f4f6; + border: 1px solid #e5e7eb; + border-radius: 20px; + font-size: 14px; + font-weight: 500; + color: #374151; + transition: all 0.2s ease; + white-space: nowrap; +} + +.quick-btn:hover, +.quick-btn:active { + background: #3b82f6; + color: white; + border-color: #3b82f6; + transform: scale(0.95); +} + +.quick-btn.active { + background: #3b82f6; + color: white; + border-color: #3b82f6; +} + +/* 캘린더 헤더 */ +.calendar-header { + padding: 0 8px; +} + +.nav-btn { + width: 40px; + height: 40px; + border-radius: 50%; + background: #f9fafb; + border: 1px solid #e5e7eb; + display: flex; + align-items: center; + justify-content: center; + color: #6b7280; + transition: all 0.2s ease; +} + +.nav-btn:hover, +.nav-btn:active { + background: #3b82f6; + color: white; + border-color: #3b82f6; + transform: scale(0.9); +} + +.month-year { + color: #1f2937; + font-weight: 600; + min-width: 120px; + text-align: center; +} + +/* 요일 헤더 */ +.weekdays { + margin-bottom: 8px; +} + +.weekday { + text-align: center; + font-size: 12px; + font-weight: 600; + color: #6b7280; + padding: 8px 4px; + text-transform: uppercase; +} + +.weekday:first-child { + color: #ef4444; /* 일요일 빨간색 */ +} + +.weekday:last-child { + color: #3b82f6; /* 토요일 파란색 */ +} + +/* 캘린더 그리드 */ +.calendar-grid { + gap: 2px; +} + +.calendar-day { + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 500; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s ease; + position: relative; + background: white; + border: 1px solid transparent; + min-height: 40px; +} + +.calendar-day:hover { + background: #eff6ff; + border-color: #bfdbfe; + transform: scale(1.05); +} + +.calendar-day:active { + transform: scale(0.95); +} + +/* 다른 달 날짜 */ +.calendar-day.other-month { + color: #d1d5db; + background: #f9fafb; +} + +.calendar-day.other-month:hover { + background: #f3f4f6; + color: #9ca3af; +} + +/* 오늘 날짜 */ +.calendar-day.today { + background: #fef3c7; + color: #92400e; + font-weight: 700; + border-color: #f59e0b; +} + +.calendar-day.today:hover { + background: #fde68a; +} + +/* 선택된 날짜 */ +.calendar-day.selected { + background: #dbeafe; + color: #1e40af; + border-color: #3b82f6; +} + +/* 범위 시작/끝 */ +.calendar-day.range-start, +.calendar-day.range-end { + background: #3b82f6; + color: white; + font-weight: 700; +} + +.calendar-day.range-start:hover, +.calendar-day.range-end:hover { + background: #2563eb; +} + +/* 범위 시작일에 표시 */ +.calendar-day.range-start::before { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 6px; + height: 6px; + background: #10b981; + border-radius: 50%; +} + +/* 범위 끝일에 표시 */ +.calendar-day.range-end::after { + content: ''; + position: absolute; + bottom: 2px; + right: 2px; + width: 6px; + height: 6px; + background: #ef4444; + border-radius: 50%; +} + +/* 선택된 범위 표시 */ +.selected-range { + border: 1px solid #bfdbfe; + animation: slideIn 0.3s ease; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.clear-btn { + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + transition: all 0.2s ease; +} + +.clear-btn:hover { + background: rgba(59, 130, 246, 0.1); +} + +/* 사용법 안내 */ +.usage-hint { + opacity: 0.7; + line-height: 1.4; +} + +/* 터치 디바이스 최적화 */ +@media (hover: none) and (pointer: coarse) { + .calendar-day { + min-height: 44px; /* 터치 타겟 최소 크기 */ + } + + .nav-btn { + min-width: 44px; + min-height: 44px; + } + + .quick-btn { + min-height: 44px; + padding: 12px 16px; + } +} + +/* 작은 화면 최적화 */ +@media (max-width: 375px) { + .calendar-day { + font-size: 13px; + min-height: 36px; + } + + .quick-btn { + padding: 6px 12px; + font-size: 13px; + } + + .month-year { + font-size: 16px; + } +} + +/* 다크 모드 지원 */ +@media (prefers-color-scheme: dark) { + .mobile-calendar { + color: #f9fafb; + } + + .calendar-day { + background: #374151; + color: #f9fafb; + } + + .calendar-day:hover { + background: #4b5563; + } + + .nav-btn { + background: #374151; + color: #f9fafb; + border-color: #4b5563; + } + + .quick-btn { + background: #374151; + color: #f9fafb; + border-color: #4b5563; + } +} diff --git a/frontend/static/js/api.js b/frontend/static/js/api.js index ea622ed..bd15cb4 100644 --- a/frontend/static/js/api.js +++ b/frontend/static/js/api.js @@ -141,7 +141,10 @@ const AuthAPI = { body: JSON.stringify(userData) }), - getUsers: () => apiRequest('/auth/users'), + getUsers: () => { + console.log('🔍 AuthAPI.getUsers 호출 - 엔드포인트: /auth/users'); + return apiRequest('/auth/users'); + }, updateUser: (userId, userData) => apiRequest(`/auth/users/${userId}`, { method: 'PUT', @@ -160,10 +163,10 @@ const AuthAPI = { }) }), - resetPassword: (userId, newPassword = '000000') => apiRequest(`/auth/users/${userId}`, { - method: 'PUT', + resetPassword: (userId, newPassword = '000000') => apiRequest(`/auth/users/${userId}/reset-password`, { + method: 'POST', body: JSON.stringify({ - password: newPassword + new_password: newPassword }) }) }; @@ -281,6 +284,24 @@ function checkAdminAuth() { return user; } +// 페이지 접근 권한 체크 함수 (새로 추가) +function checkPageAccess(pageName) { + const user = checkAuth(); + if (!user) return null; + + // admin은 모든 페이지 접근 가능 + if (user.role === 'admin') return user; + + // 페이지별 권한 체크는 pagePermissionManager에서 처리 + if (window.pagePermissionManager && !window.pagePermissionManager.canAccessPage(pageName)) { + alert('이 페이지에 접근할 권한이 없습니다.'); + window.location.href = '/index.html'; + return null; + } + + return user; +} + // 프로젝트 API const ProjectsAPI = { getAll: (activeOnly = false) => { diff --git a/frontend/static/js/auth-common.js b/frontend/static/js/auth-common.js deleted file mode 100644 index 8443d7d..0000000 --- a/frontend/static/js/auth-common.js +++ /dev/null @@ -1,81 +0,0 @@ -// 공통 인증 및 네비게이션 관리 -class AuthCommon { - static init(currentPage = '') { - // 토큰 기반 사용자 정보 확인 - const user = TokenManager.getUser(); - if (!user) { - window.location.href = 'index.html'; - return null; - } - - // 전역 currentUser 설정 - window.currentUser = user; - - // 헤더 생성 (페이지별로 다른 active 상태) - CommonHeader.init(currentPage); - - // 사용자 정보 표시 - this.updateUserDisplay(user); - - // 네비게이션 권한 업데이트 - this.updateNavigation(user); - - return user; - } - - static updateUserDisplay(user) { - const userDisplayElement = document.getElementById('userDisplay'); - if (userDisplayElement) { - const displayName = user.full_name || user.username; - userDisplayElement.textContent = `${displayName} (${user.username})`; - } - } - - static updateNavigation(user) { - const isAdmin = user.role === 'admin'; - - // 관리자 전용 메뉴들 - const adminMenus = [ - 'dailyWorkBtn', - 'listBtn', - 'summaryBtn', - 'projectBtn' - ]; - - adminMenus.forEach(menuId => { - const element = document.getElementById(menuId); - if (element) { - element.style.display = isAdmin ? '' : 'none'; - } - }); - - // 관리자 버튼 처리 (드롭다운 vs 단순 버튼) - const adminBtnContainer = document.getElementById('adminBtnContainer'); - const userPasswordBtn = document.getElementById('userPasswordBtn'); - - if (isAdmin) { - // 관리자: 드롭다운 메뉴 표시 - if (adminBtnContainer) adminBtnContainer.style.display = ''; - if (userPasswordBtn) userPasswordBtn.style.display = 'none'; - } else { - // 일반 사용자: 비밀번호 변경 버튼만 표시 - if (adminBtnContainer) adminBtnContainer.style.display = 'none'; - if (userPasswordBtn) userPasswordBtn.style.display = ''; - } - } - - static logout() { - AuthAPI.logout(); - } -} - -// 전역 함수들 -function logout() { - AuthCommon.logout(); -} - -function showSection(sectionName) { - if (typeof window.showSection === 'function') { - window.showSection(sectionName); - } -} diff --git a/frontend/static/js/common-header.js b/frontend/static/js/common-header.js deleted file mode 100644 index a0e7fdf..0000000 --- a/frontend/static/js/common-header.js +++ /dev/null @@ -1,137 +0,0 @@ -// 공통 헤더 생성 및 관리 -class CommonHeader { - static create(currentPage = '') { - return ` - -
-
-
-

- 작업보고서 -

-
- - -
-
-
-
- - - - `; - } - - static getNavButton(href, id, iconClass, text, isActive = false) { - const activeClass = isActive ? ' active' : ''; - return ` - ${text} - `; - } - - static getNavButtonInternal(section, id, iconClass, text, isActive = false) { - const activeClass = isActive ? ' active' : ''; - if (section === 'list' || section === 'summary') { - return ``; - } - return ``; - } - - static init(currentPage = '') { - // 헤더 HTML 삽입 - const headerContainer = document.getElementById('header-container'); - if (headerContainer) { - headerContainer.innerHTML = this.create(); - } - - // 현재 페이지 활성화 - this.setActivePage(currentPage); - } - - static setActivePage(currentPage) { - // 모든 nav-link에서 active 클래스 제거 - document.querySelectorAll('.nav-link').forEach(link => { - link.classList.remove('active'); - }); - - // 현재 페이지에 active 클래스 추가 - const activeElement = document.getElementById(currentPage); - if (activeElement) { - activeElement.classList.add('active'); - } - } -} - -// 관리자 버튼 클릭 처리 (전역 함수) -function handleAdminClick() { - if (window.currentUser && window.currentUser.role === 'admin') { - window.location.href = 'admin.html'; - } else { - // 비밀번호 변경 모달 표시 - if (typeof showPasswordChangeModal === 'function') { - showPasswordChangeModal(); - } - } -} - -// 섹션 전환 (메인 페이지용) -function showSection(sectionName) { - if (typeof window.showSection === 'function') { - window.showSection(sectionName); - } -} - -// 관리자 메뉴 토글 -function toggleAdminMenu() { - const menu = document.getElementById('adminMenu'); - if (menu) { - menu.classList.toggle('hidden'); - } -} - -// 메뉴 외부 클릭 시 닫기 -document.addEventListener('click', function(event) { - const adminBtn = document.getElementById('adminBtn'); - const adminMenu = document.getElementById('adminMenu'); - - if (adminBtn && adminMenu && !adminBtn.contains(event.target) && !adminMenu.contains(event.target)) { - adminMenu.classList.add('hidden'); - } -}); diff --git a/frontend/static/js/components/common-header.js b/frontend/static/js/components/common-header.js new file mode 100644 index 0000000..e648476 --- /dev/null +++ b/frontend/static/js/components/common-header.js @@ -0,0 +1,421 @@ +/** + * 공통 헤더 컴포넌트 + * 권한 기반으로 메뉴를 동적으로 생성하고 부드러운 페이지 전환을 제공 + */ + +class CommonHeader { + constructor() { + this.currentUser = null; + this.currentPage = ''; + this.menuItems = this.initMenuItems(); + } + + /** + * 메뉴 아이템 정의 + */ + initMenuItems() { + return [ + { + id: 'daily_work', + title: '일일 공수', + icon: 'fas fa-calendar-check', + url: '/daily-work.html', + pageName: 'daily_work', + color: 'text-blue-600', + bgColor: 'bg-blue-50 hover:bg-blue-100' + }, + { + id: 'issues_create', + title: '부적합 등록', + icon: 'fas fa-plus-circle', + url: '/index.html', + pageName: 'issues_create', + color: 'text-green-600', + bgColor: 'bg-green-50 hover:bg-green-100' + }, + { + id: 'issues_view', + title: '부적합 조회', + icon: 'fas fa-search', + url: '/issue-view.html', + pageName: 'issues_view', + color: 'text-purple-600', + bgColor: 'bg-purple-50 hover:bg-purple-100' + }, + { + id: 'issues_manage', + title: '목록 관리', + icon: 'fas fa-tasks', + url: '/issue-view.html#manage', + pageName: 'issues_manage', + color: 'text-orange-600', + bgColor: 'bg-orange-50 hover:bg-orange-100' + }, + { + id: 'reports', + title: '보고서', + icon: 'fas fa-chart-bar', + url: '/reports.html', + pageName: 'reports', + color: 'text-red-600', + bgColor: 'bg-red-50 hover:bg-red-100' + }, + { + id: 'projects_manage', + title: '프로젝트 관리', + icon: 'fas fa-folder-open', + url: '/project-management.html', + pageName: 'projects_manage', + color: 'text-indigo-600', + bgColor: 'bg-indigo-50 hover:bg-indigo-100' + }, + { + id: 'users_manage', + title: '사용자 관리', + icon: 'fas fa-users-cog', + url: '/admin.html', + pageName: 'users_manage', + color: 'text-gray-600', + bgColor: 'bg-gray-50 hover:bg-gray-100' + } + ]; + } + + /** + * 헤더 초기화 + * @param {Object} user - 현재 사용자 정보 + * @param {string} currentPage - 현재 페이지 ID + */ + async init(user, currentPage = '') { + this.currentUser = user; + this.currentPage = currentPage; + + // 권한 시스템이 로드될 때까지 대기 + await this.waitForPermissionSystem(); + + this.render(); + this.bindEvents(); + + // 키보드 단축키 초기화 + this.initializeKeyboardShortcuts(); + + // 페이지 프리로더 초기화 + this.initializePreloader(); + } + + /** + * 권한 시스템 로드 대기 + */ + async waitForPermissionSystem() { + let attempts = 0; + const maxAttempts = 50; // 5초 대기 + + while (!window.pagePermissionManager && attempts < maxAttempts) { + await new Promise(resolve => setTimeout(resolve, 100)); + attempts++; + } + + if (window.pagePermissionManager && this.currentUser) { + window.pagePermissionManager.setUser(this.currentUser); + // 권한 로드 대기 + await new Promise(resolve => setTimeout(resolve, 300)); + } + } + + /** + * 헤더 렌더링 + */ + render() { + const headerHTML = this.generateHeaderHTML(); + + // 기존 헤더가 있으면 교체, 없으면 body 상단에 추가 + let headerContainer = document.getElementById('common-header'); + if (headerContainer) { + headerContainer.innerHTML = headerHTML; + } else { + headerContainer = document.createElement('div'); + headerContainer.id = 'common-header'; + headerContainer.innerHTML = headerHTML; + document.body.insertBefore(headerContainer, document.body.firstChild); + } + } + + /** + * 헤더 HTML 생성 + */ + generateHeaderHTML() { + const accessibleMenus = this.getAccessibleMenus(); + const userDisplayName = this.currentUser?.full_name || this.currentUser?.username || '사용자'; + const userRole = this.getUserRoleDisplay(); + + return ` +
+
+
+ +
+
+ +

작업보고서

+
+
+ + + + + +
+ +
+
+
${userDisplayName}
+
${userRole}
+
+
+ + ${userDisplayName.charAt(0).toUpperCase()} + +
+
+ + + + + + +
+
+ + + +
+
+ `; + } + + /** + * 접근 가능한 메뉴 필터링 + */ + getAccessibleMenus() { + return this.menuItems.filter(menu => { + // admin은 모든 메뉴 접근 가능 + if (this.currentUser?.role === 'admin') { + return true; + } + + // 권한 시스템이 로드되지 않았으면 기본 메뉴만 + if (!window.canAccessPage) { + return ['issues_create', 'issues_view'].includes(menu.id); + } + + return window.canAccessPage(menu.pageName); + }); + } + + /** + * 데스크톱 메뉴 아이템 HTML 생성 + */ + generateMenuItemHTML(menu) { + const isActive = this.currentPage === menu.id; + const activeClass = isActive ? 'bg-blue-100 text-blue-700' : `${menu.bgColor} ${menu.color}`; + + return ` + + + ${menu.title} + + `; + } + + /** + * 모바일 메뉴 아이템 HTML 생성 + */ + generateMobileMenuItemHTML(menu) { + const isActive = this.currentPage === menu.id; + const activeClass = isActive ? 'bg-blue-50 text-blue-700 border-blue-500' : 'text-gray-700 hover:bg-gray-50'; + + return ` + + + ${menu.title} + + `; + } + + /** + * 사용자 역할 표시명 가져오기 + */ + getUserRoleDisplay() { + const roleNames = { + 'admin': '관리자', + 'user': '사용자' + }; + return roleNames[this.currentUser?.role] || '사용자'; + } + + /** + * 이벤트 바인딩 + */ + bindEvents() { + // 사용자 메뉴 토글 + const userMenuButton = document.getElementById('user-menu-button'); + const userMenu = document.getElementById('user-menu'); + + if (userMenuButton && userMenu) { + userMenuButton.addEventListener('click', (e) => { + e.stopPropagation(); + userMenu.classList.toggle('hidden'); + }); + + // 외부 클릭 시 메뉴 닫기 + document.addEventListener('click', () => { + userMenu.classList.add('hidden'); + }); + } + + // 모바일 메뉴 토글 + const mobileMenuButton = document.getElementById('mobile-menu-button'); + const mobileMenu = document.getElementById('mobile-menu'); + + if (mobileMenuButton && mobileMenu) { + mobileMenuButton.addEventListener('click', () => { + mobileMenu.classList.toggle('hidden'); + }); + } + } + + /** + * 페이지 네비게이션 (부드러운 전환) + */ + static navigateToPage(event, url, pageId) { + event.preventDefault(); + + // 현재 페이지와 같으면 무시 + if (window.commonHeader?.currentPage === pageId) { + return; + } + + // 로딩 표시 + CommonHeader.showPageTransition(); + + // 페이지 이동 + setTimeout(() => { + window.location.href = url; + }, 150); // 부드러운 전환을 위한 딜레이 + } + + /** + * 페이지 전환 로딩 표시 + */ + static showPageTransition() { + // 기존 로딩이 있으면 제거 + const existingLoader = document.getElementById('page-transition-loader'); + if (existingLoader) { + existingLoader.remove(); + } + + const loader = document.createElement('div'); + loader.id = 'page-transition-loader'; + loader.className = 'fixed inset-0 bg-white bg-opacity-75 flex items-center justify-center z-50'; + loader.innerHTML = ` +
+
+

페이지를 로드하는 중...

+
+ `; + + document.body.appendChild(loader); + } + + /** + * 비밀번호 변경 모달 표시 + */ + static showPasswordModal() { + // 비밀번호 변경 모달 구현 (기존 코드 재사용) + alert('비밀번호 변경 기능은 관리자 페이지에서 이용해주세요.'); + } + + /** + * 로그아웃 + */ + static logout() { + if (confirm('로그아웃 하시겠습니까?')) { + localStorage.removeItem('access_token'); + localStorage.removeItem('currentUser'); + window.location.href = '/index.html'; + } + } + + /** + * 현재 페이지 업데이트 + */ + updateCurrentPage(pageId) { + this.currentPage = pageId; + + // 활성 메뉴 업데이트 + document.querySelectorAll('.nav-item').forEach(item => { + const itemPageId = item.getAttribute('data-page'); + if (itemPageId === pageId) { + item.classList.add('bg-blue-100', 'text-blue-700'); + item.classList.remove('bg-blue-50', 'hover:bg-blue-100'); + } else { + item.classList.remove('bg-blue-100', 'text-blue-700'); + } + }); + } + + /** + * 키보드 단축키 초기화 + */ + initializeKeyboardShortcuts() { + if (window.keyboardShortcuts) { + window.keyboardShortcuts.setUser(this.currentUser); + console.log('⌨️ 키보드 단축키 사용자 설정 완료'); + } + } + + /** + * 페이지 프리로더 초기화 + */ + initializePreloader() { + if (window.pagePreloader) { + // 사용자 설정 후 프리로더 초기화 + setTimeout(() => { + window.pagePreloader.init(); + console.log('🚀 페이지 프리로더 초기화 완료'); + }, 1000); // 권한 시스템 로드 후 실행 + } + } +} + +// 전역 인스턴스 +window.commonHeader = new CommonHeader(); + +// 전역 함수로 노출 +window.CommonHeader = CommonHeader; diff --git a/frontend/static/js/components/mobile-calendar.js b/frontend/static/js/components/mobile-calendar.js new file mode 100644 index 0000000..9d6b808 --- /dev/null +++ b/frontend/static/js/components/mobile-calendar.js @@ -0,0 +1,359 @@ +/** + * 모바일 친화적 캘린더 컴포넌트 + * 터치 및 스와이프 지원, 날짜 범위 선택 기능 + */ + +class MobileCalendar { + constructor(containerId, options = {}) { + this.container = document.getElementById(containerId); + this.options = { + locale: 'ko-KR', + startDate: null, + endDate: null, + maxRange: 90, // 최대 90일 범위 + onDateSelect: null, + onRangeSelect: null, + ...options + }; + + this.currentDate = new Date(); + this.selectedStartDate = null; + this.selectedEndDate = null; + this.isSelecting = false; + this.touchStartX = 0; + this.touchStartY = 0; + + this.init(); + } + + init() { + this.render(); + this.bindEvents(); + } + + render() { + const calendarHTML = ` +
+ +
+
+ + + + + + +
+
+ + +
+ +

+ +
+ + +
+
+
+
+
+
+
+
+
+ + +
+ +
+ + + + + +
+ 📅 날짜를 터치하여 시작일을 선택하고, 다시 터치하여 종료일을 선택하세요 +
+
+ `; + + this.container.innerHTML = calendarHTML; + this.updateCalendar(); + } + + updateCalendar() { + const year = this.currentDate.getFullYear(); + const month = this.currentDate.getMonth(); + + // 월/년 표시 업데이트 + document.getElementById('monthYear').textContent = + `${year}년 ${month + 1}월`; + + // 캘린더 그리드 생성 + this.generateCalendarGrid(year, month); + } + + generateCalendarGrid(year, month) { + const grid = document.getElementById('calendarGrid'); + const firstDay = new Date(year, month, 1); + const lastDay = new Date(year, month + 1, 0); + const startDate = new Date(firstDay); + startDate.setDate(startDate.getDate() - firstDay.getDay()); + + let html = ''; + const today = new Date(); + + // 6주 표시 (42일) + for (let i = 0; i < 42; i++) { + const date = new Date(startDate); + date.setDate(startDate.getDate() + i); + + const isCurrentMonth = date.getMonth() === month; + const isToday = this.isSameDate(date, today); + const isSelected = this.isDateInRange(date); + const isStart = this.selectedStartDate && this.isSameDate(date, this.selectedStartDate); + const isEnd = this.selectedEndDate && this.isSameDate(date, this.selectedEndDate); + + let classes = ['calendar-day']; + if (!isCurrentMonth) classes.push('other-month'); + if (isToday) classes.push('today'); + if (isSelected) classes.push('selected'); + if (isStart) classes.push('range-start'); + if (isEnd) classes.push('range-end'); + + html += ` +
+ ${date.getDate()} +
+ `; + } + + grid.innerHTML = html; + } + + bindEvents() { + // 빠른 선택 버튼들 + this.container.querySelectorAll('.quick-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const range = e.target.dataset.range; + this.selectQuickRange(range); + }); + }); + + // 월 네비게이션 + document.getElementById('prevMonth').addEventListener('click', () => { + this.currentDate.setMonth(this.currentDate.getMonth() - 1); + this.updateCalendar(); + }); + + document.getElementById('nextMonth').addEventListener('click', () => { + this.currentDate.setMonth(this.currentDate.getMonth() + 1); + this.updateCalendar(); + }); + + // 날짜 선택 + this.container.addEventListener('click', (e) => { + if (e.target.classList.contains('calendar-day')) { + this.handleDateClick(e.target); + } + }); + + // 터치 이벤트 (스와이프 지원) + this.container.addEventListener('touchstart', (e) => { + this.touchStartX = e.touches[0].clientX; + this.touchStartY = e.touches[0].clientY; + }); + + this.container.addEventListener('touchend', (e) => { + if (!this.touchStartX || !this.touchStartY) return; + + const touchEndX = e.changedTouches[0].clientX; + const touchEndY = e.changedTouches[0].clientY; + + const diffX = this.touchStartX - touchEndX; + const diffY = this.touchStartY - touchEndY; + + // 수평 스와이프가 수직 스와이프보다 클 때만 처리 + if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) { + if (diffX > 0) { + // 왼쪽으로 스와이프 - 다음 달 + this.currentDate.setMonth(this.currentDate.getMonth() + 1); + } else { + // 오른쪽으로 스와이프 - 이전 달 + this.currentDate.setMonth(this.currentDate.getMonth() - 1); + } + this.updateCalendar(); + } + + this.touchStartX = 0; + this.touchStartY = 0; + }); + + // 범위 지우기 + document.getElementById('clearRange').addEventListener('click', () => { + this.clearSelection(); + }); + } + + handleDateClick(dayElement) { + const dateStr = dayElement.dataset.date; + const date = new Date(dateStr + 'T00:00:00'); + + if (!this.selectedStartDate || (this.selectedStartDate && this.selectedEndDate)) { + // 새로운 선택 시작 + this.selectedStartDate = date; + this.selectedEndDate = null; + this.isSelecting = true; + } else if (this.selectedStartDate && !this.selectedEndDate) { + // 종료일 선택 + if (date < this.selectedStartDate) { + // 시작일보다 이전 날짜를 선택하면 시작일로 설정 + this.selectedEndDate = this.selectedStartDate; + this.selectedStartDate = date; + } else { + this.selectedEndDate = date; + } + this.isSelecting = false; + + // 범위가 너무 크면 제한 + const daysDiff = Math.abs(this.selectedEndDate - this.selectedStartDate) / (1000 * 60 * 60 * 24); + if (daysDiff > this.options.maxRange) { + alert(`최대 ${this.options.maxRange}일까지만 선택할 수 있습니다.`); + this.clearSelection(); + return; + } + } + + this.updateCalendar(); + this.updateSelectedRange(); + + // 콜백 호출 + if (this.selectedStartDate && this.selectedEndDate && this.options.onRangeSelect) { + this.options.onRangeSelect(this.selectedStartDate, this.selectedEndDate); + } + } + + selectQuickRange(range) { + const today = new Date(); + let startDate, endDate; + + switch (range) { + case 'today': + startDate = endDate = new Date(today); + break; + case 'week': + startDate = new Date(today); + startDate.setDate(today.getDate() - today.getDay()); + endDate = new Date(startDate); + endDate.setDate(startDate.getDate() + 6); + break; + case 'month': + startDate = new Date(today.getFullYear(), today.getMonth(), 1); + endDate = new Date(today.getFullYear(), today.getMonth() + 1, 0); + break; + case 'last7': + endDate = new Date(today); + startDate = new Date(today); + startDate.setDate(today.getDate() - 6); + break; + case 'last30': + endDate = new Date(today); + startDate = new Date(today); + startDate.setDate(today.getDate() - 29); + break; + case 'all': + this.clearSelection(); + if (this.options.onRangeSelect) { + this.options.onRangeSelect(null, null); + } + return; + } + + this.selectedStartDate = startDate; + this.selectedEndDate = endDate; + this.updateCalendar(); + this.updateSelectedRange(); + + if (this.options.onRangeSelect) { + this.options.onRangeSelect(startDate, endDate); + } + } + + updateSelectedRange() { + const rangeElement = document.getElementById('selectedRange'); + const rangeText = document.getElementById('rangeText'); + + if (this.selectedStartDate && this.selectedEndDate) { + const startStr = this.formatDate(this.selectedStartDate); + const endStr = this.formatDate(this.selectedEndDate); + const daysDiff = Math.ceil((this.selectedEndDate - this.selectedStartDate) / (1000 * 60 * 60 * 24)) + 1; + + rangeText.textContent = `${startStr} ~ ${endStr} (${daysDiff}일)`; + rangeElement.style.display = 'block'; + } else if (this.selectedStartDate) { + rangeText.textContent = `시작일: ${this.formatDate(this.selectedStartDate)} (종료일을 선택하세요)`; + rangeElement.style.display = 'block'; + } else { + rangeElement.style.display = 'none'; + } + } + + clearSelection() { + this.selectedStartDate = null; + this.selectedEndDate = null; + this.isSelecting = false; + this.updateCalendar(); + this.updateSelectedRange(); + } + + isDateInRange(date) { + if (!this.selectedStartDate) return false; + if (!this.selectedEndDate) return this.isSameDate(date, this.selectedStartDate); + + return date >= this.selectedStartDate && date <= this.selectedEndDate; + } + + isSameDate(date1, date2) { + return date1.toDateString() === date2.toDateString(); + } + + formatDate(date) { + return date.toLocaleDateString('ko-KR', { + month: 'short', + day: 'numeric' + }); + } + + // 외부에서 호출할 수 있는 메서드들 + getSelectedRange() { + return { + startDate: this.selectedStartDate, + endDate: this.selectedEndDate + }; + } + + setSelectedRange(startDate, endDate) { + this.selectedStartDate = startDate; + this.selectedEndDate = endDate; + this.updateCalendar(); + this.updateSelectedRange(); + } +} + +// 전역으로 노출 +window.MobileCalendar = MobileCalendar; diff --git a/frontend/static/js/core/keyboard-shortcuts.js b/frontend/static/js/core/keyboard-shortcuts.js new file mode 100644 index 0000000..68816df --- /dev/null +++ b/frontend/static/js/core/keyboard-shortcuts.js @@ -0,0 +1,621 @@ +/** + * 키보드 단축키 관리자 + * 전역 키보드 단축키를 관리하고 사용자 경험을 향상시킵니다. + */ + +class KeyboardShortcutManager { + constructor() { + this.shortcuts = new Map(); + this.isEnabled = true; + this.helpModalVisible = false; + this.currentUser = null; + + // 기본 단축키 등록 + this.registerDefaultShortcuts(); + + // 이벤트 리스너 등록 + this.bindEvents(); + } + + /** + * 기본 단축키 등록 + */ + registerDefaultShortcuts() { + // 전역 단축키 + this.register('?', () => this.showHelpModal(), '도움말 표시'); + this.register('Escape', () => this.handleEscape(), '모달/메뉴 닫기'); + + // 네비게이션 단축키 + this.register('g h', () => this.navigateToPage('/index.html', 'issues_create'), '홈 (부적합 등록)'); + this.register('g v', () => this.navigateToPage('/issue-view.html', 'issues_view'), '부적합 조회'); + this.register('g d', () => this.navigateToPage('/daily-work.html', 'daily_work'), '일일 공수'); + this.register('g p', () => this.navigateToPage('/project-management.html', 'projects_manage'), '프로젝트 관리'); + this.register('g r', () => this.navigateToPage('/reports.html', 'reports'), '보고서'); + this.register('g a', () => this.navigateToPage('/admin.html', 'users_manage'), '관리자'); + + // 액션 단축키 + this.register('n', () => this.triggerNewAction(), '새 항목 생성'); + this.register('s', () => this.triggerSaveAction(), '저장'); + this.register('r', () => this.triggerRefreshAction(), '새로고침'); + this.register('f', () => this.focusSearchField(), '검색 포커스'); + + // 관리자 전용 단축키 + this.register('ctrl+shift+u', () => this.navigateToPage('/admin.html', 'users_manage'), '사용자 관리 (관리자)'); + + console.log('⌨️ 키보드 단축키 등록 완료'); + } + + /** + * 단축키 등록 + * @param {string} combination - 키 조합 (예: 'ctrl+s', 'g h') + * @param {function} callback - 실행할 함수 + * @param {string} description - 설명 + * @param {object} options - 옵션 + */ + register(combination, callback, description, options = {}) { + const normalizedCombo = this.normalizeKeyCombination(combination); + + this.shortcuts.set(normalizedCombo, { + callback, + description, + requiresAuth: options.requiresAuth !== false, + adminOnly: options.adminOnly || false, + pageSpecific: options.pageSpecific || null + }); + } + + /** + * 키 조합 정규화 + */ + normalizeKeyCombination(combination) { + return combination + .toLowerCase() + .split(' ') + .map(part => part.trim()) + .filter(part => part.length > 0) + .join(' '); + } + + /** + * 이벤트 바인딩 + */ + bindEvents() { + let keySequence = []; + let sequenceTimer = null; + + document.addEventListener('keydown', (e) => { + if (!this.isEnabled) return; + + // 입력 필드에서는 일부 단축키만 허용 + if (this.isInputField(e.target)) { + this.handleInputFieldShortcuts(e); + return; + } + + // 키 조합 생성 + const keyCombo = this.createKeyCombo(e); + + // 시퀀스 타이머 리셋 + if (sequenceTimer) { + clearTimeout(sequenceTimer); + } + + // 단일 키 단축키 확인 + if (this.handleShortcut(keyCombo, e)) { + return; + } + + // 시퀀스 키 처리 + keySequence.push(keyCombo); + + // 시퀀스 단축키 확인 + const sequenceCombo = keySequence.join(' '); + if (this.handleShortcut(sequenceCombo, e)) { + keySequence = []; + return; + } + + // 시퀀스 타이머 설정 (1초 후 리셋) + sequenceTimer = setTimeout(() => { + keySequence = []; + }, 1000); + }); + } + + /** + * 키 조합 생성 + */ + createKeyCombo(event) { + const parts = []; + + if (event.ctrlKey) parts.push('ctrl'); + if (event.altKey) parts.push('alt'); + if (event.shiftKey) parts.push('shift'); + if (event.metaKey) parts.push('meta'); + + const key = event.key.toLowerCase(); + + // 특수 키 처리 + const specialKeys = { + ' ': 'space', + 'enter': 'enter', + 'escape': 'escape', + 'tab': 'tab', + 'backspace': 'backspace', + 'delete': 'delete', + 'arrowup': 'up', + 'arrowdown': 'down', + 'arrowleft': 'left', + 'arrowright': 'right' + }; + + const normalizedKey = specialKeys[key] || key; + parts.push(normalizedKey); + + return parts.join('+'); + } + + /** + * 단축키 처리 + */ + handleShortcut(combination, event) { + const shortcut = this.shortcuts.get(combination); + + if (!shortcut) return false; + + // 권한 확인 + if (shortcut.requiresAuth && !this.currentUser) { + return false; + } + + if (shortcut.adminOnly && this.currentUser?.role !== 'admin') { + return false; + } + + // 페이지별 단축키 확인 + if (shortcut.pageSpecific && !this.isCurrentPage(shortcut.pageSpecific)) { + return false; + } + + // 기본 동작 방지 + event.preventDefault(); + event.stopPropagation(); + + // 콜백 실행 + try { + shortcut.callback(event); + console.log(`⌨️ 단축키 실행: ${combination}`); + } catch (error) { + console.error('단축키 실행 실패:', combination, error); + } + + return true; + } + + /** + * 입력 필드 확인 + */ + isInputField(element) { + const inputTypes = ['input', 'textarea', 'select']; + const contentEditable = element.contentEditable === 'true'; + + return inputTypes.includes(element.tagName.toLowerCase()) || contentEditable; + } + + /** + * 입력 필드에서의 단축키 처리 + */ + handleInputFieldShortcuts(event) { + const keyCombo = this.createKeyCombo(event); + + // 입력 필드에서 허용되는 단축키 + const allowedInInput = ['escape', 'ctrl+s', 'ctrl+enter']; + + if (allowedInInput.includes(keyCombo)) { + this.handleShortcut(keyCombo, event); + } + } + + /** + * 현재 페이지 확인 + */ + isCurrentPage(pageId) { + return window.commonHeader?.currentPage === pageId; + } + + /** + * 페이지 네비게이션 + */ + navigateToPage(url, pageId) { + // 권한 확인 + if (pageId && window.canAccessPage && !window.canAccessPage(pageId)) { + this.showNotification('해당 페이지에 접근할 권한이 없습니다.', 'warning'); + return; + } + + // 현재 페이지와 같으면 무시 + if (window.location.pathname === url) { + return; + } + + // 부드러운 전환 + if (window.CommonHeader) { + window.CommonHeader.navigateToPage( + { preventDefault: () => {}, stopPropagation: () => {} }, + url, + pageId + ); + } else { + window.location.href = url; + } + } + + /** + * 새 항목 생성 액션 + */ + triggerNewAction() { + const newButtons = [ + 'button[onclick*="showAddModal"]', + 'button[onclick*="addNew"]', + '#addBtn', + '#add-btn', + '.btn-add', + 'button:contains("추가")', + 'button:contains("등록")', + 'button:contains("새")' + ]; + + for (const selector of newButtons) { + const button = document.querySelector(selector); + if (button && !button.disabled) { + button.click(); + this.showNotification('새 항목 생성', 'info'); + return; + } + } + + this.showNotification('새 항목 생성 버튼을 찾을 수 없습니다.', 'warning'); + } + + /** + * 저장 액션 + */ + triggerSaveAction() { + const saveButtons = [ + 'button[type="submit"]', + 'button[onclick*="save"]', + '#saveBtn', + '#save-btn', + '.btn-save', + 'button:contains("저장")', + 'button:contains("등록")' + ]; + + for (const selector of saveButtons) { + const button = document.querySelector(selector); + if (button && !button.disabled) { + button.click(); + this.showNotification('저장 실행', 'success'); + return; + } + } + + this.showNotification('저장 버튼을 찾을 수 없습니다.', 'warning'); + } + + /** + * 새로고침 액션 + */ + triggerRefreshAction() { + const refreshButtons = [ + 'button[onclick*="load"]', + 'button[onclick*="refresh"]', + '#refreshBtn', + '#refresh-btn', + '.btn-refresh' + ]; + + for (const selector of refreshButtons) { + const button = document.querySelector(selector); + if (button && !button.disabled) { + button.click(); + this.showNotification('새로고침 실행', 'info'); + return; + } + } + + // 기본 새로고침 + window.location.reload(); + } + + /** + * 검색 필드 포커스 + */ + focusSearchField() { + const searchFields = [ + 'input[type="search"]', + 'input[placeholder*="검색"]', + 'input[placeholder*="찾기"]', + '#searchInput', + '#search', + '.search-input' + ]; + + for (const selector of searchFields) { + const field = document.querySelector(selector); + if (field) { + field.focus(); + field.select(); + this.showNotification('검색 필드 포커스', 'info'); + return; + } + } + + this.showNotification('검색 필드를 찾을 수 없습니다.', 'warning'); + } + + /** + * Escape 키 처리 + */ + handleEscape() { + // 모달 닫기 + const modals = document.querySelectorAll('.modal, [id*="modal"], [class*="modal"]'); + for (const modal of modals) { + if (!modal.classList.contains('hidden') && modal.style.display !== 'none') { + modal.classList.add('hidden'); + this.showNotification('모달 닫기', 'info'); + return; + } + } + + // 드롭다운 메뉴 닫기 + const dropdowns = document.querySelectorAll('[id*="menu"], [class*="dropdown"]'); + for (const dropdown of dropdowns) { + if (!dropdown.classList.contains('hidden')) { + dropdown.classList.add('hidden'); + return; + } + } + + // 포커스 해제 + if (document.activeElement && document.activeElement !== document.body) { + document.activeElement.blur(); + } + } + + /** + * 도움말 모달 표시 + */ + showHelpModal() { + if (this.helpModalVisible) { + this.hideHelpModal(); + return; + } + + const modal = this.createHelpModal(); + document.body.appendChild(modal); + this.helpModalVisible = true; + + // 외부 클릭으로 닫기 + modal.addEventListener('click', (e) => { + if (e.target === modal) { + this.hideHelpModal(); + } + }); + } + + /** + * 도움말 모달 생성 + */ + createHelpModal() { + const modal = document.createElement('div'); + modal.id = 'keyboard-shortcuts-modal'; + modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'; + + const shortcuts = this.getAvailableShortcuts(); + const shortcutGroups = this.groupShortcuts(shortcuts); + + modal.innerHTML = ` +
+
+
+

+ + 키보드 단축키 +

+ +
+
+ +
+
+ ${Object.entries(shortcutGroups).map(([group, items]) => ` +
+

+ ${group} +

+
+ ${items.map(item => ` +
+ ${item.description} +
+ ${item.keys.map(key => ` + + ${key} + + `).join('')} +
+
+ `).join('')} +
+
+ `).join('')} +
+ +
+
+ +
+

사용 팁

+
    +
  • • 입력 필드에서는 일부 단축키만 작동합니다.
  • +
  • • 'g' 키를 누른 후 다른 키를 눌러 페이지를 이동할 수 있습니다.
  • +
  • • ESC 키로 모달이나 메뉴를 닫을 수 있습니다.
  • +
  • • '?' 키로 언제든 이 도움말을 볼 수 있습니다.
  • +
+
+
+
+
+
+ `; + + return modal; + } + + /** + * 사용 가능한 단축키 가져오기 + */ + getAvailableShortcuts() { + const available = []; + + for (const [combination, shortcut] of this.shortcuts) { + // 권한 확인 + if (shortcut.requiresAuth && !this.currentUser) continue; + if (shortcut.adminOnly && this.currentUser?.role !== 'admin') continue; + + available.push({ + combination, + description: shortcut.description, + keys: this.formatKeyCombo(combination) + }); + } + + return available; + } + + /** + * 단축키 그룹화 + */ + groupShortcuts(shortcuts) { + const groups = { + '네비게이션': [], + '액션': [], + '전역': [] + }; + + shortcuts.forEach(shortcut => { + if (shortcut.combination.startsWith('g ')) { + groups['네비게이션'].push(shortcut); + } else if (['n', 's', 'r', 'f'].includes(shortcut.combination)) { + groups['액션'].push(shortcut); + } else { + groups['전역'].push(shortcut); + } + }); + + return groups; + } + + /** + * 키 조합 포맷팅 + */ + formatKeyCombo(combination) { + return combination + .split(' ') + .map(part => { + return part + .split('+') + .map(key => { + const keyNames = { + 'ctrl': 'Ctrl', + 'alt': 'Alt', + 'shift': 'Shift', + 'meta': 'Cmd', + 'space': 'Space', + 'enter': 'Enter', + 'escape': 'Esc', + 'tab': 'Tab' + }; + return keyNames[key] || key.toUpperCase(); + }) + .join(' + '); + }); + } + + /** + * 도움말 모달 숨기기 + */ + hideHelpModal() { + const modal = document.getElementById('keyboard-shortcuts-modal'); + if (modal) { + modal.remove(); + this.helpModalVisible = false; + } + } + + /** + * 알림 표시 + */ + showNotification(message, type = 'info') { + // 기존 알림 제거 + const existing = document.getElementById('shortcut-notification'); + if (existing) existing.remove(); + + const notification = document.createElement('div'); + notification.id = 'shortcut-notification'; + notification.className = `fixed top-4 right-4 px-4 py-2 rounded-lg shadow-lg z-50 transition-all duration-300 ${this.getNotificationClass(type)}`; + notification.textContent = message; + + document.body.appendChild(notification); + + // 3초 후 자동 제거 + setTimeout(() => { + if (notification.parentNode) { + notification.remove(); + } + }, 3000); + } + + /** + * 알림 클래스 가져오기 + */ + getNotificationClass(type) { + const classes = { + 'info': 'bg-blue-600 text-white', + 'success': 'bg-green-600 text-white', + 'warning': 'bg-yellow-600 text-white', + 'error': 'bg-red-600 text-white' + }; + return classes[type] || classes.info; + } + + /** + * 사용자 설정 + */ + setUser(user) { + this.currentUser = user; + } + + /** + * 단축키 활성화/비활성화 + */ + setEnabled(enabled) { + this.isEnabled = enabled; + console.log(`⌨️ 키보드 단축키 ${enabled ? '활성화' : '비활성화'}`); + } + + /** + * 단축키 제거 + */ + unregister(combination) { + const normalizedCombo = this.normalizeKeyCombination(combination); + return this.shortcuts.delete(normalizedCombo); + } +} + +// 전역 인스턴스 +window.keyboardShortcuts = new KeyboardShortcutManager(); diff --git a/frontend/static/js/core/page-manager.js b/frontend/static/js/core/page-manager.js new file mode 100644 index 0000000..2c6d2c1 --- /dev/null +++ b/frontend/static/js/core/page-manager.js @@ -0,0 +1,368 @@ +/** + * 페이지 관리자 + * 모듈화된 페이지들의 생명주기를 관리하고 부드러운 전환을 제공 + */ + +class PageManager { + constructor() { + this.currentPage = null; + this.loadedModules = new Map(); + this.pageHistory = []; + } + + /** + * 페이지 초기화 + * @param {string} pageId - 페이지 식별자 + * @param {Object} options - 초기화 옵션 + */ + async initializePage(pageId, options = {}) { + try { + // 로딩 표시 + this.showPageLoader(); + + // 사용자 인증 확인 + const user = await this.checkAuthentication(); + if (!user) return; + + // 공통 헤더 초기화 + await this.initializeCommonHeader(user, pageId); + + // 페이지별 권한 체크 + if (!this.checkPagePermission(pageId, user)) { + this.redirectToAccessiblePage(); + return; + } + + // 페이지 모듈 로드 및 초기화 + await this.loadPageModule(pageId, options); + + // 페이지 히스토리 업데이트 + this.updatePageHistory(pageId); + + // 로딩 숨기기 + this.hidePageLoader(); + + } catch (error) { + console.error('페이지 초기화 실패:', error); + this.showErrorPage(error); + } + } + + /** + * 사용자 인증 확인 + */ + async checkAuthentication() { + const token = localStorage.getItem('access_token'); + if (!token) { + window.location.href = '/index.html'; + return null; + } + + try { + // API가 로드될 때까지 대기 + await this.waitForAPI(); + + const user = await AuthAPI.getCurrentUser(); + localStorage.setItem('currentUser', JSON.stringify(user)); + return user; + } catch (error) { + console.error('인증 실패:', error); + localStorage.removeItem('access_token'); + localStorage.removeItem('currentUser'); + window.location.href = '/index.html'; + return null; + } + } + + /** + * API 로드 대기 + */ + async waitForAPI() { + let attempts = 0; + const maxAttempts = 50; + + while (!window.AuthAPI && attempts < maxAttempts) { + await new Promise(resolve => setTimeout(resolve, 100)); + attempts++; + } + + if (!window.AuthAPI) { + throw new Error('API를 로드할 수 없습니다.'); + } + } + + /** + * 공통 헤더 초기화 + */ + async initializeCommonHeader(user, pageId) { + // 권한 시스템 초기화 + if (window.pagePermissionManager) { + window.pagePermissionManager.setUser(user); + } + + // 공통 헤더 초기화 + if (window.commonHeader) { + await window.commonHeader.init(user, pageId); + } + } + + /** + * 페이지 권한 체크 + */ + checkPagePermission(pageId, user) { + // admin은 모든 페이지 접근 가능 + if (user.role === 'admin') { + return true; + } + + // 권한 시스템이 로드되지 않았으면 기본 페이지만 허용 + if (!window.canAccessPage) { + return ['issues_create', 'issues_view'].includes(pageId); + } + + return window.canAccessPage(pageId); + } + + /** + * 접근 가능한 페이지로 리다이렉트 + */ + redirectToAccessiblePage() { + alert('이 페이지에 접근할 권한이 없습니다.'); + + // 기본적으로 접근 가능한 페이지로 이동 + if (window.canAccessPage && window.canAccessPage('issues_view')) { + window.location.href = '/issue-view.html'; + } else { + window.location.href = '/index.html'; + } + } + + /** + * 페이지 모듈 로드 + */ + async loadPageModule(pageId, options) { + // 이미 로드된 모듈이 있으면 재사용 + if (this.loadedModules.has(pageId)) { + const module = this.loadedModules.get(pageId); + if (module.reinitialize) { + await module.reinitialize(options); + } + return; + } + + // 페이지별 모듈 로드 + const module = await this.createPageModule(pageId, options); + if (module) { + this.loadedModules.set(pageId, module); + this.currentPage = pageId; + } + } + + /** + * 페이지 모듈 생성 + */ + async createPageModule(pageId, options) { + switch (pageId) { + case 'issues_create': + return new IssuesCreateModule(options); + case 'issues_view': + return new IssuesViewModule(options); + case 'issues_manage': + return new IssuesManageModule(options); + case 'projects_manage': + return new ProjectsManageModule(options); + case 'daily_work': + return new DailyWorkModule(options); + case 'reports': + return new ReportsModule(options); + case 'users_manage': + return new UsersManageModule(options); + default: + console.warn(`알 수 없는 페이지 ID: ${pageId}`); + return null; + } + } + + /** + * 페이지 히스토리 업데이트 + */ + updatePageHistory(pageId) { + this.pageHistory.push({ + pageId, + timestamp: new Date(), + url: window.location.href + }); + + // 히스토리 크기 제한 (최대 10개) + if (this.pageHistory.length > 10) { + this.pageHistory.shift(); + } + } + + /** + * 페이지 로더 표시 + */ + showPageLoader() { + const existingLoader = document.getElementById('page-loader'); + if (existingLoader) return; + + const loader = document.createElement('div'); + loader.id = 'page-loader'; + loader.className = 'fixed inset-0 bg-white bg-opacity-90 flex items-center justify-center z-50'; + loader.innerHTML = ` +
+
+

페이지를 로드하는 중...

+

잠시만 기다려주세요

+
+ `; + + document.body.appendChild(loader); + } + + /** + * 페이지 로더 숨기기 + */ + hidePageLoader() { + const loader = document.getElementById('page-loader'); + if (loader) { + loader.remove(); + } + } + + /** + * 에러 페이지 표시 + */ + showErrorPage(error) { + this.hidePageLoader(); + + const errorContainer = document.createElement('div'); + errorContainer.className = 'fixed inset-0 bg-gray-50 flex items-center justify-center z-50'; + errorContainer.innerHTML = ` +
+
+ +
+

페이지 로드 실패

+

${error.message || '알 수 없는 오류가 발생했습니다.'}

+
+ + +
+
+ `; + + document.body.appendChild(errorContainer); + } + + /** + * 페이지 정리 + */ + cleanup() { + if (this.currentPage && this.loadedModules.has(this.currentPage)) { + const module = this.loadedModules.get(this.currentPage); + if (module.cleanup) { + module.cleanup(); + } + } + } +} + +/** + * 기본 페이지 모듈 클래스 + * 모든 페이지 모듈이 상속받아야 하는 기본 클래스 + */ +class BasePageModule { + constructor(options = {}) { + this.options = options; + this.initialized = false; + this.eventListeners = []; + } + + /** + * 모듈 초기화 (하위 클래스에서 구현) + */ + async initialize() { + throw new Error('initialize 메서드를 구현해야 합니다.'); + } + + /** + * 모듈 재초기화 + */ + async reinitialize(options = {}) { + this.cleanup(); + this.options = { ...this.options, ...options }; + await this.initialize(); + } + + /** + * 이벤트 리스너 등록 (자동 정리를 위해) + */ + addEventListener(element, event, handler) { + element.addEventListener(event, handler); + this.eventListeners.push({ element, event, handler }); + } + + /** + * 모듈 정리 + */ + cleanup() { + // 등록된 이벤트 리스너 제거 + this.eventListeners.forEach(({ element, event, handler }) => { + element.removeEventListener(event, handler); + }); + this.eventListeners = []; + + this.initialized = false; + } + + /** + * 로딩 표시 + */ + showLoading(container, message = '로딩 중...') { + if (typeof container === 'string') { + container = document.getElementById(container); + } + + if (container) { + container.innerHTML = ` +
+
+
+

${message}

+
+
+ `; + } + } + + /** + * 에러 표시 + */ + showError(container, message = '오류가 발생했습니다.') { + if (typeof container === 'string') { + container = document.getElementById(container); + } + + if (container) { + container.innerHTML = ` +
+
+ +

${message}

+
+
+ `; + } + } +} + +// 전역 인스턴스 +window.pageManager = new PageManager(); +window.BasePageModule = BasePageModule; diff --git a/frontend/static/js/core/page-preloader.js b/frontend/static/js/core/page-preloader.js new file mode 100644 index 0000000..7d89eb8 --- /dev/null +++ b/frontend/static/js/core/page-preloader.js @@ -0,0 +1,317 @@ +/** + * 페이지 프리로더 + * 사용자가 방문할 가능성이 높은 페이지들을 미리 로드하여 성능 향상 + */ + +class PagePreloader { + constructor() { + this.preloadedPages = new Set(); + this.preloadQueue = []; + this.isPreloading = false; + this.preloadCache = new Map(); + this.resourceCache = new Map(); + } + + /** + * 프리로더 초기화 + */ + init() { + // 유휴 시간에 프리로딩 시작 + this.schedulePreloading(); + + // 링크 호버 시 프리로딩 + this.setupHoverPreloading(); + + // 서비스 워커 등록 (캐싱용) + this.registerServiceWorker(); + } + + /** + * 우선순위 기반 프리로딩 스케줄링 + */ + schedulePreloading() { + // 현재 사용자 권한에 따른 접근 가능한 페이지들 + const accessiblePages = this.getAccessiblePages(); + + // 우선순위 설정 + const priorityPages = this.getPriorityPages(accessiblePages); + + // 유휴 시간에 프리로딩 시작 + if ('requestIdleCallback' in window) { + requestIdleCallback(() => { + this.startPreloading(priorityPages); + }, { timeout: 2000 }); + } else { + // requestIdleCallback 미지원 브라우저 + setTimeout(() => { + this.startPreloading(priorityPages); + }, 1000); + } + } + + /** + * 접근 가능한 페이지 목록 가져오기 + */ + getAccessiblePages() { + const allPages = [ + { id: 'issues_create', url: '/index.html', priority: 1 }, + { id: 'issues_view', url: '/issue-view.html', priority: 1 }, + { id: 'issues_manage', url: '/issue-view.html#manage', priority: 2 }, + { id: 'projects_manage', url: '/project-management.html', priority: 3 }, + { id: 'daily_work', url: '/daily-work.html', priority: 2 }, + { id: 'reports', url: '/reports.html', priority: 3 }, + { id: 'users_manage', url: '/admin.html', priority: 4 } + ]; + + // 권한 체크 + return allPages.filter(page => { + if (!window.canAccessPage) return false; + return window.canAccessPage(page.id); + }); + } + + /** + * 우선순위 기반 페이지 정렬 + */ + getPriorityPages(pages) { + return pages + .sort((a, b) => a.priority - b.priority) + .slice(0, 3); // 최대 3개 페이지만 프리로드 + } + + /** + * 프리로딩 시작 + */ + async startPreloading(pages) { + if (this.isPreloading) return; + + this.isPreloading = true; + console.log('🚀 페이지 프리로딩 시작:', pages.map(p => p.id)); + + for (const page of pages) { + if (this.preloadedPages.has(page.url)) continue; + + try { + await this.preloadPage(page); + + // 네트워크 상태 확인 (느린 연결에서는 중단) + if (this.isSlowConnection()) { + console.log('⚠️ 느린 연결 감지, 프리로딩 중단'); + break; + } + + // CPU 부하 방지를 위한 딜레이 + await this.delay(500); + + } catch (error) { + console.warn('프리로딩 실패:', page.id, error); + } + } + + this.isPreloading = false; + console.log('✅ 페이지 프리로딩 완료'); + } + + /** + * 개별 페이지 프리로드 + */ + async preloadPage(page) { + try { + // HTML 프리로드 + const htmlResponse = await fetch(page.url, { + method: 'GET', + headers: { 'Accept': 'text/html' } + }); + + if (htmlResponse.ok) { + const html = await htmlResponse.text(); + this.preloadCache.set(page.url, html); + + // 페이지 내 리소스 추출 및 프리로드 + await this.preloadPageResources(html, page.url); + + this.preloadedPages.add(page.url); + console.log(`📄 프리로드 완료: ${page.id}`); + } + + } catch (error) { + console.warn(`프리로드 실패: ${page.id}`, error); + } + } + + /** + * 페이지 리소스 프리로드 (CSS, JS) + */ + async preloadPageResources(html, baseUrl) { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + + // CSS 파일 프리로드 + const cssLinks = doc.querySelectorAll('link[rel="stylesheet"]'); + for (const link of cssLinks) { + const href = this.resolveUrl(link.href, baseUrl); + if (!this.resourceCache.has(href)) { + this.preloadResource(href, 'style'); + } + } + + // JS 파일 프리로드 (중요한 것만) + const scriptTags = doc.querySelectorAll('script[src]'); + for (const script of scriptTags) { + const src = this.resolveUrl(script.src, baseUrl); + if (this.isImportantScript(src) && !this.resourceCache.has(src)) { + this.preloadResource(src, 'script'); + } + } + } + + /** + * 리소스 프리로드 + */ + preloadResource(url, type) { + const link = document.createElement('link'); + link.rel = 'preload'; + link.href = url; + link.as = type; + + link.onload = () => { + this.resourceCache.set(url, true); + }; + + link.onerror = () => { + console.warn('리소스 프리로드 실패:', url); + }; + + document.head.appendChild(link); + } + + /** + * 중요한 스크립트 판별 + */ + isImportantScript(src) { + const importantScripts = [ + 'api.js', + 'permissions.js', + 'common-header.js', + 'page-manager.js' + ]; + + return importantScripts.some(script => src.includes(script)); + } + + /** + * URL 해결 + */ + resolveUrl(url, baseUrl) { + if (url.startsWith('http') || url.startsWith('//')) { + return url; + } + + const base = new URL(baseUrl, window.location.origin); + return new URL(url, base).href; + } + + /** + * 호버 시 프리로딩 설정 + */ + setupHoverPreloading() { + let hoverTimeout; + + document.addEventListener('mouseover', (e) => { + const link = e.target.closest('a[href]'); + if (!link) return; + + const href = link.getAttribute('href'); + if (!href || href.startsWith('#') || href.startsWith('javascript:')) return; + + // 300ms 후 프리로드 (실제 클릭 의도 확인) + hoverTimeout = setTimeout(() => { + this.preloadOnHover(href); + }, 300); + }); + + document.addEventListener('mouseout', (e) => { + if (hoverTimeout) { + clearTimeout(hoverTimeout); + hoverTimeout = null; + } + }); + } + + /** + * 호버 시 프리로드 + */ + async preloadOnHover(url) { + if (this.preloadedPages.has(url)) return; + + try { + const response = await fetch(url, { + method: 'GET', + headers: { 'Accept': 'text/html' } + }); + + if (response.ok) { + const html = await response.text(); + this.preloadCache.set(url, html); + this.preloadedPages.add(url); + console.log('🖱️ 호버 프리로드 완료:', url); + } + } catch (error) { + console.warn('호버 프리로드 실패:', url, error); + } + } + + /** + * 느린 연결 감지 + */ + isSlowConnection() { + if ('connection' in navigator) { + const connection = navigator.connection; + return connection.effectiveType === 'slow-2g' || + connection.effectiveType === '2g' || + connection.saveData === true; + } + return false; + } + + /** + * 딜레이 유틸리티 + */ + delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + /** + * 서비스 워커 등록 + */ + async registerServiceWorker() { + if ('serviceWorker' in navigator) { + try { + const registration = await navigator.serviceWorker.register('/sw.js'); + console.log('🔧 서비스 워커 등록 완료:', registration); + } catch (error) { + console.log('서비스 워커 등록 실패:', error); + } + } + } + + /** + * 프리로드된 페이지 가져오기 + */ + getPreloadedPage(url) { + return this.preloadCache.get(url); + } + + /** + * 캐시 정리 + */ + clearCache() { + this.preloadCache.clear(); + this.resourceCache.clear(); + this.preloadedPages.clear(); + console.log('🗑️ 프리로드 캐시 정리 완료'); + } +} + +// 전역 인스턴스 +window.pagePreloader = new PagePreloader(); diff --git a/frontend/static/js/core/permissions.js b/frontend/static/js/core/permissions.js new file mode 100644 index 0000000..540a2a1 --- /dev/null +++ b/frontend/static/js/core/permissions.js @@ -0,0 +1,260 @@ +/** + * 단순화된 페이지 권한 관리 시스템 + * admin/user 구조에서 페이지별 접근 권한을 관리 + */ + +class PagePermissionManager { + constructor() { + this.currentUser = null; + this.pagePermissions = new Map(); + this.defaultPages = this.initDefaultPages(); + } + + /** + * 기본 페이지 목록 초기화 + */ + initDefaultPages() { + return { + 'issues_create': { title: '부적합 등록', defaultAccess: true }, + 'issues_view': { title: '부적합 조회', defaultAccess: true }, + 'issues_manage': { title: '부적합 관리', defaultAccess: false }, + 'projects_manage': { title: '프로젝트 관리', defaultAccess: false }, + 'daily_work': { title: '일일 공수', defaultAccess: false }, + 'reports': { title: '보고서', defaultAccess: false }, + 'users_manage': { title: '사용자 관리', defaultAccess: false } + }; + } + + /** + * 사용자 설정 + * @param {Object} user - 사용자 객체 + */ + setUser(user) { + this.currentUser = user; + this.loadPagePermissions(); + } + + /** + * 사용자별 페이지 권한 로드 + */ + async loadPagePermissions() { + if (!this.currentUser) return; + + try { + // API에서 사용자별 페이지 권한 가져오기 + const response = await fetch(`/api/users/${this.currentUser.id}/page-permissions`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + } + }); + + if (response.ok) { + const pagePermissions = await response.json(); + this.pagePermissions.clear(); // 기존 권한 초기화 + pagePermissions.forEach(perm => { + this.pagePermissions.set(perm.page_name, perm.can_access); + }); + console.log('페이지 권한 로드 완료:', this.pagePermissions); + } else { + console.warn('페이지 권한 로드 실패, 기본 권한 사용'); + } + } catch (error) { + console.warn('페이지 권한 로드 실패, 기본 권한 사용:', error); + } + } + + /** + * 페이지 접근 권한 체크 + * @param {string} pageName - 체크할 페이지명 + * @returns {boolean} 접근 권한 여부 + */ + canAccessPage(pageName) { + if (!this.currentUser) return false; + + // admin은 모든 페이지 접근 가능 + if (this.currentUser.role === 'admin') { + return true; + } + + // 개별 페이지 권한이 설정되어 있으면 우선 적용 + if (this.pagePermissions.has(pageName)) { + return this.pagePermissions.get(pageName); + } + + // 기본 권한 확인 + const pageConfig = this.defaultPages[pageName]; + return pageConfig ? pageConfig.defaultAccess : false; + } + + /** + * UI 요소 페이지 권한 제어 + * @param {string} selector - CSS 선택자 + * @param {string} pageName - 필요한 페이지 권한 + * @param {string} action - 'show'|'hide'|'disable'|'enable' + */ + controlElement(selector, pageName, action = 'show') { + const elements = document.querySelectorAll(selector); + const hasAccess = this.canAccessPage(pageName); + + elements.forEach(element => { + switch (action) { + case 'show': + element.style.display = hasAccess ? '' : 'none'; + break; + case 'hide': + element.style.display = hasAccess ? 'none' : ''; + break; + case 'disable': + element.disabled = !hasAccess; + if (!hasAccess) { + element.classList.add('opacity-50', 'cursor-not-allowed'); + } + break; + case 'enable': + element.disabled = hasAccess; + if (hasAccess) { + element.classList.remove('opacity-50', 'cursor-not-allowed'); + } + break; + } + }); + } + + /** + * 메뉴 구성 생성 + * @returns {Array} 페이지 권한에 따른 메뉴 구성 + */ + getMenuConfig() { + const menuItems = [ + { + id: 'issues_create', + title: '부적합 등록', + icon: 'fas fa-plus-circle', + path: '#issues/create', + pageName: 'issues_create' + }, + { + id: 'issues_view', + title: '부적합 조회', + icon: 'fas fa-search', + path: '#issues/view', + pageName: 'issues_view' + }, + { + id: 'issues_manage', + title: '부적합 관리', + icon: 'fas fa-tasks', + path: '#issues/manage', + pageName: 'issues_manage' + }, + { + id: 'projects_manage', + title: '프로젝트 관리', + icon: 'fas fa-folder-open', + path: '#projects/manage', + pageName: 'projects_manage' + }, + { + id: 'daily_work', + title: '일일 공수', + icon: 'fas fa-calendar-check', + path: '#daily-work', + pageName: 'daily_work' + }, + { + id: 'reports', + title: '보고서', + icon: 'fas fa-chart-bar', + path: '#reports', + pageName: 'reports' + }, + { + id: 'users_manage', + title: '사용자 관리', + icon: 'fas fa-users-cog', + path: '#users/manage', + pageName: 'users_manage' + } + ]; + + // 페이지 권한에 따라 메뉴 필터링 + return menuItems.filter(item => this.canAccessPage(item.pageName)); + } + + /** + * 페이지 권한 부여 + * @param {number} userId - 사용자 ID + * @param {string} pageName - 페이지명 + * @param {boolean} canAccess - 접근 허용 여부 + * @param {string} notes - 메모 + */ + async grantPageAccess(userId, pageName, canAccess, notes = '') { + if (this.currentUser.role !== 'admin') { + throw new Error('관리자만 권한을 설정할 수 있습니다.'); + } + + try { + const response = await fetch('/api/page-permissions/grant', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + }, + body: JSON.stringify({ + user_id: userId, + page_name: pageName, + can_access: canAccess, + notes: notes + }) + }); + + if (!response.ok) { + throw new Error('페이지 권한 설정 실패'); + } + + return await response.json(); + } catch (error) { + console.error('페이지 권한 설정 오류:', error); + throw error; + } + } + + /** + * 사용자 페이지 권한 목록 조회 + * @param {number} userId - 사용자 ID + * @returns {Array} 페이지 권한 목록 + */ + async getUserPagePermissions(userId) { + try { + const response = await fetch(`/api/users/${userId}/page-permissions`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + } + }); + + if (!response.ok) { + throw new Error('페이지 권한 목록 조회 실패'); + } + + return await response.json(); + } catch (error) { + console.error('페이지 권한 목록 조회 오류:', error); + throw error; + } + } + + /** + * 모든 페이지 목록과 설명 가져오기 + * @returns {Object} 페이지 목록 + */ + getAllPages() { + return this.defaultPages; + } +} + +// 전역 페이지 권한 관리자 인스턴스 +window.pagePermissionManager = new PagePermissionManager(); + +// 편의 함수들 +window.canAccessPage = (pageName) => window.pagePermissionManager.canAccessPage(pageName); +window.controlElement = (selector, pageName, action) => window.pagePermissionManager.controlElement(selector, pageName, action); diff --git a/frontend/sw.js b/frontend/sw.js new file mode 100644 index 0000000..ddbc9ff --- /dev/null +++ b/frontend/sw.js @@ -0,0 +1,335 @@ +/** + * 서비스 워커 - 페이지 및 리소스 캐싱 + * M-Project 작업보고서 시스템 + */ + +const CACHE_NAME = 'mproject-v1.0.0'; +const STATIC_CACHE = 'mproject-static-v1.0.0'; +const DYNAMIC_CACHE = 'mproject-dynamic-v1.0.0'; + +// 캐시할 정적 리소스 +const STATIC_ASSETS = [ + '/', + '/index.html', + '/issue-view.html', + '/daily-work.html', + '/project-management.html', + '/admin.html', + '/static/js/api.js', + '/static/js/core/permissions.js', + '/static/js/components/common-header.js', + '/static/js/core/page-manager.js', + '/static/js/core/page-preloader.js', + '/static/js/date-utils.js', + '/static/js/image-utils.js', + 'https://cdn.tailwindcss.com', + 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css' +]; + +// 캐시 전략별 URL 패턴 +const CACHE_STRATEGIES = { + // 네트워크 우선 (API 호출) + networkFirst: [ + /\/api\//, + /\/auth\// + ], + + // 캐시 우선 (정적 리소스) + cacheFirst: [ + /\.css$/, + /\.js$/, + /\.png$/, + /\.jpg$/, + /\.jpeg$/, + /\.gif$/, + /\.svg$/, + /\.woff$/, + /\.woff2$/, + /cdn\.tailwindcss\.com/, + /cdnjs\.cloudflare\.com/ + ], + + // 스테일 허용 (HTML 페이지) + staleWhileRevalidate: [ + /\.html$/, + /\/$/ + ] +}; + +/** + * 서비스 워커 설치 + */ +self.addEventListener('install', (event) => { + console.log('🔧 서비스 워커 설치 중...'); + + event.waitUntil( + caches.open(STATIC_CACHE) + .then((cache) => { + console.log('📦 정적 리소스 캐싱 중...'); + return cache.addAll(STATIC_ASSETS); + }) + .then(() => { + console.log('✅ 서비스 워커 설치 완료'); + return self.skipWaiting(); + }) + .catch((error) => { + console.error('❌ 서비스 워커 설치 실패:', error); + }) + ); +}); + +/** + * 서비스 워커 활성화 + */ +self.addEventListener('activate', (event) => { + console.log('🚀 서비스 워커 활성화 중...'); + + event.waitUntil( + caches.keys() + .then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => { + // 이전 버전 캐시 삭제 + if (cacheName !== STATIC_CACHE && + cacheName !== DYNAMIC_CACHE && + cacheName !== CACHE_NAME) { + console.log('🗑️ 이전 캐시 삭제:', cacheName); + return caches.delete(cacheName); + } + }) + ); + }) + .then(() => { + console.log('✅ 서비스 워커 활성화 완료'); + return self.clients.claim(); + }) + ); +}); + +/** + * 네트워크 요청 가로채기 + */ +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // CORS 요청이나 외부 도메인은 기본 처리 + if (url.origin !== location.origin && !isCDNResource(url)) { + return; + } + + // 캐시 전략 결정 + const strategy = getCacheStrategy(request.url); + + event.respondWith( + handleRequest(request, strategy) + ); +}); + +/** + * 요청 처리 (캐시 전략별) + */ +async function handleRequest(request, strategy) { + try { + switch (strategy) { + case 'networkFirst': + return await networkFirst(request); + case 'cacheFirst': + return await cacheFirst(request); + case 'staleWhileRevalidate': + return await staleWhileRevalidate(request); + default: + return await fetch(request); + } + } catch (error) { + console.error('요청 처리 실패:', request.url, error); + return await handleOffline(request); + } +} + +/** + * 네트워크 우선 전략 + */ +async function networkFirst(request) { + try { + const networkResponse = await fetch(request); + + // 성공적인 응답만 캐시 + if (networkResponse.ok) { + const cache = await caches.open(DYNAMIC_CACHE); + cache.put(request, networkResponse.clone()); + } + + return networkResponse; + } catch (error) { + // 네트워크 실패 시 캐시에서 반환 + const cachedResponse = await caches.match(request); + if (cachedResponse) { + return cachedResponse; + } + throw error; + } +} + +/** + * 캐시 우선 전략 + */ +async function cacheFirst(request) { + const cachedResponse = await caches.match(request); + + if (cachedResponse) { + return cachedResponse; + } + + // 캐시에 없으면 네트워크에서 가져와서 캐시 + const networkResponse = await fetch(request); + + if (networkResponse.ok) { + const cache = await caches.open(STATIC_CACHE); + cache.put(request, networkResponse.clone()); + } + + return networkResponse; +} + +/** + * 스테일 허용 전략 + */ +async function staleWhileRevalidate(request) { + const cachedResponse = await caches.match(request); + + // 백그라운드에서 업데이트 + const networkResponsePromise = fetch(request) + .then((networkResponse) => { + if (networkResponse.ok) { + const cache = caches.open(DYNAMIC_CACHE); + cache.then(c => c.put(request, networkResponse.clone())); + } + return networkResponse; + }) + .catch(() => null); + + // 캐시된 응답이 있으면 즉시 반환, 없으면 네트워크 대기 + return cachedResponse || await networkResponsePromise; +} + +/** + * 캐시 전략 결정 + */ +function getCacheStrategy(url) { + for (const [strategy, patterns] of Object.entries(CACHE_STRATEGIES)) { + if (patterns.some(pattern => pattern.test(url))) { + return strategy; + } + } + return 'networkFirst'; // 기본값 +} + +/** + * CDN 리소스 확인 + */ +function isCDNResource(url) { + const cdnDomains = [ + 'cdn.tailwindcss.com', + 'cdnjs.cloudflare.com', + 'fonts.googleapis.com', + 'fonts.gstatic.com' + ]; + + return cdnDomains.some(domain => url.hostname.includes(domain)); +} + +/** + * 오프라인 처리 + */ +async function handleOffline(request) { + // HTML 요청에 대한 오프라인 페이지 + if (request.destination === 'document') { + const offlinePage = await caches.match('/index.html'); + if (offlinePage) { + return offlinePage; + } + } + + // 이미지 요청에 대한 기본 이미지 + if (request.destination === 'image') { + return new Response( + '오프라인', + { headers: { 'Content-Type': 'image/svg+xml' } } + ); + } + + // 기본 오프라인 응답 + return new Response('오프라인 상태입니다.', { + status: 503, + statusText: 'Service Unavailable', + headers: { 'Content-Type': 'text/plain; charset=utf-8' } + }); +} + +/** + * 메시지 처리 (캐시 관리) + */ +self.addEventListener('message', (event) => { + const { type, payload } = event.data; + + switch (type) { + case 'CLEAR_CACHE': + clearAllCaches().then(() => { + event.ports[0].postMessage({ success: true }); + }); + break; + + case 'CACHE_PAGE': + cachePage(payload.url).then(() => { + event.ports[0].postMessage({ success: true }); + }); + break; + + case 'GET_CACHE_STATUS': + getCacheStatus().then((status) => { + event.ports[0].postMessage({ status }); + }); + break; + } +}); + +/** + * 모든 캐시 정리 + */ +async function clearAllCaches() { + const cacheNames = await caches.keys(); + await Promise.all( + cacheNames.map(cacheName => caches.delete(cacheName)) + ); + console.log('🗑️ 모든 캐시 정리 완료'); +} + +/** + * 특정 페이지 캐시 + */ +async function cachePage(url) { + try { + const cache = await caches.open(DYNAMIC_CACHE); + await cache.add(url); + console.log('📦 페이지 캐시 완료:', url); + } catch (error) { + console.error('페이지 캐시 실패:', url, error); + } +} + +/** + * 캐시 상태 조회 + */ +async function getCacheStatus() { + const cacheNames = await caches.keys(); + const status = {}; + + for (const cacheName of cacheNames) { + const cache = await caches.open(cacheName); + const keys = await cache.keys(); + status[cacheName] = keys.length; + } + + return status; +}