From 544d48dabb16f58d5af0eb112f3f74d86022bd53 Mon Sep 17 00:00:00 2001 From: VirtualTests1 Date: Sat, 18 Apr 2026 16:46:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=A4=9A=E8=AF=AD?= =?UTF-8?q?=E8=A8=80=E6=94=AF=E6=8C=81=E5=B9=B6=E6=94=B9=E8=BF=9B=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增中英文双语支持,可通过语言切换按钮更改界面语言 - 重构任务数据结构,支持标题和详细描述 - 改进任务编辑功能,提供更直观的编辑体验 - 优化响应式布局,适配不同屏幕尺寸 - 更新PWA缓存策略,提升离线使用体验 --- public/app.js | 444 +++++++++++++++++++++++++++----------- public/assets/styles.css | 453 ++++++++++++++++++++++++++++++--------- public/index.html | 97 +++++---- public/service-worker.js | 27 ++- server.js | 2 +- 5 files changed, 747 insertions(+), 276 deletions(-) diff --git a/public/app.js b/public/app.js index c1a6890..2174e8c 100644 --- a/public/app.js +++ b/public/app.js @@ -1,24 +1,95 @@ import { ToastManager } from './managers/toast.js'; +// 语言配置 +const translations = { + en: { + pageTitle: 'DumbDo - Stupidly Simple Todo List', + headerTitle: 'DumbDo', + placeholderTitle: 'Title', + placeholderDesc: 'Details (optional)', + addButton: 'Add', + saveButton: 'Save', + cancelButton: 'Cancel', + confirmDelete: 'Are you sure you want to delete this task?', + clearCompletedConfirm: (count) => `Are you sure you want to delete ${count} completed task${count === 1 ? '' : 's'}?`, + taskAdded: 'Task added', + taskUpdated: 'Task updated', + taskDeleted: 'Task deleted', + taskCompleted: 'Task completed! 🎉', + taskUncompleted: 'Task uncompleted', + listRenamed: 'List renamed', + listAdded: 'New list added', + listDeleted: 'List deleted', + clearedCompleted: (count) => `Cleared ${count} completed task${count === 1 ? '' : 's'}`, + noCompletedToClear: 'No completed tasks to clear', + failedToLoad: 'Failed to load todos', + failedToSave: 'Failed to save todos', + completed: 'Completed', + newListName: 'Enter new list name:', + deleteListConfirm: (name) => `Are you sure you want to delete "${name}" and all its tasks?`, + cannotDeleteList: 'Cannot delete this list', + selectList: 'Select todo list', + renameList: 'Rename current list', + addNewList: 'Add new list', + toggleTheme: 'Toggle theme', + toggleLanguage: 'Toggle language' + }, + zh: { + pageTitle: 'DumbDo - 简单的待办列表', + headerTitle: 'DumbDo', + placeholderTitle: '标题', + placeholderDesc: '详情(可选)', + addButton: '添加', + saveButton: '保存', + cancelButton: '取消', + confirmDelete: '确定要删除这个任务吗?', + clearCompletedConfirm: (count) => `确定要删除 ${count} 个已完成的任务吗?`, + taskAdded: '任务已添加', + taskUpdated: '任务已更新', + taskDeleted: '任务已删除', + taskCompleted: '任务已完成!🎉', + taskUncompleted: '任务已取消完成', + listRenamed: '列表已重命名', + listAdded: '新列表已添加', + listDeleted: '列表已删除', + clearedCompleted: (count) => `已清除 ${count} 个已完成的任务`, + noCompletedToClear: '没有已完成的任务需要清除', + failedToLoad: '加载待办失败', + failedToSave: '保存待办失败', + completed: '已完成', + newListName: '输入新列表名称:', + deleteListConfirm: (name) => `确定要删除"${name}"及其所有任务吗?`, + cannotDeleteList: '无法删除此列表', + selectList: '选择待办列表', + renameList: '重命名当前列表', + addNewList: '添加新列表', + toggleTheme: '切换主题', + toggleLanguage: '切换语言' + } +}; document.addEventListener('DOMContentLoaded', () => { // DOM Elements const todoForm = document.getElementById('todoForm'); - const todoInput = document.getElementById('todoInput'); + const todoTitleInput = document.getElementById('todoTitleInput'); + const todoDescInput = document.getElementById('todoDescInput'); const todoList = document.getElementById('todoList'); const themeToggle = document.getElementById('themeToggle'); + const langToggle = document.getElementById('langToggle'); + const langText = document.getElementById('langText'); const moonIcon = themeToggle.querySelector('.moon'); const sunIcon = themeToggle.querySelector('.sun'); + const submitBtn = document.getElementById('submitBtn'); + const cancelEditBtn = document.getElementById('cancelEditBtn'); const toastContainer = document.getElementById('toast-container'); const toastManager = new ToastManager(toastContainer); - const pinModal = document.getElementById('pinModal'); - const pinInputs = [...document.querySelectorAll('.pin-input')]; - const pinError = document.getElementById('pinError'); const clearCompletedBtn = document.getElementById('clearCompleted'); const listSelector = document.getElementById('listSelector'); const renameListBtn = document.getElementById('renameList'); const deleteListBtn = document.getElementById('deleteList'); const addListBtn = document.getElementById('addList'); + const headerTitle = document.getElementById('header-title'); + const pageTitle = document.getElementById('page-title'); const listControls = document.getElementById('listControls'); @@ -67,18 +138,82 @@ document.addEventListener('DOMContentLoaded', () => { // State let todos = {}; let currentList = 'List 1'; + let editingTodo = null; + let currentLang = localStorage.getItem('language') || 'en'; + + // Language Management + function updateLanguage() { + const t = translations[currentLang]; + langText.textContent = currentLang === 'en' ? 'EN' : '中'; + + todoTitleInput.placeholder = t.placeholderTitle; + todoDescInput.placeholder = t.placeholderDesc; + submitBtn.textContent = editingTodo ? t.saveButton : t.addButton; + cancelEditBtn.textContent = t.cancelButton; + + listSelector.setAttribute('aria-label', t.selectList); + renameListBtn.setAttribute('aria-label', t.renameList); + addListBtn.setAttribute('aria-label', t.addNewList); + themeToggle.setAttribute('aria-label', t.toggleTheme); + langToggle.setAttribute('aria-label', t.toggleLanguage); + + renderTodos(); + } + + function toggleLanguage() { + currentLang = currentLang === 'en' ? 'zh' : 'en'; + localStorage.setItem('language', currentLang); + document.documentElement.setAttribute('data-lang', currentLang); + updateLanguage(); + } + + // Auto-resize textarea + function autoResize(textarea) { + textarea.style.height = 'auto'; + textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px'; + } + + // Initialize auto-resize for textareas + function setupAutoResize() { + const textareas = document.querySelectorAll('.auto-resize'); + textareas.forEach(textarea => { + textarea.addEventListener('input', () => autoResize(textarea)); + autoResize(textarea); + }); + } + + // Generate unique ID for todos + function generateId() { + return Date.now().toString(36) + Math.random().toString(36).substr(2); + } + + // Convert old data format to new format + function convertDataFormat(oldData) { + const newData = {}; + Object.entries(oldData).forEach(([listName, listTodos]) => { + newData[listName] = listTodos.map(todo => { + if (todo.id && todo.title !== undefined) { + return todo; + } + return { + id: generateId(), + title: todo.text, + description: '', + completed: todo.completed + }; + }); + }); + return newData; + } // List Management function initializeLists(data) { if (!data || Object.keys(data).length === 0) { - // Only create List 1 when there are no lists at all todos = { 'List 1': [] }; currentList = 'List 1'; } else { - // Convert only numeric keys, preserve custom names const convertedData = {}; Object.entries(data).forEach(([key, value]) => { - // Only convert numeric keys if (/^\d+$/.test(key)) { const newKey = `List ${Object.keys(convertedData).length + 1}`; convertedData[newKey] = value; @@ -87,8 +222,8 @@ document.addEventListener('DOMContentLoaded', () => { } }); - todos = convertedData; - currentList = Object.keys(convertedData)[0]; + todos = convertDataFormat(convertedData); + currentList = Object.keys(todos)[0]; } updateListSelector(); @@ -96,22 +231,20 @@ document.addEventListener('DOMContentLoaded', () => { } function updateListSelector() { - // Sort the list keys to ensure List 1 comes first + const t = translations[currentLang]; const sortedKeys = Object.keys(todos).sort((a, b) => { if (a === 'List 1') return -1; if (b === 'List 1') return 1; return a.localeCompare(b); }); - // Update the native select listSelector.innerHTML = sortedKeys.map(listId => `` ).join(''); - // Create a custom select const customSelect = document.createElement('div'); customSelect.className = 'custom-select'; - customSelect.style.display = 'none'; // Explicitly set initial state + customSelect.style.display = 'none'; sortedKeys.forEach(listId => { const item = document.createElement('div'); @@ -149,7 +282,6 @@ document.addEventListener('DOMContentLoaded', () => { customSelect.appendChild(item); }); - // Replace the existing custom select if any const existingCustomSelect = selectorContainer.querySelector('.custom-select'); if (existingCustomSelect) { const wasVisible = existingCustomSelect.style.display === 'block'; @@ -163,11 +295,13 @@ document.addEventListener('DOMContentLoaded', () => { function switchList(listId) { currentList = listId; - listSelector.value = listId; // Update the native select value + listSelector.value = listId; + clearEditMode(); renderTodos(); } function addNewList() { + const t = translations[currentLang]; const listCount = Object.keys(todos).length + 1; const newListId = `List ${listCount}`; todos[newListId] = []; @@ -175,80 +309,64 @@ document.addEventListener('DOMContentLoaded', () => { updateListSelector(); renderTodos(); saveTodos(); - toastManager.show('New list added'); + toastManager.show(t.listAdded); } async function renameCurrentList() { - const newName = prompt('Enter new list name:', currentList); + const t = translations[currentLang]; + const newName = prompt(t.newListName, currentList); if (newName && newName.trim() && newName !== currentList && !todos[newName]) { const oldName = currentList; - const oldTodos = { ...todos }; // Keep a full backup + const oldTodos = { ...todos }; try { - // Update the data structure todos[newName] = todos[currentList]; delete todos[currentList]; currentList = newName; - // Update UI updateListSelector(); - // Save changes await saveTodos(); - toastManager.show('List renamed'); + toastManager.show(t.listRenamed); } catch (error) { - // Revert all changes on failure todos = oldTodos; currentList = oldName; updateListSelector(); - toastManager.show('Failed to save list name change', 'error', false, 5000); + toastManager.show(t.failedToSave, 'error', false, 5000); } } } async function deleteList(listId) { - // Don't allow deleting the last list or List 1 + const t = translations[currentLang]; if (Object.keys(todos).length <= 1 || listId === 'List 1') { - toastManager.show('Cannot delete this list', 'error'); + toastManager.show(t.cannotDeleteList, 'error'); return; } - if (confirm(`Are you sure you want to delete "${listId}" and all its tasks?`)) { + if (confirm(t.deleteListConfirm(listId))) { const oldTodos = { ...todos }; try { - // Remove the list delete todos[listId]; - // If we're deleting the current list, switch to another one if (listId === currentList) { currentList = Object.keys(todos)[0]; } - // Update UI updateListSelector(); renderTodos(); - // Save changes await saveTodos(); - toastManager.show('List deleted'); + toastManager.show(t.listDeleted); } catch (error) { - // Revert changes on failure todos = oldTodos; updateListSelector(); renderTodos(); - toastManager.show('Failed to delete list', 'error', false, 5000); + toastManager.show(t.failedToSave, 'error', false, 5000); } } } - // Event Listeners for List Management - listSelector.addEventListener('change', (e) => { - switchList(e.target.value); - }); - - renameListBtn.addEventListener('click', renameCurrentList); - addListBtn.addEventListener('click', addNewList); - // Enhanced fetch with auth headers async function fetchWithAuth(url, options = {}) { return fetch(url, options); @@ -261,7 +379,12 @@ document.addEventListener('DOMContentLoaded', () => { sunIcon.style.display = isDark ? 'block' : 'none'; } - // Initialize theme icons + // Load theme from localStorage on page load + const savedTheme = localStorage.getItem('theme'); + if (savedTheme) { + document.documentElement.setAttribute('data-theme', savedTheme); + } + updateThemeIcons(); themeToggle.addEventListener('click', () => { @@ -272,6 +395,54 @@ document.addEventListener('DOMContentLoaded', () => { updateThemeIcons(); }); + // Language Toggle + langToggle.addEventListener('click', toggleLanguage); + + // Edit Mode + function setEditMode(todo) { + const t = translations[currentLang]; + editingTodo = todo; + todoTitleInput.value = todo.title; + todoDescInput.value = todo.description; + submitBtn.textContent = t.saveButton; + cancelEditBtn.style.display = 'inline-block'; + autoResize(todoTitleInput); + autoResize(todoDescInput); + todoTitleInput.focus(); + + todoList.querySelectorAll('.todo-item').forEach(item => { + if (item.dataset.id === todo.id) { + item.classList.add('editing'); + } else { + item.classList.remove('editing'); + } + }); + } + + function clearEditMode() { + const t = translations[currentLang]; + editingTodo = null; + todoTitleInput.value = ''; + todoDescInput.value = ''; + submitBtn.textContent = t.addButton; + cancelEditBtn.style.display = 'none'; + autoResize(todoTitleInput); + autoResize(todoDescInput); + + todoList.querySelectorAll('.todo-item').forEach(item => { + item.classList.remove('editing'); + }); + } + + cancelEditBtn.addEventListener('click', clearEditMode); + + // Truncate text for preview + function truncateText(text, maxLength = 50) { + if (!text) return ''; + if (text.length <= maxLength) return text; + return text.substring(0, maxLength) + '...'; + } + // Todo Management async function loadTodos() { try { @@ -279,9 +450,10 @@ document.addEventListener('DOMContentLoaded', () => { if (!response.ok) throw new Error('Failed to load todos'); const data = await response.json(); initializeLists(data); - initializeDropdown(); // Initialize dropdown after data is loaded + initializeDropdown(); } catch (error) { - toastManager.show('Failed to load todos', 'error', true); + const t = translations[currentLang]; + toastManager.show(t.failedToLoad, 'error', true); console.error(error); } } @@ -298,108 +470,98 @@ document.addEventListener('DOMContentLoaded', () => { if (!response.ok) throw new Error('Failed to save todos'); return true; } catch (error) { - toastManager.show('Failed to save todos', 'error'); + const t = translations[currentLang]; + toastManager.show(t.failedToSave, 'error'); console.error(error); - throw error; // Re-throw to handle in calling function + throw error; } } function createTodoElement(todo) { + const t = translations[currentLang]; const li = document.createElement('li'); - li.className = `todo-item ${todo.completed ? 'completed' : ''}`; + li.className = `todo-item ${todo.completed ? 'completed' : ''} ${editingTodo && editingTodo.id === todo.id ? 'editing' : ''}`; + li.dataset.id = todo.id; - // Add drag attributes only for non-completed items if (!todo.completed) { li.draggable = true; - li.setAttribute('data-todo-id', todo.text); // Using text as a simple identifier + li.setAttribute('data-todo-id', todo.id); } + const titlePreview = truncateText(todo.title, 40); + const descPreview = truncateText(todo.description, 60); + li.innerHTML = `
- ${linkifyText(todo.text)} +
+ ${linkifyText(titlePreview)} + ${descPreview ? `${linkifyText(descPreview)}` : ''} +
`; const checkbox = li.querySelector('input'); const checkboxWrapper = li.querySelector('.checkbox-wrapper'); - const todoText = li.querySelector('.todo-text'); + const todoContent = li.querySelector('.todo-content'); + const deleteBtn = li.querySelector('.delete-btn'); - // Add click handler to the wrapper checkboxWrapper.addEventListener('click', (e) => { - // Only trigger if clicking the wrapper (not the checkbox directly) + e.stopPropagation(); if (e.target === checkboxWrapper) { checkbox.checked = !checkbox.checked; todo.completed = checkbox.checked; renderTodos(); saveTodos(); - toastManager.show(todo.completed ? 'Task completed! 🎉' : 'Task uncompleted'); + toastManager.show(todo.completed ? t.taskCompleted : t.taskUncompleted); } }); - checkbox.addEventListener('change', () => { + checkbox.addEventListener('change', (e) => { + e.stopPropagation(); todo.completed = checkbox.checked; renderTodos(); saveTodos(); - toastManager.show(todo.completed ? 'Task completed! 🎉' : 'Task uncompleted'); + toastManager.show(todo.completed ? t.taskCompleted : t.taskUncompleted); }); - // Make text editable on click - todoText.addEventListener('click', (e) => { - // Don't trigger edit if clicking a link + // Click to edit - click on todo content to edit + todoContent.addEventListener('click', (e) => { + e.stopPropagation(); if (e.target.tagName === 'A') return; - - const input = document.createElement('input'); - input.type = 'text'; - input.value = todo.text; - input.className = 'edit-input'; - - const originalText = todoText.innerHTML; - todoText.replaceWith(input); - input.focus(); - - function saveEdit() { - const newText = input.value.trim(); - if (newText && newText !== todo.text) { - todo.text = newText; - renderTodos(); - saveTodos(); - toastManager.show('Task updated'); - } else { - input.replaceWith(todoText); - todoText.innerHTML = originalText; - } - } - - input.addEventListener('blur', saveEdit); - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - saveEdit(); - } else if (e.key === 'Escape') { - input.replaceWith(todoText); - todoText.innerHTML = originalText; - } - }); + setEditMode(todo); }); - const deleteBtn = li.querySelector('.delete-btn'); - deleteBtn.addEventListener('click', () => { - if (confirm(`Are you sure you want to delete "${todo.text}"?`)) { + // Also bind click to li as fallback + li.addEventListener('click', (e) => { + if (e.target.tagName === 'A') return; + if (e.target.tagName === 'INPUT') return; + if (e.target.tagName === 'BUTTON') return; + if (checkboxWrapper.contains(e.target)) return; + if (deleteBtn.contains(e.target)) return; + setEditMode(todo); + }); + + deleteBtn.addEventListener('click', (e) => { + e.stopPropagation(); + if (confirm(t.confirmDelete)) { li.remove(); - todos[currentList] = todos[currentList].filter(t => t !== todo); + todos[currentList] = todos[currentList].filter(t => t.id !== todo.id); + if (editingTodo && editingTodo.id === todo.id) { + clearEditMode(); + } + renderTodos(); saveTodos(); - toastManager.show('Task deleted', 'error'); + toastManager.show(t.taskDeleted, 'error'); } }); - // Add drag and drop event listeners for non-completed items + // Drag and drop if (!todo.completed) { li.addEventListener('dragstart', (e) => { - e.dataTransfer.setData('text/plain', todo.text); + e.dataTransfer.setData('text/plain', todo.id); li.classList.add('dragging'); - // Set a custom drag image (optional) const dragImage = li.cloneNode(true); dragImage.style.position = 'absolute'; dragImage.style.top = '-1000px'; @@ -431,11 +593,10 @@ document.addEventListener('DOMContentLoaded', () => { li.parentNode.insertBefore(draggingItem, li.nextSibling); } - // Update the todos array to match the new order const activeTodos = todos[currentList].filter(t => !t.completed); const completedTodos = todos[currentList].filter(t => t.completed); const newOrder = [...document.querySelectorAll('.todo-item:not(.completed)')].map(item => { - return activeTodos.find(t => t.text === item.getAttribute('data-todo-id')); + return activeTodos.find(t => t.id === item.getAttribute('data-todo-id')); }); todos[currentList] = [...newOrder, ...completedTodos]; saveTodos(); @@ -447,25 +608,21 @@ document.addEventListener('DOMContentLoaded', () => { return li; } - // Helper function to convert URLs in text to clickable links function linkifyText(text) { - // Updated regex that doesn't include trailing punctuation in the URL const urlRegex = /(https?:\/\/[^\s)]+)([)\s]|$)/g; return text.replace(urlRegex, (match, url, endChar) => { - // Return the URL as a link plus any trailing character return `${url}${endChar}`; }); } function renderTodos() { + const t = translations[currentLang]; todoList.innerHTML = ''; const currentTodos = todos[currentList] || []; - // Separate todos into active and completed const activeTodos = currentTodos.filter(todo => !todo.completed); const completedTodos = currentTodos.filter(todo => todo.completed); - // Create a container for active todos const activeTodosContainer = document.createElement('div'); activeTodosContainer.className = 'active-todos'; activeTodosContainer.addEventListener('dragover', (e) => { @@ -480,20 +637,17 @@ document.addEventListener('DOMContentLoaded', () => { }); todoList.appendChild(activeTodosContainer); - // Render active todos activeTodos.forEach(todo => { activeTodosContainer.appendChild(createTodoElement(todo)); }); - // Add divider if there are both active and completed todos if (activeTodos.length > 0 && completedTodos.length > 0) { const divider = document.createElement('li'); divider.className = 'todo-divider'; - divider.textContent = 'Completed'; + divider.textContent = t.completed; todoList.appendChild(divider); } - // Render completed todos completedTodos.forEach(todo => { todoList.appendChild(createTodoElement(todo)); }); @@ -502,37 +656,74 @@ document.addEventListener('DOMContentLoaded', () => { // Event Listeners todoForm.addEventListener('submit', (e) => { e.preventDefault(); - const text = todoInput.value.trim(); + const t = translations[currentLang]; + const title = todoTitleInput.value.trim(); + const description = todoDescInput.value.trim(); - if (text) { - const todo = { text, completed: false }; - todos[currentList].push(todo); - renderTodos(); - saveTodos(); - todoInput.value = ''; - toastManager.show('Task added'); + if (title) { + if (editingTodo) { + const todo = todos[currentList].find(t => t.id === editingTodo.id); + if (todo) { + todo.title = title; + todo.description = description; + renderTodos(); + saveTodos(); + toastManager.show(t.taskUpdated); + } + clearEditMode(); + } else { + const todo = { + id: generateId(), + title, + description, + completed: false + }; + todos[currentList].push(todo); + renderTodos(); + saveTodos(); + todoTitleInput.value = ''; + todoDescInput.value = ''; + autoResize(todoTitleInput); + autoResize(todoDescInput); + toastManager.show(t.taskAdded); + } } }); + listSelector.addEventListener('change', (e) => { + switchList(e.target.value); + }); + + renameListBtn.addEventListener('click', renameCurrentList); + addListBtn.addEventListener('click', addNewList); + // Clear completed tasks clearCompletedBtn.addEventListener('click', () => { + const t = translations[currentLang]; const currentTodos = todos[currentList]; const completedCount = currentTodos.filter(todo => todo.completed).length; if (completedCount === 0) { - toastManager.show('No completed tasks to clear'); + toastManager.show(t.noCompletedToClear); return; } - if (confirm(`Are you sure you want to delete ${completedCount} completed task${completedCount === 1 ? '' : 's'}?`)) { + if (confirm(t.clearCompletedConfirm(completedCount))) { todos[currentList] = currentTodos.filter(todo => !todo.completed); renderTodos(); saveTodos(); - toastManager.show(`Cleared ${completedCount} completed task${completedCount === 1 ? '' : 's'}`); + toastManager.show(t.clearedCompleted(completedCount)); } }); const initialize = async () => { + // Initialize language + document.documentElement.setAttribute('data-lang', currentLang); + updateLanguage(); + + // Setup auto-resize + setupAutoResize(); + fetch(`api/config`) .then(resp => resp.json()) .then(config => { @@ -540,8 +731,8 @@ document.addEventListener('DOMContentLoaded', () => { throw new Error(config.error); } - document.getElementById('page-title').textContent = config.siteTitle; - document.getElementById('header-title').textContent = config.siteTitle; + pageTitle.textContent = config.siteTitle; + headerTitle.textContent = config.siteTitle; if (listControls) listControls.style.display = config.singleList ? 'none' : ''; loadTodos(); @@ -549,9 +740,8 @@ document.addEventListener('DOMContentLoaded', () => { .catch(err => { console.error('Error loading site config:', err); toastManager.show(err, 'error', true); - }) + }); - // Register PWA Service Worker if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/service-worker.js") .then((reg) => console.log("Service Worker registered:", reg.scope)) diff --git a/public/assets/styles.css b/public/assets/styles.css index 99b2b46..256f72f 100644 --- a/public/assets/styles.css +++ b/public/assets/styles.css @@ -37,10 +37,9 @@ body { } .app { - max-width: 600px; + max-width: 1400px; margin: 0 auto; padding: 2rem 1rem; - text-align: center; } header { @@ -48,14 +47,19 @@ header { text-align: center; margin-bottom: 2rem; display: flex; - flex-direction: column; + align-items: center; + justify-content: space-between; gap: 1rem; + flex-wrap: wrap; } h1 { margin: 0; font-size: 2rem; color: var(--text); + flex: 1; + text-align: left; + min-width: 150px; } button { @@ -72,10 +76,36 @@ button:hover { background: var(--primary-hover); } +.header-controls { + display: flex; + gap: 0.5rem; + align-items: center; +} + +#langToggle { + background: transparent; + font-size: 0.875rem; + padding: 0.5rem; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid var(--text); + border-radius: 50%; + font-weight: 600; + transition: all var(--transition); +} + +#langToggle:hover { + background: rgba(255,255,255,0.1); +} + +#langToggle #langText { + color: var(--text); +} + #themeToggle { - position: absolute; - top: 0; - right: 0; background: transparent; font-size: 1.5rem; padding: 0.5rem; @@ -127,27 +157,120 @@ button:hover { background: rgba(255,68,68,0.1); } +/* Main Container - Responsive Two Column Layout */ +.main-container { + display: grid; + grid-template-columns: 1fr; + gap: 2rem; +} + +@media (min-width: 1024px) { + .main-container { + grid-template-columns: 380px 1fr; + gap: 2rem; + } +} + +@media (min-width: 1200px) { + .main-container { + grid-template-columns: 420px 1fr; + } +} + +/* Left Panel (Function Area) */ +.left-panel { + display: flex; + flex-direction: column; + gap: 1rem; +} + .todo-form { display: flex; - gap: 0.5rem; - margin-bottom: 2rem; - justify-content: center; + flex-direction: column; + gap: 1rem; + background: var(--container); + padding: 1.5rem; + border-radius: 12px; + box-shadow: var(--shadow); } -input { - flex: 1; - max-width: 400px; +.input-container { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +/* Auto-resize Textarea */ +.auto-resize { + width: 100%; padding: 0.75rem; border: 1px solid var(--border); border-radius: 8px; - background: var(--container); + background: var(--background); color: var(--text); - transition: border-color var(--transition); + font-size: 1rem; + font-family: inherit; + resize: none; + overflow: hidden; + min-height: 40px; + max-height: 300px; + transition: border-color var(--transition), box-shadow var(--transition); + line-height: 1.5; } -input:focus { +.auto-resize:focus { outline: none; border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.1); +} + +.auto-resize::placeholder { + color: var(--border); + opacity: 0.8; +} + +#todoTitleInput { + font-weight: 600; + font-size: 1.1rem; +} + +#todoDescInput { + font-size: 0.95rem; + opacity: 0.9; +} + +.todo-form .button-group { + justify-content: flex-start; + flex-wrap: wrap; +} + +.todo-form button[type="submit"], +#cancelEditBtn { + padding: 0.75rem 1.5rem; + font-size: 1rem; + border-radius: 8px; + min-width: 80px; +} + +#cancelEditBtn { + background: transparent; + border: 1px solid var(--border); + color: var(--text); +} + +#cancelEditBtn:hover { + background: rgba(0,0,0,0.05); + border-color: var(--text); +} + +[data-theme="dark"] #cancelEditBtn:hover { + background: rgba(255,255,255,0.1); +} + +/* Right Panel (Todo List) */ +.right-panel { + display: flex; + flex-direction: column; } .todo-list { @@ -155,24 +278,32 @@ input:focus { display: flex; flex-direction: column; gap: 0.5rem; - align-items: center; width: 100%; } .todo-item { display: grid; grid-template-columns: 30px 1fr auto; - align-items: center; + align-items: flex-start; gap: 0.75rem; padding: 1rem; background: var(--container); border-radius: 8px; box-shadow: var(--shadow); - transition: transform var(--transition), box-shadow var(--transition); + transition: transform var(--transition), box-shadow var(--transition), border-color var(--transition); width: 100%; - max-width: 500px; cursor: default; position: relative; + border: 2px solid transparent; +} + +.todo-item:hover { + transform: translateY(-2px); +} + +.todo-item.editing { + border-color: var(--primary); + background: rgba(33, 150, 243, 0.05); } .todo-item:not(.completed) { @@ -186,13 +317,16 @@ input:focus { transform: scale(1.02) translateY(-2px); } -.todo-item:not(.completed):hover { - transform: translateY(-2px); +.todo-item.completed .todo-content { + opacity: 0.7; } -.todo-item.completed span { +.todo-item.completed .todo-title { + text-decoration: line-through; +} + +.todo-item.completed .todo-description { text-decoration: line-through; - opacity: 0.7; } .checkbox-wrapper { @@ -200,11 +334,11 @@ input:focus { width: 100%; height: 100%; display: flex; - align-items: center; + align-items: flex-start; justify-content: center; cursor: pointer; - padding: 0.5rem; - margin: -0.5rem; + padding: 0.25rem; + margin-top: 0.25rem; } .todo-item input[type="checkbox"] { @@ -217,36 +351,45 @@ input:focus { z-index: 1; } -.todo-item span { - overflow-wrap: break-word; - word-break: break-word; +.todo-content { + display: flex; + flex-direction: column; + gap: 0.25rem; min-width: 0; - text-align: left; - justify-self: start; cursor: pointer; padding: 0.25rem; border-radius: 4px; transition: background-color var(--transition); } -.todo-item span:hover { - background-color: rgba(0, 0, 0, 0.05); +.todo-content:hover { + background-color: rgba(0, 0, 0, 0.03); } -[data-theme="dark"] .todo-item span:hover { - background-color: rgba(255, 255, 255, 0.05); +[data-theme="dark"] .todo-content:hover { + background-color: rgba(255, 255, 255, 0.03); } -.todo-item .edit-input { - width: 100%; - padding: 0.25rem; - margin: 0; - border: 1px solid var(--primary); - border-radius: 4px; - background: var(--container); +.todo-title { + font-weight: 500; + font-size: 1rem; color: var(--text); - font-size: inherit; - font-family: inherit; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; + line-height: 1.4; +} + +.todo-description { + font-size: 0.875rem; + color: var(--text); + opacity: 0.7; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; + line-height: 1.4; } .todo-item a { @@ -272,6 +415,8 @@ input:focus { padding: 0.25rem 0.5rem; border: 1px solid #ff4444; justify-self: end; + align-self: flex-start; + margin-top: 0.25rem; } .delete-btn:hover { @@ -332,7 +477,6 @@ input:focus { .todo-divider { width: 100%; - max-width: 500px; text-align: left; padding: 1rem 0; margin: 1rem 0; @@ -342,30 +486,6 @@ input:focus { border-bottom: 1px solid var(--border); } -@media (max-width: 480px) { - .app { - padding: 1rem; - } - - h1 { - font-size: 1.5rem; - } - - .todo-form { - flex-direction: column; - align-items: center; - } - - .button-group { - width: 100%; - max-width: 400px; - } - - .button-group button[type="submit"] { - flex: 1; - } -} - /* PIN Modal Styles */ .modal { position: fixed; @@ -465,7 +585,7 @@ input:focus { gap: 0.5rem; align-items: center; justify-content: flex-start; - width: 100%; + flex: 1; padding: 0 1rem; } @@ -624,24 +744,6 @@ input:focus { display: none; } -@media (max-width: 600px) { - .list-controls { - position: static; - margin-bottom: 1rem; - justify-content: center; - } - - header { - flex-direction: column; - gap: 1rem; - } - - #themeToggle { - position: static; - margin-left: auto; - } -} - /* Login Page Styles */ .login-page { min-height: 100vh; @@ -681,20 +783,6 @@ input:focus { margin-bottom: 0.5rem; } -@media (max-width: 480px) { - .login-container { - padding: 1rem; - } - - .login-box { - padding: 1.5rem; - } - - .login-page h1 { - font-size: 2rem; - } -} - /* PIN Status Styles */ .pin-status { margin-top: 1rem; @@ -745,7 +833,6 @@ input:focus { flex-direction: column; gap: 0.5rem; width: 100%; - align-items: center; } /* Add a visual indicator for drop target */ @@ -770,10 +857,164 @@ input:focus { opacity: 1; } -/* Add touch-friendly tap target on mobile */ -@media (max-width: 768px) { +/* Responsive Design */ +@media (max-width: 1023px) { + .app { + padding: 1rem; + } + + header { + flex-direction: row; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 1.5rem; + } + + h1 { + order: 1; + text-align: left; + font-size: 1.5rem; + flex: 1; + min-width: auto; + } + + .list-controls { + order: 3; + width: 100%; + justify-content: flex-start; + padding: 0; + } + + .header-controls { + order: 2; + } + + .main-container { + gap: 1.5rem; + } + + .todo-form { + padding: 1rem; + } + + .todo-form .button-group { + justify-content: stretch; + } + + .todo-form button[type="submit"], + #cancelEditBtn { + flex: 1; + min-width: auto; + } + + .todo-item { + padding: 0.75rem; + gap: 0.5rem; + } + + .todo-title { + font-size: 0.95rem; + } + + .todo-description { + font-size: 0.8rem; + } +} + +@media (max-width: 600px) { + .list-controls { + position: static; + margin-bottom: 0.5rem; + justify-content: flex-start; + } + + header { + flex-direction: row; + gap: 0.5rem; + align-items: center; + } + + #themeToggle, + #langToggle { + position: static; + margin-left: 0; + } + + h1 { + font-size: 1.25rem; + } + + .todo-form { + flex-direction: column; + } + + .button-group { + width: 100%; + } + + .button-group button[type="submit"] { + flex: 1; + } + + /* Add touch-friendly tap target on mobile */ .checkbox-wrapper { padding: 0.75rem; margin: -0.75rem; } -} \ No newline at end of file +} + +@media (max-width: 480px) { + .login-container { + padding: 1rem; + } + + .login-box { + padding: 1.5rem; + } + + .login-page h1 { + font-size: 2rem; + } +} + +/* Two column specific styles */ +@media (min-width: 1024px) { + .left-panel { + position: sticky; + top: 2rem; + align-self: flex-start; + } + + .todo-form { + padding: 1.5rem; + } + + .right-panel { + min-height: 60vh; + } + + .todo-list { + max-height: 75vh; + overflow-y: auto; + padding-right: 0.5rem; + } + + /* Custom scrollbar for todo list */ + .todo-list::-webkit-scrollbar { + width: 6px; + } + + .todo-list::-webkit-scrollbar-track { + background: transparent; + } + + .todo-list::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; + } + + .todo-list::-webkit-scrollbar-thumb:hover { + background: var(--text); + opacity: 0.5; + } +} diff --git a/public/index.html b/public/index.html index 7b6cfa0..7c110f4 100644 --- a/public/index.html +++ b/public/index.html @@ -40,50 +40,71 @@

DumbDo

- +
+ + +
-
-
- -
- - -
-
- - +
+
+
+
+ + +
+
+ + + +
+
+
+ +
+
    + +
+
- + \ No newline at end of file diff --git a/public/service-worker.js b/public/service-worker.js index 3b573dd..d8aef48 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_NAME = "DUMBDO_PWA_CACHE_V1"; +const CACHE_NAME = "DUMBDO_PWA_CACHE_V2"; const ASSETS_TO_CACHE = []; const preload = async () => { @@ -20,13 +20,32 @@ globalThis.addEventListener("install", (event) => { }); globalThis.addEventListener("activate", (event) => { - event.waitUntil(clients.claim()); + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => { + if (cacheName !== CACHE_NAME) { + console.log("Deleting old cache:", cacheName); + return caches.delete(cacheName); + } + }) + ); + }).then(() => { + return clients.claim(); + }) + ); }); globalThis.addEventListener("fetch", (event) => { event.respondWith( - caches.match(event.request).then((cachedResponse) => { - return cachedResponse || fetch(event.request); + fetch(event.request).then((response) => { + const responseClone = response.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(event.request, responseClone); + }); + return response; + }).catch(() => { + return caches.match(event.request); }) ); }); \ No newline at end of file diff --git a/server.js b/server.js index 7affb0d..3f81cbf 100644 --- a/server.js +++ b/server.js @@ -11,7 +11,7 @@ const { convertLogoToPng } = require('./scripts/convert-logo'); const { generatePWAManifest } = require('./scripts/pwa-manifest-generator'); // Environment variables -const PORT = process.env.PORT || 3000; +const PORT = process.env.PORT || 3001; const PIN = process.env.DUMBDO_PIN; const SITE_TITLE = process.env.DUMBDO_SITE_TITLE || 'DumbDo'; const SINGLE_LIST = process.env.SINGLE_LIST === 'true' || false;