diff --git a/public/app.js b/public/app.js index 8bb7f2a..5ee2e41 100644 --- a/public/app.js +++ b/public/app.js @@ -50,6 +50,7 @@ class App { async init() { this.setupGreeting(); this.setupEventListeners(); + this.setupContainerDrag(); await this.loadTasks(); await this.loadArchivedCount(); } @@ -257,6 +258,30 @@ class App { hideContextMenu() { document.getElementById('context-menu').classList.remove('show'); } + // ─── Confirm Dialog ─── + + showConfirm(message, icon = '🗑') { + return new Promise((resolve) => { + const overlay = document.getElementById('confirm-overlay'); + document.getElementById('confirm-message').innerHTML = message; + document.getElementById('confirm-icon').innerHTML = icon; + overlay.classList.add('open'); + + const cancel = document.getElementById('confirm-cancel'); + const del = document.getElementById('confirm-delete'); + + const cleanup = () => { + overlay.classList.remove('open'); + cancel.replaceWith(cancel.cloneNode(true)); + del.replaceWith(del.cloneNode(true)); + }; + + document.getElementById('confirm-cancel').addEventListener('click', () => { cleanup(); resolve(false); }); + document.getElementById('confirm-delete').addEventListener('click', () => { cleanup(); resolve(true); }); + overlay.addEventListener('click', (e) => { if (e.target === overlay) { cleanup(); resolve(false); } }, { once: true }); + }); + } + handleContextEdit() { if (this.contextMenuTarget) this.openEditModal(this.contextMenuTarget, this.contextMenuParent); this.hideContextMenu(); @@ -264,10 +289,14 @@ class App { async handleContextDelete() { if (!this.contextMenuTarget) return; - if (!confirm(`Delete this ${this.contextMenuType}?`)) { this.hideContextMenu(); return; } - if (this.contextMenuType === 'task') await this.deleteTask(this.contextMenuTarget.id); - else if (this.contextMenuType === 'subtask' && this.contextMenuParent) await this.deleteSubtask(this.contextMenuParent.id, this.contextMenuTarget.id); + const type = this.contextMenuType; this.hideContextMenu(); + const confirmed = await this.showConfirm( + `Delete this ${type}?
This action cannot be undone.` + ); + if (!confirmed) return; + if (type === 'task') this.deleteTask(this.contextMenuTarget.id); + else if (type === 'subtask' && this.contextMenuParent) this.deleteSubtask(this.contextMenuParent.id, this.contextMenuTarget.id); } async handleContextPromote() { @@ -497,6 +526,13 @@ class App { } } + async confirmDeleteTask(taskId) { + const confirmed = await this.showConfirm( + 'Delete permanently?
This action cannot be undone.' + ); + if (confirmed) this.deleteTask(taskId); + } + toggleTaskComplete(taskId) { const task = this.getCurrentTasks().find(t => t.id === taskId); if (!task) return; @@ -589,26 +625,169 @@ class App { } } - // ─── Drag & Drop ─── + // ─── Drag & Drop (insertion indicator system) ─── + + _createDropIndicator() { + const el = document.createElement('div'); + el.className = 'drop-indicator'; + el.style.display = 'none'; + document.body.appendChild(el); + return el; + } + + _showDropIndicator(container, y) { + if (!this._dropIndicator) this._dropIndicator = this._createDropIndicator(); + const cards = [...container.querySelectorAll(':scope > .task-card')]; + let insertIndex = cards.length; + let indicatorY = 0; + + for (let i = 0; i < cards.length; i++) { + const rect = cards[i].getBoundingClientRect(); + const midY = rect.top + rect.height / 2; + if (y < midY) { + insertIndex = i; + indicatorY = rect.top - 4; + break; + } + } + + // If after all cards + if (insertIndex === cards.length && cards.length > 0) { + const lastRect = cards[cards.length - 1].getBoundingClientRect(); + indicatorY = lastRect.bottom + 4; + } else if (cards.length === 0) { + const containerRect = container.getBoundingClientRect(); + indicatorY = containerRect.top + 8; + } + + const containerRect = container.getBoundingClientRect(); + this._dropIndicator.style.display = 'block'; + this._dropIndicator.style.top = indicatorY + 'px'; + this._dropIndicator.style.left = containerRect.left + 'px'; + this._dropIndicator.style.width = containerRect.width + 'px'; + this._dropInsertIndex = insertIndex; + } + + _hideDropIndicator() { + if (this._dropIndicator) this._dropIndicator.style.display = 'none'; + this._dropInsertIndex = null; + } + + _showSubtaskDropIndicator(subtaskContainer, y) { + if (!this._subtaskDropIndicator) { + this._subtaskDropIndicator = document.createElement('div'); + this._subtaskDropIndicator.className = 'drop-indicator drop-indicator-subtask'; + this._subtaskDropIndicator.style.display = 'none'; + document.body.appendChild(this._subtaskDropIndicator); + } + const subtasks = [...subtaskContainer.querySelectorAll(':scope > .subtask')]; + let insertIndex = subtasks.length; + let indicatorY = 0; + + for (let i = 0; i < subtasks.length; i++) { + const rect = subtasks[i].getBoundingClientRect(); + if (y < rect.top + rect.height / 2) { + insertIndex = i; + indicatorY = rect.top - 2; + break; + } + } + + if (insertIndex === subtasks.length && subtasks.length > 0) { + const lastRect = subtasks[subtasks.length - 1].getBoundingClientRect(); + indicatorY = lastRect.bottom + 2; + } + + const cRect = subtaskContainer.getBoundingClientRect(); + this._subtaskDropIndicator.style.display = 'block'; + this._subtaskDropIndicator.style.top = indicatorY + 'px'; + this._subtaskDropIndicator.style.left = cRect.left + 'px'; + this._subtaskDropIndicator.style.width = cRect.width + 'px'; + this._subtaskDropInsertIndex = insertIndex; + } + + _hideSubtaskDropIndicator() { + if (this._subtaskDropIndicator) this._subtaskDropIndicator.style.display = 'none'; + this._subtaskDropInsertIndex = null; + } + + setupContainerDrag() { + const container = document.getElementById('tasks-container'); + + container.addEventListener('dragover', (e) => { + e.preventDefault(); + if (!this.draggedTask) return; + this._showDropIndicator(container, e.clientY); + }); + + container.addEventListener('dragleave', (e) => { + // Only hide if leaving the container entirely + if (!container.contains(e.relatedTarget)) { + this._hideDropIndicator(); + } + }); + + container.addEventListener('drop', (e) => { + e.preventDefault(); + + // Handle nesting via drop zone + const dropZone = e.target.closest('.drop-zone'); + if (dropZone) { + const taskCard = dropZone.closest('.task-card'); + if (taskCard) { + const taskId = taskCard.getAttribute('data-task-id'); + const targetTask = this.tasks.find(t => t.id === taskId); + if (targetTask) this._handleNestDrop(targetTask); + } + this._hideDropIndicator(); + return; + } + + // Handle reorder via indicator + if (this.draggedTask && this._dropInsertIndex != null) { + const currentIndex = this.tasks.findIndex(t => t.id === this.draggedTask.id); + let targetIndex = this._dropInsertIndex; + + // Adjust index since we're removing before inserting + if (currentIndex !== -1 && currentIndex < targetIndex) targetIndex--; + if (currentIndex !== -1 && currentIndex !== targetIndex) { + this.tasks.splice(currentIndex, 1); + this.tasks.splice(targetIndex, 0, this.draggedTask); + this._syncReorder(); + this.render(); + } + } + + this._hideDropIndicator(); + }); + } handleDragStart(e, item, isSubtask = false, parentTask = null, index = null) { - if (isSubtask) { this.draggedSubtask = item; this.draggedParent = parentTask; this.draggedSubtaskIndex = index; this.draggedTask = null; } - else { this.draggedTask = item; this.draggedSubtask = null; this.draggedParent = null; this.draggedSubtaskIndex = null; } + if (isSubtask) { + this.draggedSubtask = item; this.draggedParent = parentTask; + this.draggedSubtaskIndex = index; this.draggedTask = null; + } else { + this.draggedTask = item; this.draggedSubtask = null; + this.draggedParent = null; this.draggedSubtaskIndex = null; + } e.currentTarget.classList.add('dragging'); + // Delay to let browser capture drag image + requestAnimationFrame(() => e.currentTarget.style.opacity = '0.4'); } handleDragEnd(e) { e.currentTarget.classList.remove('dragging'); + e.currentTarget.style.opacity = ''; document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over')); document.querySelectorAll('.drop-zone').forEach(el => el.classList.remove('visible')); + this._hideDropIndicator(); + this._hideSubtaskDropIndicator(); if (this.dropZoneTimer) { clearTimeout(this.dropZoneTimer); this.dropZoneTimer = null; } - this.draggedTask = null; this.draggedSubtask = null; this.draggedParent = null; this.draggedSubtaskIndex = null; + this.draggedTask = null; this.draggedSubtask = null; + this.draggedParent = null; this.draggedSubtaskIndex = null; } - handleCardDragOver(e) { e.preventDefault(); e.stopPropagation(); e.currentTarget.classList.add('drag-over'); } - handleDragLeave(e) { e.currentTarget.classList.remove('drag-over'); } - - handleDragEnter(e, taskId) { + handleDragEnterCard(e, taskId) { e.preventDefault(); if (!this.draggedTask && !this.draggedSubtask) return; if (this.dropZoneTimer) clearTimeout(this.dropZoneTimer); @@ -618,8 +797,7 @@ class App { }, 1000); } - async handleDrop(e, targetTask) { - e.preventDefault(); e.stopPropagation(); e.currentTarget.classList.remove('drag-over'); + _handleNestDrop(targetTask) { if (this.draggedTask && this.draggedTask.id !== targetTask.id) { targetTask.subtasks.push(this.draggedTask); this.tasks = this.tasks.filter(t => t.id !== this.draggedTask.id); @@ -637,58 +815,34 @@ class App { } } - async handleTaskReorder(e, targetIndex) { - e.preventDefault(); e.stopPropagation(); e.currentTarget.classList.remove('drag-over'); - if (!this.draggedTask) return; - const currentIndex = this.tasks.findIndex(t => t.id === this.draggedTask.id); - if (currentIndex === -1 || currentIndex === targetIndex) return; - - // Update data - this.tasks.splice(currentIndex, 1); - this.tasks.splice(targetIndex, 0, this.draggedTask); - - // Swap DOM nodes directly - const container = document.getElementById('tasks-container'); - const cards = [...container.children]; - const draggedCard = cards[currentIndex]; - if (draggedCard) { - draggedCard.remove(); - const referenceCard = container.children[targetIndex]; - if (referenceCard) container.insertBefore(draggedCard, referenceCard); - else container.appendChild(draggedCard); - } - - // Sync to server in background - this._syncReorder(); + handleSubtaskContainerDragOver(e, task) { + e.preventDefault(); + e.stopPropagation(); + if (!this.draggedSubtask || !this.draggedParent || this.draggedParent.id !== task.id) return; + const subtaskContainer = e.currentTarget; + this._showSubtaskDropIndicator(subtaskContainer, e.clientY); } - async handleSubtaskReorder(e, parentTask, targetIndex) { - e.preventDefault(); e.stopPropagation(); e.currentTarget.classList.remove('drag-over'); - if (!this.draggedSubtask || !this.draggedParent || this.draggedParent.id !== parentTask.id) return; - if (this.draggedSubtaskIndex === targetIndex) return; - - // Update data - const subtask = parentTask.subtasks[this.draggedSubtaskIndex]; - parentTask.subtasks.splice(this.draggedSubtaskIndex, 1); - parentTask.subtasks.splice(targetIndex, 0, subtask); - - // Swap subtask DOM nodes - const card = document.querySelector(`[data-task-id="${parentTask.id}"]`); - if (card) { - const subtaskContainer = card.querySelector('.subtasks-container'); - if (subtaskContainer) { - const subtaskNodes = [...subtaskContainer.children]; - const draggedNode = subtaskNodes[this.draggedSubtaskIndex]; - if (draggedNode) { - draggedNode.remove(); - const ref = subtaskContainer.children[targetIndex]; - if (ref) subtaskContainer.insertBefore(draggedNode, ref); - else subtaskContainer.appendChild(draggedNode); - } - } + handleSubtaskContainerDrop(e, task) { + e.preventDefault(); + e.stopPropagation(); + if (!this.draggedSubtask || !this.draggedParent || this.draggedParent.id !== task.id) { + this._hideSubtaskDropIndicator(); + return; } + if (this._subtaskDropInsertIndex == null) { this._hideSubtaskDropIndicator(); return; } - this._syncToServer(parentTask); + let targetIndex = this._subtaskDropInsertIndex; + const currentIndex = this.draggedSubtaskIndex; + if (currentIndex < targetIndex) targetIndex--; + if (currentIndex !== targetIndex) { + const subtask = task.subtasks[currentIndex]; + task.subtasks.splice(currentIndex, 1); + task.subtasks.splice(targetIndex, 0, subtask); + this._syncToServer(task); + this.render(); + } + this._hideSubtaskDropIndicator(); } // ─── Render: Active ─── @@ -702,15 +856,9 @@ class App { card.setAttribute('data-task-id', task.id); card.style.borderLeftColor = task.color || '#6366f1'; - card.addEventListener('dragstart', (e) => this.handleDragStart(e, task, false)); + card.addEventListener('dragstart', (e) => { if (!e.target.closest('.subtask')) this.handleDragStart(e, task, false); }); card.addEventListener('dragend', (e) => this.handleDragEnd(e)); - card.addEventListener('dragover', (e) => this.handleCardDragOver(e)); - card.addEventListener('dragleave', (e) => this.handleDragLeave(e)); - card.addEventListener('dragenter', (e) => this.handleDragEnter(e, task.id)); - card.addEventListener('drop', (e) => { - if (e.target.closest('.drop-zone')) this.handleDrop(e, task); - else if (this.draggedTask && this.draggedTask.id !== task.id) this.handleTaskReorder(e, index); - }); + card.addEventListener('dragenter', (e) => this.handleDragEnterCard(e, task.id)); card.addEventListener('contextmenu', (e) => { if (!e.target.closest('.subtask')) this.showContextMenu(e, task, 'task'); }); card.addEventListener('dblclick', (e) => { if (!e.target.closest('.subtask') && !e.target.closest('button')) this.openEditModal(task); }); @@ -775,6 +923,13 @@ class App { const container = document.createElement('div'); container.className = 'subtasks-container'; + // Container-level drag events for subtask reorder + container.addEventListener('dragover', (e) => this.handleSubtaskContainerDragOver(e, task)); + container.addEventListener('drop', (e) => this.handleSubtaskContainerDrop(e, task)); + container.addEventListener('dragleave', (e) => { + if (!container.contains(e.relatedTarget)) this._hideSubtaskDropIndicator(); + }); + task.subtasks.forEach((subtask, index) => { const div = document.createElement('div'); div.className = 'subtask'; @@ -783,12 +938,6 @@ class App { div.addEventListener('dragstart', (e) => { e.stopPropagation(); this.handleDragStart(e, subtask, true, task, index); }); div.addEventListener('dragend', (e) => { e.stopPropagation(); this.handleDragEnd(e); }); - div.addEventListener('dragover', (e) => { - e.preventDefault(); e.stopPropagation(); - if (this.draggedSubtask && this.draggedParent && this.draggedParent.id === task.id) e.currentTarget.classList.add('drag-over'); - }); - div.addEventListener('dragleave', (e) => { e.stopPropagation(); e.currentTarget.classList.remove('drag-over'); }); - div.addEventListener('drop', (e) => { e.stopPropagation(); this.handleSubtaskReorder(e, task, index); }); div.addEventListener('contextmenu', (e) => { e.stopPropagation(); this.showContextMenu(e, subtask, 'subtask', task); }); div.addEventListener('dblclick', (e) => { e.stopPropagation(); if (!e.target.closest('button')) this.openEditModal(subtask, task); }); @@ -878,7 +1027,7 @@ class App { ` : ''}
- +
`; @@ -954,14 +1103,155 @@ class App { } fireBigCelebration() { - this.fireConfetti(0.5, 0.4, 80); - setTimeout(() => this.fireConfetti(0.25, 0.5, 50), 300); - setTimeout(() => this.fireConfetti(0.75, 0.5, 50), 600); - const overlay = document.createElement('div'); - overlay.className = 'celebration-overlay'; - overlay.innerHTML = '
All Goals Complete!
'; - document.body.appendChild(overlay); - setTimeout(() => overlay.remove(), 3200); + const canvas = document.createElement('canvas'); + canvas.className = 'confetti-canvas'; + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + document.body.appendChild(canvas); + const ctx = canvas.getContext('2d'); + + const palettes = [ + ['#6366f1', '#818cf8', '#a5b4fc'], + ['#f43f5e', '#fb7185', '#fda4af'], + ['#f59e0b', '#fbbf24', '#fde68a'], + ['#10b981', '#34d399', '#6ee7b7'], + ['#0ea5e9', '#38bdf8', '#7dd3fc'], + ['#ec4899', '#f472b6', '#f9a8d4'], + ['#8b5cf6', '#a78bfa', '#c4b5fd'], + ]; + + const particles = []; + const rockets = []; + let startTime = performance.now(); + const duration = 4500; + const launchTimes = [0, 300, 550, 900, 1200, 1600, 2000, 2400, 2800, 3200]; + + const launchRocket = () => { + const x = 0.15 + Math.random() * 0.7; + const targetY = 0.15 + Math.random() * 0.35; + rockets.push({ + x: x * canvas.width, y: canvas.height, + targetY: targetY * canvas.height, + speed: 6 + Math.random() * 4, + palette: palettes[Math.floor(Math.random() * palettes.length)], + trail: [], alive: true + }); + }; + + const explode = (x, y, palette) => { + const count = 50 + Math.floor(Math.random() * 40); + for (let i = 0; i < count; i++) { + const angle = Math.random() * Math.PI * 2; + const speed = 1.5 + Math.random() * 5; + particles.push({ + x, y, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + color: palette[Math.floor(Math.random() * palette.length)], + size: 1.5 + Math.random() * 2.5, + life: 1, + decay: 0.012 + Math.random() * 0.01, + trail: [], + sparkle: Math.random() > 0.6 + }); + } + }; + + let launchIndex = 0; + let frame; + + const animate = (now) => { + const elapsed = now - startTime; + + while (launchIndex < launchTimes.length && elapsed >= launchTimes[launchIndex]) { + launchRocket(); + launchIndex++; + } + + // Clear to transparent each frame + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Draw rockets + for (const r of rockets) { + if (!r.alive) continue; + r.trail.push({ x: r.x, y: r.y }); + if (r.trail.length > 8) r.trail.shift(); + r.y -= r.speed; + + for (let i = 0; i < r.trail.length; i++) { + const t = r.trail[i]; + const alpha = (i / r.trail.length) * 0.7; + ctx.globalAlpha = alpha; + ctx.fillStyle = r.palette[0]; + ctx.beginPath(); + ctx.arc(t.x, t.y, 2, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.globalAlpha = 1; + ctx.fillStyle = '#fff'; + ctx.shadowColor = r.palette[0]; + ctx.shadowBlur = 8; + ctx.beginPath(); + ctx.arc(r.x, r.y, 3, 0, Math.PI * 2); + ctx.fill(); + ctx.shadowBlur = 0; + + if (r.y <= r.targetY) { + r.alive = false; + explode(r.x, r.y, r.palette); + } + } + + // Draw particles with trails + for (const p of particles) { + if (p.life <= 0) continue; + p.trail.push({ x: p.x, y: p.y, life: p.life }); + if (p.trail.length > 5) p.trail.shift(); + p.x += p.vx; p.y += p.vy; + p.vy += 0.04; + p.vx *= 0.985; p.vy *= 0.985; + p.life -= p.decay; + + // Trail + for (let i = 0; i < p.trail.length; i++) { + const t = p.trail[i]; + ctx.globalAlpha = (i / p.trail.length) * t.life * 0.35; + ctx.fillStyle = p.color; + ctx.beginPath(); + ctx.arc(t.x, t.y, p.size * 0.5, 0, Math.PI * 2); + ctx.fill(); + } + + // Main particle with glow + ctx.globalAlpha = p.life; + if (p.sparkle && Math.random() > 0.5) { + ctx.globalAlpha = p.life * (0.5 + Math.random() * 0.5); + } + ctx.shadowColor = p.color; + ctx.shadowBlur = 6; + ctx.fillStyle = p.color; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); + ctx.fill(); + ctx.shadowBlur = 0; + } + + ctx.globalAlpha = 1; + + const allDone = elapsed > duration && + rockets.every(r => !r.alive) && + particles.every(p => p.life <= 0); + + if (allDone) { + cancelAnimationFrame(frame); + canvas.remove(); + } else { + frame = requestAnimationFrame(animate); + } + }; + + frame = requestAnimationFrame(animate); } fireConfetti(originX = 0.5, originY = 0.5, count = 50) { diff --git a/public/index.html b/public/index.html index a816fac..490d17f 100644 --- a/public/index.html +++ b/public/index.html @@ -16,6 +16,18 @@
🗑 Delete
+ +
+
+
🗑
+
Are you sure?
+
+ + +
+
+
+