Edited completed Task + Delte HTML Windows
This commit is contained in:
1 parent
e5e0703106
commit
def7d22294
3 files changed
+504
-122
No files matched your search
+374
-84
@@ -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}?<br><span style="font-size:0.85em;color:var(--text-muted)">This action cannot be undone.</span>`
|
||||
);
|
||||
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?<br><span style="font-size:0.85em;color:var(--text-muted)">This action cannot be undone.</span>'
|
||||
);
|
||||
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 {
|
||||
` : ''}
|
||||
<div class="task-actions">
|
||||
<button class="btn-small btn-restore" onclick="app.unarchiveTask('${task.id}')">Restore</button>
|
||||
<button class="btn-small btn-delete" onclick="if(confirm('Delete permanently?')) app.deleteTask('${task.id}')">Delete</button>
|
||||
<button class="btn-small btn-delete" onclick="app.confirmDeleteTask('${task.id}')">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -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 = '<div class="celebration-text">All Goals Complete!</div>';
|
||||
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) {
|
||||
|
||||
@@ -16,6 +16,18 @@
|
||||
<div class="context-menu-item danger" id="context-delete">🗑 Delete</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<div class="confirm-overlay" id="confirm-overlay">
|
||||
<div class="confirm-dialog">
|
||||
<div class="confirm-icon" id="confirm-icon">🗑</div>
|
||||
<div class="confirm-message" id="confirm-message">Are you sure?</div>
|
||||
<div class="confirm-actions">
|
||||
<button class="btn-confirm-cancel" id="confirm-cancel">Cancel</button>
|
||||
<button class="btn-confirm-delete" id="confirm-delete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal" id="modal">
|
||||
<div class="modal-content">
|
||||
|
||||
+118
-38
@@ -188,6 +188,94 @@ h1 {
|
||||
.context-menu-item:hover { background: var(--accent-soft); color: var(--accent); }
|
||||
.context-menu-item.danger:hover { background: var(--rose-soft); color: var(--rose); }
|
||||
|
||||
/* ─── Confirm Dialog ─── */
|
||||
.confirm-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 2000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.confirm-overlay.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.confirm-dialog {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border-radius: 16px;
|
||||
padding: 28px 32px 22px;
|
||||
min-width: 300px;
|
||||
max-width: 360px;
|
||||
text-align: center;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
animation: confirmIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes confirmIn {
|
||||
from { opacity: 0; transform: scale(0.92) translateY(8px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.confirm-icon {
|
||||
font-size: 2.2em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.confirm-message {
|
||||
font-size: 0.95em;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-confirm-cancel,
|
||||
.btn-confirm-delete {
|
||||
padding: 8px 22px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.88em;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-confirm-cancel {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-confirm-cancel:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-confirm-delete {
|
||||
background: var(--rose);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(225, 29, 72, 0.25);
|
||||
}
|
||||
|
||||
.btn-confirm-delete:hover {
|
||||
background: #be123c;
|
||||
box-shadow: 0 4px 12px rgba(225, 29, 72, 0.35);
|
||||
}
|
||||
|
||||
/* ─── Modal ─── */
|
||||
.modal {
|
||||
display: none;
|
||||
@@ -502,11 +590,6 @@ button {
|
||||
.task-card:active { cursor: grabbing; }
|
||||
.task-card.dragging { opacity: 0.4; }
|
||||
|
||||
.task-card.drag-over {
|
||||
border-color: var(--accent);
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
|
||||
.task-card.archived-card {
|
||||
cursor: default;
|
||||
}
|
||||
@@ -873,48 +956,46 @@ button {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.celebration-overlay {
|
||||
/* ─── Drop Indicator ─── */
|
||||
.drop-indicator {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
height: 3px;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
z-index: 9998;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: celebFade 3s ease forwards;
|
||||
box-shadow: 0 0 8px rgba(99, 102, 241, 0.5);
|
||||
transition: top 0.08s ease;
|
||||
}
|
||||
|
||||
.celebration-text {
|
||||
font-size: 3em;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #6366f1, #a78bfa, #f59e0b, #10b981);
|
||||
background-size: 300% 300%;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
animation: celebPulse 2s ease, celebGradient 2s ease infinite;
|
||||
filter: drop-shadow(0 2px 8px rgba(99, 102, 241, 0.3));
|
||||
.drop-indicator::before,
|
||||
.drop-indicator::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
background: var(--accent);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@keyframes celebFade {
|
||||
0% { opacity: 0; }
|
||||
15% { opacity: 1; }
|
||||
70% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
.drop-indicator::before { left: -5px; }
|
||||
.drop-indicator::after { right: -5px; }
|
||||
|
||||
.drop-indicator-subtask {
|
||||
height: 2px;
|
||||
box-shadow: 0 0 6px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
@keyframes celebPulse {
|
||||
0% { transform: scale(0.3); }
|
||||
50% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); }
|
||||
.drop-indicator-subtask::before,
|
||||
.drop-indicator-subtask::after {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
top: -3px;
|
||||
}
|
||||
|
||||
@keyframes celebGradient {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
.drop-indicator-subtask::before { left: -4px; }
|
||||
.drop-indicator-subtask::after { right: -4px; }
|
||||
|
||||
.task-card.just-completed {
|
||||
animation: completeFlash 0.6s ease;
|
||||
@@ -939,5 +1020,4 @@ button {
|
||||
.form-row-2 { grid-template-columns: 1fr; }
|
||||
.task-card { padding: 10px 12px; }
|
||||
.greeting-banner .greeting-text { font-size: 1em; }
|
||||
.celebration-text { font-size: 2em; }
|
||||
}
|
||||
Reference in new issue
Block a user