Skip to content

Instantly share code, notes, and snippets.

@lunamoth
Created November 19, 2025 11:14
Show Gist options
  • Select an option

  • Save lunamoth/91c1e1bc25e219e9214c3fd4be6b1307 to your computer and use it in GitHub Desktop.

Select an option

Save lunamoth/91c1e1bc25e219e9214c3fd4be6b1307 to your computer and use it in GitHub Desktop.
<!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">&lt;</button>
<button id="thisWeekBtn">이번 주</button>
<button id="calendarBtn" class="calendar-btn">달력</button>
<button id="searchBtn">검색</button>
<button id="nextWeekBtn">&gt;</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