Created
November 19, 2025 11:14
-
-
Save lunamoth/91c1e1bc25e219e9214c3fd4be6b1307 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="ko"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Vintage Weekly Planner 📜</title> | |
| <!-- 폰트 로딩 최적화 (Preload) --> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link rel="preload" href="https://fonts.gstatic.com/s/lora/v32/0QI6MX1D_JOuGQbT0bvTPZ0.woff2" as="font" type="font/woff2" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,600;0,700;1,400&display=swap" | |
| rel="stylesheet"> | |
| <style> | |
| @font-face { | |
| font-family: 'Lora'; | |
| font-style: normal; | |
| font-weight: 400; | |
| src: local('Lora'), url('https://fonts.gstatic.com/s/lora/v32/0QI6MX1D_JOuGQbT0bvTPZ0.woff2') format('woff2'); | |
| font-display: swap; | |
| } | |
| :root { | |
| --bg-color: #3d2c1d; | |
| --page-bg-color: #fdf6e3; | |
| --primary-text-color: #583e23; | |
| --secondary-text-color: #9f8c76; | |
| --border-color: #dcd1b8; | |
| --accent-color: #4a6b82; | |
| --accent-red-color: #b74a3d; | |
| --hover-bg-color: #f9f0d9; | |
| --page-border-color: #856a4f; | |
| --drag-line-color: #583e23; | |
| --sidebar-width: 320px; | |
| } | |
| body.dark-mode { | |
| --bg-color: #0f172a; | |
| --page-bg-color: #1e293b; | |
| --primary-text-color: #e2e8f0; | |
| --secondary-text-color: #94a3b8; | |
| --border-color: #334155; | |
| --accent-color: #38bdf8; | |
| --accent-red-color: #f87171; | |
| --hover-bg-color: #334155; | |
| --page-border-color: #475569; | |
| --drag-line-color: #e2e8f0; | |
| } | |
| *, | |
| *::before, | |
| *::after { | |
| box-sizing: border-box; | |
| } | |
| html, | |
| body { | |
| height: 100%; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| body { | |
| font-family: 'Lora', serif; | |
| background-color: var(--bg-color); | |
| color: var(--primary-text-color); | |
| -webkit-font-smoothing: antialiased; | |
| font-size: 16px; | |
| transition: background-color 0.3s ease, color 0.3s ease; | |
| overflow-x: hidden; | |
| } | |
| .planner-page { | |
| background-color: var(--page-bg-color); | |
| padding: 2.5rem; | |
| min-height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| position: relative; | |
| transition: background-color 0.3s ease, margin-right 0.3s ease; | |
| } | |
| .planner-page.search-active { | |
| margin-right: var(--sidebar-width); | |
| } | |
| main { | |
| display: flex; | |
| flex-grow: 1; | |
| flex-direction: column; | |
| } | |
| .app-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 2rem; | |
| border-bottom: 2px solid var(--page-border-color); | |
| padding-bottom: 1.5rem; | |
| } | |
| .header-left { | |
| display: flex; | |
| align-items: center; | |
| gap: 1rem; | |
| } | |
| .header-date h1 { | |
| font-size: 2.5rem; | |
| font-weight: 600; | |
| margin: 0; | |
| cursor: pointer; | |
| position: relative; | |
| } | |
| .save-status { | |
| font-size: 0.8rem; | |
| color: var(--secondary-text-color); | |
| font-style: italic; | |
| margin-left: 1rem; | |
| min-width: 60px; | |
| transition: color 0.3s; | |
| } | |
| .save-status.saving { | |
| color: var(--accent-color); | |
| } | |
| .week-navigation { | |
| display: flex; | |
| align-items: center; | |
| } | |
| .week-navigation button { | |
| background: none; | |
| border: 1px solid var(--border-color); | |
| border-radius: 4px; | |
| padding: 0.5rem 1rem; | |
| font-family: 'Lora', serif; | |
| font-size: 0.9rem; | |
| color: var(--primary-text-color); | |
| cursor: pointer; | |
| transition: background-color 0.2s, border-color 0.2s; | |
| margin: 0 0.25rem; | |
| } | |
| .week-navigation button:hover { | |
| background-color: var(--hover-bg-color); | |
| border-color: var(--page-border-color); | |
| } | |
| /* Date Picker Styles */ | |
| .calendar-btn { | |
| font-size: 1.1rem; | |
| padding: 0.4rem 0.8rem !important; | |
| } | |
| #datePicker { | |
| width: 0; | |
| height: 0; | |
| opacity: 0; | |
| position: absolute; | |
| z-index: -1; | |
| } | |
| .week-grid { | |
| display: grid; | |
| grid-template-columns: repeat(7, 1fr); | |
| border-left: 1px solid var(--border-color); | |
| flex-grow: 1; | |
| } | |
| .day-column { | |
| padding: 1rem 1.5rem; | |
| border-right: 1px solid var(--border-color); | |
| display: flex; | |
| flex-direction: column; | |
| cursor: text; | |
| transition: background-color 0.5s ease; | |
| outline: none; | |
| /* CSS Containment 적용: 렌더링 성능 최적화 */ | |
| contain: content; | |
| } | |
| .day-column:focus-within { | |
| background-color: rgba(var(--primary-text-color), 0.02); | |
| } | |
| @keyframes flash-highlight { | |
| 0% { | |
| background-color: rgba(220, 209, 184, 0.8); | |
| } | |
| 100% { | |
| background-color: transparent; | |
| } | |
| } | |
| .day-column.flash-active { | |
| animation: flash-highlight 1s ease-out; | |
| } | |
| .day-header { | |
| padding-bottom: 1rem; | |
| margin-bottom: 1rem; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: baseline; | |
| border-bottom: 1px solid var(--border-color); | |
| } | |
| .date { | |
| font-weight: 700; | |
| font-size: 1.32rem; | |
| } | |
| .day-name { | |
| font-size: 1.08rem; | |
| font-style: italic; | |
| color: var(--secondary-text-color); | |
| } | |
| .day-header.sunday .date, | |
| .day-header.sunday .day-name { | |
| color: var(--accent-red-color); | |
| } | |
| .day-header.saturday .date, | |
| .day-header.saturday .day-name { | |
| color: var(--accent-color); | |
| } | |
| .day-column.today .day-header .date { | |
| color: var(--primary-text-color); | |
| font-weight: 900; | |
| text-decoration: underline; | |
| text-decoration-thickness: 2px; | |
| } | |
| .today-date-highlight { | |
| color: var(--primary-text-color) !important; | |
| font-weight: 900 !important; | |
| text-decoration: underline; | |
| text-decoration-thickness: 2px; | |
| } | |
| .today-name-highlight { | |
| font-weight: 700 !important; | |
| color: var(--primary-text-color) !important; | |
| } | |
| .task-list { | |
| list-style: none; | |
| padding: 0; | |
| margin: 0; | |
| flex-grow: 1; | |
| min-height: 50px; | |
| /* Optimization: Strict layout containment for lists to reduce reflows */ | |
| contain: layout style; | |
| } | |
| .task-item { | |
| display: flex; | |
| align-items: flex-start; | |
| padding: 0.75rem 0.25rem; | |
| cursor: grab; | |
| transition: background-color 0.2s; | |
| border-bottom: 1px dotted var(--border-color); | |
| border-top: 2px solid transparent; | |
| } | |
| .task-item:last-of-type { | |
| border-bottom: none; | |
| } | |
| .task-item:hover { | |
| background-color: var(--hover-bg-color); | |
| } | |
| .task-item.dragging { | |
| opacity: 0.5; | |
| background-color: var(--hover-bg-color); | |
| } | |
| .task-item:focus-within { | |
| background-color: var(--hover-bg-color); | |
| } | |
| @keyframes search-pulse { | |
| 0% { | |
| background-color: rgba(74, 107, 130, 0.3); | |
| } | |
| 50% { | |
| background-color: rgba(74, 107, 130, 0.6); | |
| } | |
| 100% { | |
| background-color: rgba(74, 107, 130, 0.3); | |
| } | |
| } | |
| .highlight-task { | |
| animation: search-pulse 2s infinite; | |
| border-radius: 4px; | |
| } | |
| .drag-placeholder { | |
| height: 40px; | |
| background-color: rgba(88, 62, 35, 0.1); | |
| border: 2px dashed var(--drag-line-color); | |
| margin: 0.5rem 0; | |
| border-radius: 4px; | |
| transition: all 0.2s ease; | |
| } | |
| input[type="checkbox"] { | |
| width: 16px; | |
| height: 16px; | |
| margin-right: 0.75rem; | |
| margin-top: 0.25rem; | |
| cursor: pointer; | |
| flex-shrink: 0; | |
| accent-color: var(--primary-text-color); | |
| } | |
| .task-text { | |
| flex-grow: 1; | |
| outline: none; | |
| line-height: 1.6; | |
| min-height: 1em; | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| transition: opacity 0.3s, color 0.3s; | |
| border-radius: 3px; | |
| padding: 0 2px; | |
| } | |
| /* Markdown Styles - Enhanced */ | |
| .task-text b { | |
| font-weight: 700; | |
| color: var(--primary-text-color); | |
| } | |
| .task-text i { | |
| font-style: italic; | |
| color: var(--secondary-text-color); | |
| } | |
| /* Markdown UX Improvement: Distinct styling during editing */ | |
| .task-text.editing { | |
| font-family: "Courier New", Courier, monospace; /* Monospace for easier syntax editing */ | |
| background-color: rgba(var(--primary-text-color), 0.03); | |
| color: var(--primary-text-color); | |
| } | |
| input[type="checkbox"]:checked+.task-text { | |
| text-decoration: line-through; | |
| color: var(--secondary-text-color); | |
| font-style: italic; | |
| opacity: 0.5; | |
| } | |
| .delete-task { | |
| background: none; | |
| border: none; | |
| cursor: pointer; | |
| font-size: 1.2rem; | |
| opacity: 0; | |
| transition: opacity 0.2s; | |
| color: var(--secondary-text-color); | |
| font-weight: bold; | |
| } | |
| .task-item:hover .delete-task, | |
| .task-item:focus-within .delete-task { | |
| opacity: 0.5; | |
| } | |
| .delete-task:hover { | |
| opacity: 1; | |
| color: var(--accent-red-color); | |
| } | |
| /* Empty State */ | |
| .empty-state { | |
| text-align: center; | |
| padding: 2rem 0; | |
| opacity: 0.5; | |
| font-style: italic; | |
| font-size: 0.9rem; | |
| color: var(--secondary-text-color); | |
| pointer-events: none; | |
| } | |
| .data-controls { | |
| margin-top: 2rem; | |
| display: flex; | |
| justify-content: flex-end; | |
| gap: 1rem; | |
| opacity: 1; | |
| transition: opacity 0.3s ease; | |
| font-size: 0.8rem; | |
| } | |
| .data-btn { | |
| background: none; | |
| border: none; | |
| cursor: pointer; | |
| color: var(--secondary-text-color); | |
| text-decoration: underline; | |
| padding: 0; | |
| font-family: 'Lora', serif; | |
| } | |
| .data-btn:hover { | |
| color: var(--accent-color); | |
| } | |
| /* Search Sidebar (Updated UX) */ | |
| .search-overlay { | |
| position: fixed; | |
| top: 0; | |
| right: calc(-1 * var(--sidebar-width)); | |
| width: var(--sidebar-width); | |
| height: 100%; | |
| background-color: var(--bg-color); | |
| border-left: 1px solid var(--border-color); | |
| box-shadow: -2px 0 10px rgba(0, 0, 0, 0.1); | |
| z-index: 2000; | |
| display: flex; | |
| flex-direction: column; | |
| padding: 1.5rem; | |
| transition: right 0.3s ease; | |
| } | |
| .search-overlay.active { | |
| right: 0; | |
| } | |
| .search-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 1.5rem; | |
| } | |
| .search-title { | |
| font-weight: bold; | |
| font-size: 1.2rem; | |
| margin: 0; | |
| color: #ffffff; | |
| } | |
| .close-search { | |
| background: none; | |
| border: none; | |
| color: #ffffff; | |
| font-size: 1.5rem; | |
| cursor: pointer; | |
| } | |
| .search-input { | |
| background: rgba(255, 255, 255, 0.05); | |
| border: 1px solid rgba(255, 255, 255, 0.2); | |
| color: #ffffff; | |
| font-size: 1rem; | |
| font-family: 'Lora', serif; | |
| padding: 0.8rem; | |
| outline: none; | |
| width: 100%; | |
| border-radius: 4px; | |
| margin-bottom: 1rem; | |
| } | |
| .search-input:focus { | |
| border-color: #ffffff; | |
| background: rgba(255, 255, 255, 0.1); | |
| } | |
| .search-input::placeholder { | |
| color: rgba(255, 255, 255, 0.4); | |
| } | |
| .search-results { | |
| overflow-y: auto; | |
| flex-grow: 1; | |
| } | |
| .search-result-item { | |
| padding: 0.8rem; | |
| border-bottom: 1px solid rgba(255, 255, 255, 0.1); | |
| cursor: pointer; | |
| color: #ffffff; | |
| display: flex; | |
| flex-direction: column; | |
| transition: background-color 0.2s; | |
| } | |
| .search-result-item:hover { | |
| background-color: rgba(255, 255, 255, 0.1); | |
| } | |
| .result-week { | |
| font-size: 0.75rem; | |
| color: rgba(255, 255, 255, 0.6); | |
| display: block; | |
| margin-bottom: 0.25rem; | |
| } | |
| .result-text { | |
| font-size: 0.95rem; | |
| line-height: 1.4; | |
| color: #ffffff; | |
| } | |
| /* Keyboard shortcut hint */ | |
| .kbd-hint { | |
| font-size: 0.7rem; | |
| color: rgba(255, 255, 255, 0.5); | |
| border: 1px solid rgba(255, 255, 255, 0.2); | |
| padding: 2px 4px; | |
| border-radius: 3px; | |
| margin-left: auto; | |
| display: inline-block; | |
| } | |
| @media (max-width: 1200px) { | |
| .planner-page { | |
| padding: 1.5rem; | |
| } | |
| .planner-page.search-active { | |
| margin-right: 0; | |
| } | |
| .search-overlay { | |
| width: 100%; | |
| right: -100%; | |
| } | |
| .search-overlay.active { | |
| right: 0; | |
| } | |
| .week-grid { | |
| grid-template-columns: 1fr; | |
| border-left: none; | |
| } | |
| .day-column { | |
| border: 1px solid var(--border-color); | |
| border-top: none; | |
| } | |
| .day-column:first-child { | |
| border-top: 1px solid var(--border-color); | |
| } | |
| } | |
| @media print { | |
| .app-header button, | |
| .data-controls, | |
| .delete-task, | |
| .week-navigation, | |
| .save-status, | |
| #themeBtn, | |
| .empty-state, | |
| .search-overlay { | |
| display: none !important; | |
| } | |
| body { | |
| background-color: white !important; | |
| color: black !important; | |
| margin: 0 !important; | |
| } | |
| .planner-page { | |
| background-color: white !important; | |
| color: black !important; | |
| height: auto; | |
| padding: 0; | |
| margin: 0 !important; | |
| border: none !important; | |
| box-shadow: none !important; | |
| } | |
| .app-header { | |
| border-bottom: 1px solid #000; | |
| } | |
| .week-grid { | |
| border: 1px solid #000; | |
| display: grid; | |
| grid-template-columns: repeat(7, 1fr); | |
| page-break-inside: avoid; | |
| } | |
| .day-column { | |
| border: 1px solid #ccc; | |
| break-inside: avoid; | |
| page-break-inside: avoid; | |
| background-color: white !important; | |
| } | |
| .task-item { | |
| border-bottom: 1px solid #eee; | |
| page-break-inside: avoid; | |
| } | |
| input[type="checkbox"] { | |
| appearance: none; | |
| -webkit-appearance: none; | |
| border: 1px solid #000; | |
| width: 12px; | |
| height: 12px; | |
| } | |
| input[type="checkbox"]:checked { | |
| background-color: #000; | |
| box-shadow: inset 0 0 0 2px white; | |
| } | |
| .task-text { | |
| color: black !important; | |
| } | |
| /* Printing Optimization: Remove URL hrefs if displayed */ | |
| a[href]:after { | |
| content: none !important; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="planner-page" id="mainContainer"> | |
| <header class="app-header"> | |
| <div class="header-left"> | |
| <div class="header-date"> | |
| <h1 id="yearMonthDisplay" title="날짜 선택"></h1> | |
| </div> | |
| <span id="saveStatus" class="save-status"></span> | |
| </div> | |
| <div class="week-navigation"> | |
| <button id="prevWeekBtn"><</button> | |
| <button id="thisWeekBtn">이번 주</button> | |
| <button id="calendarBtn" class="calendar-btn">달력</button> | |
| <button id="searchBtn">검색</button> | |
| <button id="nextWeekBtn">></button> | |
| <input type="date" id="datePicker"> | |
| </div> | |
| </header> | |
| <main> | |
| <div class="week-grid" id="weekGrid"></div> | |
| <div class="data-controls"> | |
| <button id="migrateBtn" class="data-btn" title="지난 일정 이월하기">Migrate Tasks</button> | |
| <!-- Search Button Moved --> | |
| <button id="exportBtn" class="data-btn" title="데이터를 파일로 저장합니다">Backup Data</button> | |
| <button id="importBtn" class="data-btn" title="저장된 파일을 불러옵니다">Restore Data</button> | |
| <button id="resetBtn" class="data-btn" title="모든 데이터를 초기화하고 샘플 데이터를 로드합니다">Reset Data</button> | |
| <button id="themeBtn" class="data-btn" title="화면 테마를 변경합니다">Toggle Theme</button> | |
| <input type="file" id="importFile" style="display: none;" accept=".json"> | |
| </div> | |
| </main> | |
| </div> | |
| <div id="searchSidebar" class="search-overlay"> | |
| <div class="search-header"> | |
| <h2 class="search-title">기록 검색</h2> | |
| <button id="closeSearch" class="close-search">×</button> | |
| </div> | |
| <input type="text" id="searchInput" class="search-input" placeholder="검색어를 입력하세요..."> | |
| <div class="kbd-hint">ESC로 닫기</div> | |
| <div id="searchResults" class="search-results"></div> | |
| </div> | |
| <script> | |
| document.addEventListener('DOMContentLoaded', () => { | |
| // --- Constants --- | |
| const CLASSES = { | |
| DAY_COLUMN: 'day-column', | |
| DAY_HEADER: 'day-header', | |
| TASK_LIST: 'task-list', | |
| TASK_ITEM: 'task-item', | |
| TASK_TEXT: 'task-text', | |
| DELETE_BTN: 'delete-task', | |
| DRAGGING: 'dragging', | |
| TODAY: 'today', | |
| SUNDAY: 'sunday', | |
| SATURDAY: 'saturday', | |
| FLASH_ACTIVE: 'flash-active', | |
| SAVING: 'saving', | |
| DARK_MODE: 'dark-mode', | |
| HIGHLIGHT_TASK: 'highlight-task', | |
| TODAY_DATE_HIGHLIGHT: 'today-date-highlight', | |
| TODAY_NAME_HIGHLIGHT: 'today-name-highlight', | |
| DRAG_PLACEHOLDER: 'drag-placeholder', | |
| EMPTY_STATE: 'empty-state', | |
| SEARCH_ACTIVE: 'search-active', | |
| EDITING: 'editing' | |
| }; | |
| const DAY_NAMES = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일']; | |
| const STORAGE_PREFIX = 'vp_'; | |
| const INITIALIZED_KEY = 'vp_initialized'; | |
| // --- 1. Storage Manager Class (Data & Caching) --- | |
| class StorageManager { | |
| constructor() { | |
| this.cachedData = {}; | |
| // Web Worker for Search Indexing (Performance Optimization) | |
| this.searchWorker = this.initSearchWorker(); | |
| } | |
| // Initialize Web Worker inline via Blob | |
| initSearchWorker() { | |
| const workerScript = ` | |
| self.onmessage = function(e) { | |
| const { type, payload } = e.data; | |
| if (type === 'BUILD_INDEX') { | |
| const searchCache = []; | |
| const { dataMap, prefix } = payload; | |
| Object.entries(dataMap).forEach(([key, tasks]) => { | |
| const weekId = key.replace(prefix, ''); | |
| Object.entries(tasks).forEach(([dayKey, dayTasks]) => { | |
| dayTasks.forEach(task => { | |
| searchCache.push({ | |
| weekId: weekId, | |
| dayIndex: dayKey.split('-')[1], | |
| text: task.text.toLowerCase(), | |
| originalText: task.text, | |
| id: task.id | |
| }); | |
| }); | |
| }); | |
| }); | |
| self.postMessage({ type: 'INDEX_READY', payload: searchCache }); | |
| } else if (type === 'SEARCH') { | |
| const { query, index } = payload; | |
| const q = query.toLowerCase(); | |
| const results = index.filter(item => item.text.includes(q)); | |
| self.postMessage({ type: 'SEARCH_RESULTS', payload: results }); | |
| } | |
| }; | |
| `; | |
| const blob = new Blob([workerScript], { type: 'application/javascript' }); | |
| return new Worker(URL.createObjectURL(blob)); | |
| } | |
| getWeekData(weekId) { | |
| if (this.cachedData[weekId]) { | |
| return this.cachedData[weekId]; | |
| } | |
| const raw = localStorage.getItem(STORAGE_PREFIX + weekId); | |
| const data = raw ? JSON.parse(raw) : null; | |
| if (data) this.cachedData[weekId] = data; | |
| return data; | |
| } | |
| saveWeekData(weekId, data) { | |
| this.cachedData[weekId] = data; | |
| localStorage.setItem(STORAGE_PREFIX + weekId, JSON.stringify(data)); | |
| // Trigger background index update | |
| this.triggerIndexBuild(); | |
| } | |
| isInitialized() { | |
| return localStorage.getItem(INITIALIZED_KEY) === 'true'; | |
| } | |
| setInitialized() { | |
| localStorage.setItem(INITIALIZED_KEY, 'true'); | |
| } | |
| getAllKeys() { | |
| const keys = []; | |
| for (let i = 0; i < localStorage.length; i++) { | |
| const key = localStorage.key(i); | |
| if (key.startsWith(STORAGE_PREFIX) && key !== INITIALIZED_KEY) { | |
| keys.push(key); | |
| } | |
| } | |
| return keys; | |
| } | |
| // Performance: Use Worker for indexing | |
| triggerIndexBuild() { | |
| const dataMap = {}; | |
| this.getAllKeys().forEach(key => { | |
| try { | |
| dataMap[key] = JSON.parse(localStorage.getItem(key)); | |
| } catch (e) { console.error("Cache Load Error", e); } | |
| }); | |
| this.searchWorker.postMessage({ | |
| type: 'BUILD_INDEX', | |
| payload: { dataMap, prefix: STORAGE_PREFIX } | |
| }); | |
| } | |
| search(query, callback) { | |
| // Search request is handled by main controller, this is just a placeholder or util | |
| // We'll manage search state in VintagePlanner class using the worker instance | |
| } | |
| reset() { | |
| // Clear all keys related to the app | |
| this.getAllKeys().forEach(key => localStorage.removeItem(key)); | |
| localStorage.removeItem(INITIALIZED_KEY); | |
| this.cachedData = {}; | |
| } | |
| // Security: Validate Schema | |
| validateImportData(data) { | |
| if (typeof data !== 'object' || data === null) return false; | |
| // Basic structure check | |
| return Object.keys(data).every(key => { | |
| if (!key.startsWith(STORAGE_PREFIX) && key !== INITIALIZED_KEY) return false; | |
| return true; | |
| }); | |
| } | |
| import(jsonString) { | |
| try { | |
| const data = JSON.parse(jsonString); | |
| if (!this.validateImportData(data)) throw new Error("Invalid Data Format"); | |
| this.reset(); | |
| for (const key in data) { | |
| localStorage.setItem(key, data[key]); | |
| } | |
| this.setInitialized(); | |
| // We don't load all data here, let it be lazy | |
| return true; | |
| } catch (e) { | |
| throw e; | |
| } | |
| } | |
| } | |
| // --- 2. UI Manager Class (DOM Manipulation & Rendering) --- | |
| class UIManager { | |
| constructor(containerId) { | |
| this.weekGrid = document.getElementById(containerId); | |
| this.dayLists = []; | |
| this.initGrid(); | |
| } | |
| initGrid() { | |
| const fragment = document.createDocumentFragment(); | |
| for (let i = 0; i < 7; i++) { | |
| const dayColumn = document.createElement('div'); | |
| dayColumn.className = CLASSES.DAY_COLUMN; | |
| dayColumn.dataset.dayIndex = i; | |
| dayColumn.tabIndex = -1; | |
| const header = document.createElement('div'); | |
| header.className = CLASSES.DAY_HEADER; | |
| if (i === 0) header.classList.add(CLASSES.SUNDAY); | |
| if (i === 6) header.classList.add(CLASSES.SATURDAY); | |
| header.innerHTML = `<span class="date"></span><span class="day-name"></span>`; | |
| const taskList = document.createElement('ul'); | |
| taskList.className = CLASSES.TASK_LIST; | |
| taskList.id = `task-list-${i}`; | |
| this.dayLists.push(taskList); | |
| dayColumn.append(header, taskList); | |
| fragment.appendChild(dayColumn); | |
| } | |
| this.weekGrid.appendChild(fragment); | |
| } | |
| updateDates(startOfWeek, today) { | |
| for (let i = 0; i < 7; i++) { | |
| const colDate = new Date(startOfWeek); | |
| colDate.setDate(startOfWeek.getDate() + i); | |
| const dayColumn = this.weekGrid.children[i]; | |
| const dateSpan = dayColumn.querySelector('.date'); | |
| const nameSpan = dayColumn.querySelector('.day-name'); | |
| // Check date equality using strings to avoid time issues | |
| if (colDate.toDateString() === today.toDateString()) { | |
| dayColumn.classList.add(CLASSES.TODAY); | |
| dateSpan.classList.add(CLASSES.TODAY_DATE_HIGHLIGHT); | |
| nameSpan.classList.add(CLASSES.TODAY_NAME_HIGHLIGHT); | |
| } else { | |
| dayColumn.classList.remove(CLASSES.TODAY); | |
| dateSpan.classList.remove(CLASSES.TODAY_DATE_HIGHLIGHT); | |
| nameSpan.classList.remove(CLASSES.TODAY_NAME_HIGHLIGHT); | |
| } | |
| dateSpan.textContent = colDate.getDate(); | |
| nameSpan.textContent = DAY_NAMES[i].substring(0, 1); | |
| } | |
| } | |
| // Optimization: Use Text Nodes instead of innerHTML for better performance and security | |
| renderMarkdown(container, text) { | |
| // If text hasn't changed, don't re-render (simple check) | |
| if (container.dataset.rawText === text) return; | |
| container.innerHTML = ''; // Clear | |
| container.dataset.rawText = text || ''; | |
| if (!text) return; | |
| // Simple parser for **bold** and _italic_ | |
| // Splits by tokens, keeping delimiters | |
| const parts = text.split(/(\*\*.*?\*\*|_.*?_)/g); | |
| parts.forEach(part => { | |
| if (part.startsWith('**') && part.endsWith('**') && part.length >= 4) { | |
| const b = document.createElement('b'); | |
| b.textContent = part.slice(2, -2); | |
| container.appendChild(b); | |
| } else if (part.startsWith('_') && part.endsWith('_') && part.length >= 2) { | |
| const i = document.createElement('i'); | |
| i.textContent = part.slice(1, -1); | |
| container.appendChild(i); | |
| } else if (part.length > 0) { | |
| container.appendChild(document.createTextNode(part)); | |
| } | |
| }); | |
| } | |
| // Optimization: Use Virtual DOM concept (Minimize DOM Ops) | |
| renderTasks(dayIndex, tasks) { | |
| const list = this.dayLists[dayIndex]; | |
| const activeEl = document.activeElement; | |
| // Check Empty State | |
| const emptyMsg = list.querySelector(`.${CLASSES.EMPTY_STATE}`); | |
| if (tasks.length === 0) { | |
| if (!emptyMsg || list.children.length > 1) { | |
| list.innerHTML = ''; | |
| const div = document.createElement('div'); | |
| div.className = CLASSES.EMPTY_STATE; | |
| div.textContent = "새로운 모험을 기록하세요..."; | |
| list.appendChild(div); | |
| } | |
| return; | |
| } else if (emptyMsg) { | |
| emptyMsg.remove(); | |
| } | |
| const currentItems = Array.from(list.querySelectorAll(`.${CLASSES.TASK_ITEM}`)); | |
| const currentMap = new Map(currentItems.map(el => [el.id, el])); | |
| const newIds = new Set(tasks.map(t => t.id)); | |
| // 1. Remove deleted items | |
| currentItems.forEach(el => { | |
| if (!newIds.has(el.id)) el.remove(); | |
| }); | |
| // 2. Update or Create items | |
| let previousNode = null; | |
| tasks.forEach((task, index) => { | |
| let item = currentMap.get(task.id); | |
| if (item) { | |
| // Precise Update (Minimize Layout Thrashing) | |
| const checkbox = item.querySelector('input[type="checkbox"]'); | |
| if (checkbox.checked !== task.completed) checkbox.checked = task.completed; | |
| const textEl = item.querySelector(`.${CLASSES.TASK_TEXT}`); | |
| // IMPORTANT: Only update text if NOT focused to prevent cursor jumps | |
| if (activeEl !== textEl) { | |
| this.renderMarkdown(textEl, task.text); | |
| } | |
| // Reordering check | |
| if (index === 0) { | |
| if (list.firstChild !== item) list.prepend(item); | |
| } else { | |
| if (previousNode.nextSibling !== item) { | |
| previousNode.after(item); | |
| } | |
| } | |
| } else { | |
| // Create new | |
| item = this.createTaskElement(task); | |
| if (index === 0) { | |
| list.prepend(item); | |
| } else { | |
| previousNode.after(item); | |
| } | |
| } | |
| previousNode = item; | |
| }); | |
| } | |
| createTaskElement(task) { | |
| const item = document.createElement('li'); | |
| item.className = CLASSES.TASK_ITEM; | |
| item.draggable = true; | |
| item.id = task.id; | |
| const checkbox = document.createElement('input'); | |
| checkbox.type = 'checkbox'; | |
| checkbox.checked = task.completed; | |
| const textSpan = document.createElement('span'); | |
| textSpan.className = CLASSES.TASK_TEXT; | |
| textSpan.contentEditable = true; | |
| this.renderMarkdown(textSpan, task.text); | |
| // Removed inline event listeners for Delegation Optimization | |
| const deleteBtn = document.createElement('button'); | |
| deleteBtn.className = CLASSES.DELETE_BTN; | |
| deleteBtn.textContent = '×'; | |
| item.append(checkbox, textSpan, deleteBtn); | |
| return item; | |
| } | |
| } | |
| // --- 3. Main Application Class (Controller) --- | |
| class VintagePlanner { | |
| constructor() { | |
| this.storage = new StorageManager(); | |
| this.ui = new UIManager('weekGrid'); | |
| // Core State | |
| this.currentDate = new Date(); | |
| this.currentWeekId = null; | |
| this.cachedWeekData = {}; | |
| this.draggedItemMeta = null; | |
| this.dragPlaceholder = null; | |
| this.focusedTaskState = null; // For drag focus restoration | |
| this.searchIndex = []; // Cache from Worker | |
| this.dragAF = null; // rAF throttle for drag | |
| this.boundHandlers = {}; // Store handlers for cleanup | |
| // Optimization: Debounced Save | |
| this.saveDataDebounced = this.debounce(this.performSave.bind(this), 500); | |
| this.bindElements(); | |
| this.bindEvents(); | |
| this.init(); | |
| } | |
| bindElements() { | |
| this.elements = { | |
| container: document.getElementById('mainContainer'), | |
| yearMonth: document.getElementById('yearMonthDisplay'), | |
| prevBtn: document.getElementById('prevWeekBtn'), | |
| thisBtn: document.getElementById('thisWeekBtn'), | |
| nextBtn: document.getElementById('nextWeekBtn'), | |
| calBtn: document.getElementById('calendarBtn'), | |
| datePicker: document.getElementById('datePicker'), | |
| exportBtn: document.getElementById('exportBtn'), | |
| importBtn: document.getElementById('importBtn'), | |
| importFile: document.getElementById('importFile'), | |
| resetBtn: document.getElementById('resetBtn'), | |
| saveStatus: document.getElementById('saveStatus'), | |
| migrateBtn: document.getElementById('migrateBtn'), | |
| themeBtn: document.getElementById('themeBtn'), | |
| searchBtn: document.getElementById('searchBtn'), | |
| searchSidebar: document.getElementById('searchSidebar'), | |
| closeSearch: document.getElementById('closeSearch'), | |
| searchInput: document.getElementById('searchInput'), | |
| searchResults: document.getElementById('searchResults') | |
| }; | |
| } | |
| init() { | |
| // Init Worker listeners | |
| this.storage.searchWorker.onmessage = (e) => { | |
| const { type, payload } = e.data; | |
| if (type === 'INDEX_READY') { | |
| this.searchIndex = payload; | |
| } else if (type === 'SEARCH_RESULTS') { | |
| this.displaySearchResults(payload); | |
| } | |
| }; | |
| // Build Initial Index | |
| this.storage.triggerIndexBuild(); | |
| this.render(); | |
| if (localStorage.getItem('theme') === 'dark') { | |
| document.body.classList.add(CLASSES.DARK_MODE); | |
| } | |
| window.addEventListener('beforeunload', () => this.performSave()); // Force save on exit | |
| } | |
| getWeekId(date) { | |
| // Use the start of the week (Sunday) as the unique ID | |
| const start = this.getStartOfWeek(date); | |
| // Format: YYYY-MM-DD (Local Time) | |
| const year = start.getFullYear(); | |
| const month = String(start.getMonth() + 1).padStart(2, '0'); | |
| const day = String(start.getDate()).padStart(2, '0'); | |
| return `${year}-${month}-${day}`; | |
| } | |
| getStartOfWeek(date) { | |
| const d = new Date(date); // Fix: Clone date to prevent side effects | |
| d.setHours(0, 0, 0, 0); // Normalize | |
| const day = d.getDay(); // 0 is Sunday | |
| const diff = d.getDate() - day; // Subtract days to get to Sunday | |
| d.setDate(diff); | |
| return d; | |
| } | |
| generateUUID() { | |
| return typeof crypto !== 'undefined' && crypto.randomUUID | |
| ? `task-${crypto.randomUUID()}` | |
| : `task-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; | |
| } | |
| debounce(func, timeout = 300) { | |
| let timer; | |
| return (...args) => { | |
| clearTimeout(timer); | |
| timer = setTimeout(() => { func.apply(this, args); }, timeout); | |
| }; | |
| } | |
| render(force = false) { | |
| const startOfWeek = this.getStartOfWeek(this.currentDate); | |
| // FIX: Use getWeekId instead of ISO logic | |
| const weekId = this.getWeekId(startOfWeek); | |
| this.ui.updateDates(startOfWeek, new Date()); | |
| this.updateHeader(startOfWeek); | |
| if (!force && this.currentWeekId === weekId) return; | |
| this.currentWeekId = weekId; | |
| this.loadDataForWeek(weekId); | |
| } | |
| updateHeader(startOfWeek) { | |
| const endOfWeek = new Date(startOfWeek); | |
| endOfWeek.setDate(startOfWeek.getDate() + 6); | |
| const startMonth = startOfWeek.toLocaleString('ko-KR', { month: 'long' }); | |
| const endMonth = endOfWeek.toLocaleString('ko-KR', { month: 'long' }); | |
| const year = startOfWeek.getFullYear(); | |
| this.elements.yearMonth.textContent = startOfWeek.getMonth() !== endOfWeek.getMonth() | |
| ? `${year}년 ${startMonth} - ${endMonth}` | |
| : `${year}년 ${startMonth}`; | |
| } | |
| loadDataForWeek(weekId) { | |
| let data = this.storage.getWeekData(weekId); | |
| const isInit = this.storage.isInitialized(); | |
| // Bug Fix: Force sample data on first load if not initialized, ignoring logic checks | |
| if (!isInit) { | |
| data = this.getSampleData(); | |
| this.storage.setInitialized(); | |
| this.storage.saveWeekData(weekId, data); | |
| console.log("Loaded Sample Data for Week:", weekId); | |
| } else if (!data) { | |
| data = {}; | |
| } | |
| this.cachedWeekData = JSON.parse(JSON.stringify(data)); | |
| for (let i = 0; i < 7; i++) { | |
| if (!this.cachedWeekData[`day-${i}`]) this.cachedWeekData[`day-${i}`] = []; | |
| } | |
| for (let i = 0; i < 7; i++) { | |
| this.ui.renderTasks(i, this.cachedWeekData[`day-${i}`]); | |
| } | |
| } | |
| getSampleData() { | |
| const now = Date.now(); | |
| // Added Randomness to ensure unique IDs even if generated quickly | |
| const r = () => Math.random().toString(36).substring(2, 5); | |
| return { | |
| "day-0": [ | |
| { id: `task-${now}_0_1_${r()}`, text: "마샬 대학에서 원정 계획 수립 🏫", completed: true }, | |
| { id: `task-${now}_0_2_${r()}`, text: "채찍과 페도라 챙기기 🎒", completed: false } | |
| ], | |
| "day-1": [ | |
| { id: `task-${now}_1_1_${r()}`, text: "카이로에서 살라 만나기 🇪🇬", completed: false }, | |
| { id: `task-${now}_1_2_${r()}`, text: "라의 지팡이 장식 해독하기 📜", completed: false } | |
| ], | |
| "day-2": [ | |
| { id: `task-${now}_2_1_${r()}`, text: "타니스의 지도실 방문 🗺️", completed: false }, | |
| { id: `task-${now}_2_2_${r()}`, text: "시장통에서 추격자 일당 따돌리기 (14:00) 🕵️♂️", completed: false } | |
| ], | |
| "day-3": [ | |
| { id: `task-${now}_3_1_${r()}`, text: "조종사 젭과 함께 정글로 비행 ✈️", completed: false }, | |
| { id: `task-${now}_3_2_${r()}`, text: "**주의:** 뱀 조심할 것! 🐍", completed: false } | |
| ], | |
| "day-4": [ | |
| { id: `task-${now}_4_1_${r()}`, text: "고대 사원의 함정 통과하기 🧩", completed: false }, | |
| { id: `task-${now}_4_2_${r()}`, text: "거대한 바위에서 탈출하기 🪨", completed: true } | |
| ], | |
| "day-5": [ | |
| { id: `task-${now}_5_1_${r()}`, text: "성궤 회수하기 (!!)", completed: false }, | |
| { id: `task-${now}_5_2_${r()}`, text: "눈 감아! 절대 보지 마! 🫣", completed: false } | |
| ], | |
| "day-6": [ | |
| { id: `task-${now}_6_1_${r()}`, text: "박물관에 유물 전달하기 🏛️", completed: false }, | |
| { id: `task-${now}_6_2_${r()}`, text: "메리언과 저녁 식사 🍷", completed: false } | |
| ] | |
| }; | |
| } | |
| // Wrapper for debounce | |
| saveData() { | |
| this.saveDataDebounced(); | |
| } | |
| // Actual Save Logic | |
| performSave() { | |
| if (!this.currentWeekId) return; | |
| this.storage.saveWeekData(this.currentWeekId, this.cachedWeekData); | |
| this.elements.saveStatus.textContent = "Saved"; | |
| this.elements.saveStatus.classList.add(CLASSES.SAVING); | |
| setTimeout(() => { | |
| this.elements.saveStatus.classList.remove(CLASSES.SAVING); | |
| this.elements.saveStatus.textContent = ""; | |
| }, 1000); | |
| } | |
| // --- Interactions --- | |
| handleGridClick(e) { | |
| const target = e.target; | |
| const taskItem = target.closest(`.${CLASSES.TASK_ITEM}`); | |
| const dayColumn = target.closest(`.${CLASSES.DAY_COLUMN}`); | |
| // Delete | |
| if (target.classList.contains(CLASSES.DELETE_BTN) && taskItem) { | |
| const dayIndex = dayColumn.dataset.dayIndex; | |
| const task = this.findTask(dayIndex, taskItem.id); | |
| const taskIndex = this.cachedWeekData[`day-${dayIndex}`].indexOf(task); | |
| this.cachedWeekData[`day-${dayIndex}`].splice(taskIndex, 1); | |
| this.ui.renderTasks(dayIndex, this.cachedWeekData[`day-${dayIndex}`]); | |
| this.saveData(); | |
| return; | |
| } | |
| // Checkbox | |
| if (target.type === 'checkbox' && taskItem) { | |
| const dayIndex = dayColumn.dataset.dayIndex; | |
| const task = this.findTask(dayIndex, taskItem.id); | |
| task.completed = target.checked; | |
| // Trigger render to update visual styles if needed, or just save | |
| this.ui.renderTasks(dayIndex, this.cachedWeekData[`day-${dayIndex}`]); | |
| this.saveData(); | |
| return; | |
| } | |
| // Add Task (Empty Area) | |
| if (dayColumn && !taskItem && !target.closest(`.${CLASSES.DAY_HEADER}`)) { | |
| if (window.getSelection().toString().length > 0) return; | |
| const dayIndex = dayColumn.dataset.dayIndex; | |
| const newTask = { id: this.generateUUID(), text: "", completed: false }; | |
| this.cachedWeekData[`day-${dayIndex}`].push(newTask); | |
| this.ui.renderTasks(dayIndex, this.cachedWeekData[`day-${dayIndex}`]); | |
| this.saveData(); | |
| setTimeout(() => { | |
| const el = document.getElementById(newTask.id); | |
| if (el) el.querySelector(`.${CLASSES.TASK_TEXT}`).focus(); | |
| }, 0); | |
| } | |
| } | |
| findTask(dayIndex, taskId) { | |
| return this.cachedWeekData[`day-${dayIndex}`].find(t => t.id === taskId); | |
| } | |
| // --- Event Handlers --- | |
| handleKeydown(e) { | |
| // Global Shortcuts | |
| if ((e.ctrlKey || e.metaKey) && e.key === 'f') { | |
| e.preventDefault(); | |
| this.toggleSearch(); | |
| return; | |
| } | |
| if (e.key === 'Escape') { | |
| this.elements.searchSidebar.classList.remove('active'); | |
| this.elements.container.classList.remove(CLASSES.SEARCH_ACTIVE); | |
| document.activeElement.blur(); | |
| return; | |
| } | |
| // Task Editing Shortcuts | |
| if (e.target.classList.contains(CLASSES.TASK_TEXT)) { | |
| const item = e.target.closest(`.${CLASSES.TASK_ITEM}`); | |
| const dayIndex = item.closest(`.${CLASSES.DAY_COLUMN}`).dataset.dayIndex; | |
| // Ctrl+Enter for Complete | |
| if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { | |
| e.preventDefault(); | |
| const checkbox = item.querySelector('input[type="checkbox"]'); | |
| checkbox.click(); // Trigger existing logic | |
| return; | |
| } | |
| if (e.key === 'Enter') { | |
| e.preventDefault(); | |
| // Fix: IME duplicate event check | |
| if (e.isComposing || e.keyCode === 229) return; | |
| e.target.blur(); | |
| const tasks = this.cachedWeekData[`day-${dayIndex}`]; | |
| const idx = tasks.findIndex(t => t.id === item.id); | |
| const newTask = { id: this.generateUUID(), text: "", completed: false }; | |
| tasks.splice(idx + 1, 0, newTask); | |
| this.ui.renderTasks(dayIndex, tasks); | |
| this.saveData(); | |
| setTimeout(() => document.getElementById(newTask.id).querySelector(`.${CLASSES.TASK_TEXT}`).focus(), 0); | |
| } else if (e.key === 'Backspace') { | |
| // Bug Fix: Allow deletion if text matches selection (all selected) | |
| const selection = window.getSelection().toString(); | |
| const textContent = e.target.textContent; | |
| if (textContent === '' || (selection.length > 0 && selection === textContent)) { | |
| e.preventDefault(); | |
| const prev = item.previousElementSibling; | |
| const delBtn = item.querySelector(`.${CLASSES.DELETE_BTN}`); | |
| if (delBtn) delBtn.click(); // Use existing logic | |
| if (prev && prev.classList.contains(CLASSES.TASK_ITEM)) { | |
| const textEl = prev.querySelector(`.${CLASSES.TASK_TEXT}`); | |
| const range = document.createRange(); | |
| const sel = window.getSelection(); | |
| range.selectNodeContents(textEl); | |
| range.collapse(false); | |
| sel.removeAllRanges(); | |
| sel.addRange(range); | |
| textEl.focus(); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // Fix: Visual distinction logic moved from inline listener | |
| handleFocusIn(e) { | |
| if (e.target.classList.contains(CLASSES.TASK_TEXT)) { | |
| e.target.classList.add(CLASSES.EDITING); | |
| } | |
| } | |
| handleFocusOut(e) { | |
| if (e.target.classList.contains(CLASSES.TASK_TEXT)) { | |
| e.target.classList.remove(CLASSES.EDITING); | |
| const item = e.target.closest(`.${CLASSES.TASK_ITEM}`); | |
| const dayIndex = item.closest(`.${CLASSES.DAY_COLUMN}`).dataset.dayIndex; | |
| const task = this.findTask(dayIndex, item.id); | |
| if (task) { | |
| const newText = e.target.innerText; // Use innerText to be safe | |
| const oldText = task.text; | |
| if (newText.trim() === '') { | |
| // Auto delete if empty | |
| const idx = this.cachedWeekData[`day-${dayIndex}`].indexOf(task); | |
| this.cachedWeekData[`day-${dayIndex}`].splice(idx, 1); | |
| this.ui.renderTasks(dayIndex, this.cachedWeekData[`day-${dayIndex}`]); | |
| this.saveData(); | |
| } else if (newText !== oldText) { | |
| task.text = newText; | |
| this.saveData(); | |
| } else { | |
| // Just format Markdown if no change | |
| this.ui.renderMarkdown(e.target, task.text); | |
| } | |
| } | |
| } | |
| } | |
| // Fix: Modern Paste Handling (Replaces deprecated execCommand) | |
| handlePaste(e) { | |
| if (!e.target.classList.contains(CLASSES.TASK_TEXT)) return; | |
| e.preventDefault(); | |
| // Get plain text | |
| const text = (e.clipboardData || window.clipboardData).getData('text'); | |
| const selection = window.getSelection(); | |
| if (!selection.rangeCount) return; | |
| selection.deleteFromDocument(); | |
| const textNode = document.createTextNode(text); | |
| selection.getRangeAt(0).insertNode(textNode); | |
| // Move cursor to end of inserted text | |
| selection.collapse(textNode, textNode.length); | |
| } | |
| // --- Drag & Drop Optimized (rAF) --- | |
| handleDragStart(e) { | |
| if (!e.target.classList.contains(CLASSES.TASK_ITEM)) return; | |
| const item = e.target; | |
| const dayIndex = item.closest(`.${CLASSES.DAY_COLUMN}`).dataset.dayIndex; | |
| const task = this.findTask(dayIndex, item.id); | |
| const taskIndex = this.cachedWeekData[`day-${dayIndex}`].indexOf(task); | |
| // Fix: Save focus state before drag starts to restore it later | |
| const activeEl = document.activeElement; | |
| if (activeEl && activeEl.classList.contains(CLASSES.TASK_TEXT)) { | |
| this.focusedTaskState = { | |
| id: activeEl.closest(`.${CLASSES.TASK_ITEM}`).id | |
| }; | |
| } else { | |
| this.focusedTaskState = null; | |
| } | |
| this.draggedItemMeta = { | |
| id: item.id, | |
| sourceDayIndex: dayIndex, | |
| sourceIndex: taskIndex, | |
| taskData: { ...task } | |
| }; | |
| e.target.classList.add(CLASSES.DRAGGING); | |
| e.dataTransfer.effectAllowed = 'move'; | |
| // Optimized Ghost | |
| const ghost = item.cloneNode(true); | |
| ghost.style.width = item.offsetWidth + "px"; | |
| ghost.style.position = "absolute"; | |
| ghost.style.top = "-1000px"; | |
| document.body.appendChild(ghost); | |
| e.dataTransfer.setDragImage(ghost, 0, 0); | |
| setTimeout(() => document.body.removeChild(ghost), 0); | |
| } | |
| handleDragOver(e) { | |
| e.preventDefault(); | |
| // Performance: RequestAnimationFrame Optimization | |
| if (this.dragAF) return; | |
| this.dragAF = requestAnimationFrame(() => { | |
| const list = e.target.closest(`.${CLASSES.TASK_LIST}`); | |
| if (list) { | |
| if (!this.dragPlaceholder) { | |
| this.dragPlaceholder = document.createElement('div'); | |
| this.dragPlaceholder.className = CLASSES.DRAG_PLACEHOLDER; | |
| } | |
| const afterElement = this.getDragAfterElement(list, e.clientY); | |
| if (afterElement == null) { | |
| list.appendChild(this.dragPlaceholder); | |
| } else { | |
| list.insertBefore(this.dragPlaceholder, afterElement); | |
| } | |
| } | |
| this.dragAF = null; | |
| }); | |
| } | |
| handleDrop(e) { | |
| e.preventDefault(); | |
| if(this.dragAF) { | |
| cancelAnimationFrame(this.dragAF); | |
| this.dragAF = null; | |
| } | |
| const list = e.target.closest(`.${CLASSES.TASK_LIST}`); | |
| const meta = this.draggedItemMeta; | |
| if (list && meta) { | |
| const targetDayIndex = list.id.split('-')[1]; | |
| // Calculate new index based on DOM placeholder | |
| const children = Array.from(list.children).filter(el => | |
| el !== this.dragPlaceholder && !el.classList.contains(CLASSES.DRAGGING) && el.classList.contains(CLASSES.TASK_ITEM) | |
| ); | |
| let insertIndex = children.length; | |
| const afterElement = this.getDragAfterElement(list, e.clientY); | |
| if (afterElement) insertIndex = children.indexOf(afterElement); | |
| // DOM Move (Performance Optimization) | |
| const item = document.getElementById(meta.id); | |
| if (this.dragPlaceholder.parentNode === list) { | |
| list.insertBefore(item, this.dragPlaceholder); | |
| } | |
| // Update Data Model | |
| const sourceTasks = this.cachedWeekData[`day-${meta.sourceDayIndex}`]; | |
| sourceTasks.splice(meta.sourceIndex, 1); | |
| const targetTasks = this.cachedWeekData[`day-${targetDayIndex}`]; | |
| // Adjust index if moving within same list downwards is not needed here because we splice out first | |
| targetTasks.splice(insertIndex, 0, meta.taskData); | |
| this.saveData(); | |
| // Re-render ensures DOM state matches Data state perfectly | |
| // We re-render both source and target columns | |
| this.ui.renderTasks(meta.sourceDayIndex, sourceTasks); | |
| if (meta.sourceDayIndex !== targetDayIndex) { | |
| this.ui.renderTasks(targetDayIndex, targetTasks); | |
| } | |
| } | |
| this.handleDragEnd(); | |
| } | |
| handleDragEnd() { | |
| if (this.dragPlaceholder) { | |
| this.dragPlaceholder.remove(); | |
| this.dragPlaceholder = null; | |
| } | |
| const dragging = document.querySelector(`.${CLASSES.DRAGGING}`); | |
| if (dragging) dragging.classList.remove(CLASSES.DRAGGING); | |
| this.draggedItemMeta = null; | |
| // Fix: Restore focus if needed | |
| if (this.focusedTaskState) { | |
| const el = document.getElementById(this.focusedTaskState.id); | |
| if (el) { | |
| const textSpan = el.querySelector(`.${CLASSES.TASK_TEXT}`); | |
| textSpan.focus(); | |
| // Restore cursor to end of text to prevent jumping | |
| const range = document.createRange(); | |
| const sel = window.getSelection(); | |
| range.selectNodeContents(textSpan); | |
| range.collapse(false); | |
| sel.removeAllRanges(); | |
| sel.addRange(range); | |
| } | |
| this.focusedTaskState = null; | |
| } | |
| } | |
| getDragAfterElement(container, y) { | |
| const draggableElements = [...container.querySelectorAll(`.${CLASSES.TASK_ITEM}:not(.${CLASSES.DRAGGING})`)]; | |
| return draggableElements.reduce((closest, child) => { | |
| const box = child.getBoundingClientRect(); | |
| const offset = y - box.top - box.height / 2; | |
| return (offset < 0 && offset > closest.offset) ? { offset: offset, element: child } : closest; | |
| }, { offset: Number.NEGATIVE_INFINITY }).element; | |
| } | |
| // --- Search Feature (Worker Powered) --- | |
| toggleSearch() { | |
| const sidebar = this.elements.searchSidebar; | |
| const container = this.elements.container; | |
| if (sidebar.classList.contains('active')) { | |
| sidebar.classList.remove('active'); | |
| container.classList.remove(CLASSES.SEARCH_ACTIVE); | |
| } else { | |
| sidebar.classList.add('active'); | |
| container.classList.add(CLASSES.SEARCH_ACTIVE); | |
| this.elements.searchInput.focus(); | |
| } | |
| } | |
| performSearch(query) { | |
| if (!query) { | |
| this.elements.searchResults.innerHTML = ''; | |
| return; | |
| } | |
| // Offload to Worker | |
| this.storage.searchWorker.postMessage({ | |
| type: 'SEARCH', | |
| payload: { query, index: this.searchIndex } | |
| }); | |
| } | |
| displaySearchResults(results) { | |
| this.elements.searchResults.innerHTML = ''; | |
| if (results.length === 0) { | |
| this.elements.searchResults.innerHTML = '<div class="search-result-item">결과가 없습니다.</div>'; | |
| return; | |
| } | |
| const fragment = document.createDocumentFragment(); | |
| results.forEach(res => { | |
| const div = document.createElement('div'); | |
| div.className = 'search-result-item'; | |
| div.innerHTML = `<span class="result-week">${res.weekId} / ${DAY_NAMES[res.dayIndex]}</span> | |
| <span class="result-text">${res.originalText}</span>`; | |
| div.onclick = () => { | |
| // FIX: Simplified Date Navigation (Local Timezone Safe) | |
| const [y, m, d] = res.weekId.split('-').map(Number); | |
| const targetDate = new Date(y, m - 1, d); | |
| this.currentDate = targetDate; | |
| this.render(true); | |
| setTimeout(() => { | |
| const el = document.getElementById(res.id); | |
| if (el) { | |
| el.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
| el.classList.add(CLASSES.HIGHLIGHT_TASK); | |
| setTimeout(() => el.classList.remove(CLASSES.HIGHLIGHT_TASK), 2000); | |
| } | |
| }, 300); | |
| }; | |
| fragment.appendChild(div); | |
| }); | |
| this.elements.searchResults.appendChild(fragment); | |
| } | |
| // --- Data Operations --- | |
| migrateTasks() { | |
| const currentWeekId = this.currentWeekId; | |
| let count = 0; | |
| const targetDay = new Date().getDay(); | |
| const allKeys = this.storage.getAllKeys(); | |
| allKeys.forEach(key => { | |
| const wId = key.replace(STORAGE_PREFIX, ''); | |
| // String comparison works correctly for both ISO (2024-W01) and Date (2024-01-01) formats | |
| if (wId < currentWeekId) { | |
| const data = this.storage.getWeekData(wId); | |
| let modified = false; | |
| Object.keys(data).forEach(dKey => { | |
| const incomplete = data[dKey].filter(t => !t.completed); | |
| if (incomplete.length > 0) { | |
| incomplete.forEach(t => { | |
| const newTask = { ...t, id: this.generateUUID() }; | |
| this.cachedWeekData[`day-${targetDay}`].push(newTask); | |
| count++; | |
| }); | |
| data[dKey] = data[dKey].filter(t => t.completed); | |
| modified = true; | |
| } | |
| }); | |
| if (modified) this.storage.saveWeekData(wId, data); | |
| } | |
| }); | |
| if (count > 0) { | |
| this.ui.renderTasks(targetDay, this.cachedWeekData[`day-${targetDay}`]); | |
| this.saveData(); | |
| alert(`${count}개의 할 일을 이월했습니다.`); | |
| } else { | |
| alert("이월할 미완료 할 일이 없습니다."); | |
| } | |
| } | |
| importData(file) { | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| try { | |
| this.storage.import(e.target.result); | |
| location.reload(); | |
| } catch (err) { alert("파일 오류: " + err.message); } | |
| }; | |
| reader.readAsText(file); | |
| } | |
| exportData() { | |
| const data = {}; | |
| this.storage.getAllKeys().forEach(key => { | |
| data[key] = localStorage.getItem(key); | |
| }); | |
| const blob = new Blob([JSON.stringify(data)], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `vintage_planner_backup_${new Date().toISOString().slice(0, 10)}.json`; | |
| a.click(); | |
| } | |
| // Memory Leak Prevention: Store references | |
| bindEvents() { | |
| const el = this.elements; | |
| this.boundHandlers = { | |
| prev: () => { this.currentDate.setDate(this.currentDate.getDate() - 7); this.render(); }, | |
| next: () => { this.currentDate.setDate(this.currentDate.getDate() + 7); this.render(); }, | |
| thisWeek: () => { this.currentDate = new Date(); this.render(); }, | |
| showPicker: () => el.datePicker.showPicker(), | |
| onDatePick: (e) => { | |
| if (e.target.value) { | |
| // Fix: Timezone Issue (Parse explicitly) | |
| const [y, m, d] = e.target.value.split('-').map(Number); | |
| this.currentDate = new Date(y, m - 1, d); | |
| this.render(); | |
| } | |
| }, | |
| export: () => this.exportData(), | |
| importClick: () => el.importFile.click(), | |
| importChange: (e) => { this.importData(e.target.files[0]); e.target.value = ''; }, | |
| reset: () => { | |
| if (confirm("모든 데이터를 삭제하고 샘플 데이터를 로드하시겠습니까?")) { | |
| if (confirm("정말로 초기화하시겠습니까? 이 작업은 되돌릴 수 없으며 모든 데이터가 영구적으로 삭제됩니다.")) { | |
| this.storage.reset(); | |
| location.reload(); | |
| } | |
| } | |
| }, | |
| migrate: () => this.migrateTasks(), | |
| theme: () => { document.body.classList.toggle(CLASSES.DARK_MODE); localStorage.setItem('theme', document.body.classList.contains(CLASSES.DARK_MODE) ? 'dark' : 'light'); }, | |
| searchToggle: () => this.toggleSearch(), | |
| searchInput: this.debounce((e) => this.performSearch(e.target.value), 300) | |
| }; | |
| el.prevBtn.onclick = this.boundHandlers.prev; | |
| el.nextBtn.onclick = this.boundHandlers.next; | |
| el.thisBtn.onclick = this.boundHandlers.thisWeek; | |
| el.calBtn.onclick = this.boundHandlers.showPicker; | |
| el.yearMonth.onclick = this.boundHandlers.showPicker; | |
| el.datePicker.onchange = this.boundHandlers.onDatePick; | |
| el.exportBtn.onclick = this.boundHandlers.export; | |
| el.importBtn.onclick = this.boundHandlers.importClick; | |
| el.importFile.onchange = this.boundHandlers.importChange; | |
| el.resetBtn.onclick = this.boundHandlers.reset; | |
| el.migrateBtn.onclick = this.boundHandlers.migrate; | |
| el.themeBtn.onclick = this.boundHandlers.theme; | |
| // Search | |
| el.searchBtn.onclick = this.boundHandlers.searchToggle; | |
| el.closeSearch.onclick = this.boundHandlers.searchToggle; | |
| el.searchInput.oninput = this.boundHandlers.searchInput; | |
| // Global Delegation | |
| const grid = this.ui.weekGrid; | |
| grid.addEventListener('click', (e) => this.handleGridClick(e)); | |
| grid.addEventListener('keydown', (e) => this.handleKeydown(e)); | |
| // Fix: Event Delegation for Focus & Paste | |
| grid.addEventListener('focusin', (e) => this.handleFocusIn(e)); | |
| grid.addEventListener('focusout', (e) => this.handleFocusOut(e)); | |
| grid.addEventListener('paste', (e) => this.handlePaste(e)); | |
| grid.addEventListener('dragstart', (e) => this.handleDragStart(e)); | |
| grid.addEventListener('dragover', (e) => this.handleDragOver(e)); | |
| grid.addEventListener('drop', (e) => this.handleDrop(e)); | |
| grid.addEventListener('dragend', (e) => this.handleDragEnd(e)); | |
| document.addEventListener('keydown', (e) => { | |
| if ((e.ctrlKey || e.metaKey) && e.key === 'f') { e.preventDefault(); this.toggleSearch(); } | |
| }); | |
| } | |
| } | |
| const app = new VintagePlanner(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment