Internal
Public Access
Wires up the previously-scaffolded VAPID/DeviceToken infrastructure end to end: browser subscription flow on the Profile page's existing "Push Notifications" toggle, a service worker push/notificationclick handler, and server-side sending from the daily task digest. Also broadens that digest's eligibility query so push-only users (email notifications off) aren't silently skipped, and adds `generate_vapid_keys` since the pinned py-vapid's own key generator is broken against current cryptography. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
874 lines
27 KiB
JavaScript
874 lines
27 KiB
JavaScript
/**
|
|
* KeepItGoing - Main Application JavaScript
|
|
* Handles theme toggle, task selection, mobile menus, and AJAX operations
|
|
*/
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
initTheme();
|
|
initTaskSelection();
|
|
initTimerDisplays();
|
|
registerServiceWorker();
|
|
});
|
|
|
|
/* ============================================
|
|
Service Worker
|
|
============================================ */
|
|
|
|
function registerServiceWorker() {
|
|
if ('serviceWorker' in navigator) {
|
|
navigator.serviceWorker.register('/sw.js');
|
|
}
|
|
}
|
|
|
|
/* ============================================
|
|
Push Notifications
|
|
============================================ */
|
|
|
|
function urlBase64ToUint8Array(base64String) {
|
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
|
const rawData = window.atob(base64);
|
|
const outputArray = new Uint8Array(rawData.length);
|
|
for (let i = 0; i < rawData.length; i++) {
|
|
outputArray[i] = rawData.charCodeAt(i);
|
|
}
|
|
return outputArray;
|
|
}
|
|
|
|
async function subscribeToPush(vapidPublicKey) {
|
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
|
throw new Error('Push notifications are not supported in this browser.');
|
|
}
|
|
if (!vapidPublicKey) {
|
|
throw new Error('Push notifications are not configured on this server.');
|
|
}
|
|
|
|
const permission = await Notification.requestPermission();
|
|
if (permission !== 'granted') {
|
|
throw new Error('Notification permission was not granted.');
|
|
}
|
|
|
|
const registration = await navigator.serviceWorker.ready;
|
|
let subscription = await registration.pushManager.getSubscription();
|
|
if (!subscription) {
|
|
subscription = await registration.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
|
});
|
|
}
|
|
|
|
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
|
const response = await fetch('/api/users/devices/', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': csrfToken,
|
|
},
|
|
body: JSON.stringify({
|
|
platform: 'web',
|
|
token: JSON.stringify(subscription),
|
|
device_name: navigator.userAgent.slice(0, 255),
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to register push subscription with the server.');
|
|
}
|
|
}
|
|
|
|
function handleProfileFormSubmit(event, form) {
|
|
const pushCheckbox = form.querySelector('[name=push_notifications]');
|
|
if (!pushCheckbox || !pushCheckbox.checked) {
|
|
return;
|
|
}
|
|
|
|
// preventDefault() must happen synchronously (before any await) or the
|
|
// browser will have already submitted the form by the time we get to it.
|
|
event.preventDefault();
|
|
enablePushThenSubmitProfile(form, pushCheckbox);
|
|
}
|
|
|
|
async function enablePushThenSubmitProfile(form, pushCheckbox) {
|
|
try {
|
|
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
|
const registration = await navigator.serviceWorker.getRegistration();
|
|
const existing = registration && await registration.pushManager.getSubscription();
|
|
if (!existing) {
|
|
await subscribeToPush(pushCheckbox.dataset.vapidPublicKey);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
alert('Could not enable push notifications: ' + err.message);
|
|
pushCheckbox.checked = false;
|
|
}
|
|
form.submit();
|
|
}
|
|
|
|
/* ============================================
|
|
Theme Toggle
|
|
============================================ */
|
|
|
|
function initTheme() {
|
|
// Check for saved theme preference or system preference
|
|
const savedTheme = localStorage.getItem('theme');
|
|
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
|
|
if (savedTheme) {
|
|
setTheme(savedTheme);
|
|
} else if (systemDark) {
|
|
setTheme('dark');
|
|
}
|
|
|
|
// Listen for system theme changes
|
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
|
|
if (!localStorage.getItem('theme')) {
|
|
setTheme(e.matches ? 'dark' : 'light');
|
|
}
|
|
});
|
|
}
|
|
|
|
function toggleTheme() {
|
|
const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
|
|
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
|
setTheme(newTheme);
|
|
localStorage.setItem('theme', newTheme);
|
|
}
|
|
|
|
function setTheme(theme) {
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
|
|
// Update theme toggle icon
|
|
const lightIcon = document.getElementById('theme-icon-light');
|
|
const darkIcon = document.getElementById('theme-icon-dark');
|
|
|
|
if (lightIcon && darkIcon) {
|
|
if (theme === 'dark') {
|
|
lightIcon.style.display = 'none';
|
|
darkIcon.style.display = 'block';
|
|
} else {
|
|
lightIcon.style.display = 'block';
|
|
darkIcon.style.display = 'none';
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ============================================
|
|
Task Selection
|
|
============================================ */
|
|
|
|
function initTaskSelection() {
|
|
// Add click handlers to task items
|
|
const taskItems = document.querySelectorAll('.task-item');
|
|
taskItems.forEach(item => {
|
|
item.addEventListener('click', function(e) {
|
|
// Don't select if clicking checkbox form
|
|
if (e.target.closest('.task-toggle')) {
|
|
return;
|
|
}
|
|
const taskId = this.dataset.taskId;
|
|
if (taskId) {
|
|
selectTask(taskId);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function selectTask(taskId) {
|
|
// Update URL without full page reload
|
|
const url = new URL(window.location);
|
|
url.searchParams.set('selected', taskId);
|
|
window.history.pushState({}, '', url);
|
|
|
|
// Update visual selection
|
|
document.querySelectorAll('.task-item').forEach(item => {
|
|
item.classList.remove('selected');
|
|
});
|
|
const selectedItem = document.querySelector(`.task-item[data-task-id="${taskId}"]`);
|
|
if (selectedItem) {
|
|
selectedItem.classList.add('selected');
|
|
}
|
|
|
|
// Load task detail via AJAX
|
|
loadTaskDetail(taskId);
|
|
}
|
|
|
|
function loadTaskDetail(taskId) {
|
|
const detailPane = document.getElementById('detail-pane');
|
|
const appLayout = document.getElementById('app');
|
|
|
|
if (!detailPane) return;
|
|
|
|
// Show loading state
|
|
detailPane.innerHTML = '<div class="empty-state"><p>Loading...</p></div>';
|
|
detailPane.classList.remove('hidden');
|
|
if (appLayout) {
|
|
appLayout.classList.remove('detail-closed');
|
|
}
|
|
|
|
// On mobile, show detail pane
|
|
if (window.innerWidth <= 1024) {
|
|
detailPane.classList.add('open');
|
|
document.getElementById('mobile-overlay').classList.add('visible');
|
|
}
|
|
|
|
// Fetch task detail HTML
|
|
fetch(`/tasks/${taskId}/detail-partial/`)
|
|
.then(response => {
|
|
if (!response.ok) throw new Error('Failed to load task');
|
|
return response.text();
|
|
})
|
|
.then(html => {
|
|
detailPane.innerHTML = html;
|
|
})
|
|
.catch(error => {
|
|
console.error('Error loading task detail:', error);
|
|
detailPane.innerHTML = `
|
|
<div class="empty-state">
|
|
<p class="text-danger">Failed to load task details</p>
|
|
<p class="text-muted">Please refresh the page</p>
|
|
</div>
|
|
`;
|
|
});
|
|
}
|
|
|
|
function closeDetail() {
|
|
const detailPane = document.getElementById('detail-pane');
|
|
const appLayout = document.getElementById('app');
|
|
|
|
if (detailPane) {
|
|
detailPane.classList.add('hidden');
|
|
detailPane.classList.remove('open');
|
|
}
|
|
if (appLayout) {
|
|
appLayout.classList.add('detail-closed');
|
|
}
|
|
|
|
// Clear selection
|
|
document.querySelectorAll('.task-item').forEach(item => {
|
|
item.classList.remove('selected');
|
|
});
|
|
|
|
// Update URL
|
|
const url = new URL(window.location);
|
|
url.searchParams.delete('selected');
|
|
window.history.pushState({}, '', url);
|
|
|
|
// Close mobile overlay
|
|
closeMobileMenus();
|
|
}
|
|
|
|
/* ============================================
|
|
Custom Recurrence Builder
|
|
============================================ */
|
|
|
|
function toggleCustomRecurrenceBuilder(selectEl) {
|
|
const form = selectEl.closest('form');
|
|
const builder = form.querySelector('.custom-recurrence-builder');
|
|
if (builder) {
|
|
builder.classList.toggle('hidden', selectEl.value !== 'custom');
|
|
}
|
|
}
|
|
|
|
function toggleCustomFreqPanel(radioEl) {
|
|
const builder = radioEl.closest('.custom-recurrence-builder');
|
|
const weeklyPanel = builder.querySelector('.custom-weekly-panel');
|
|
const monthlyPanel = builder.querySelector('.custom-monthly-panel');
|
|
weeklyPanel.classList.toggle('hidden', radioEl.value !== 'weekly');
|
|
monthlyPanel.classList.toggle('hidden', radioEl.value !== 'monthly');
|
|
}
|
|
|
|
function assembleCustomRecurrenceRule(formEl) {
|
|
const recurrenceSelect = formEl.querySelector('[name="recurrence"]');
|
|
const ruleInput = formEl.querySelector('[name="recurrence_rule"]');
|
|
if (!recurrenceSelect || !ruleInput) {
|
|
return true;
|
|
}
|
|
|
|
if (recurrenceSelect.value !== 'custom') {
|
|
ruleInput.value = '';
|
|
return true;
|
|
}
|
|
|
|
const builder = formEl.querySelector('.custom-recurrence-builder');
|
|
if (!builder) {
|
|
return true;
|
|
}
|
|
|
|
const freqMode = builder.querySelector('.custom-freq-mode:checked');
|
|
const freq = freqMode ? freqMode.value : 'weekly';
|
|
|
|
if (freq === 'weekly') {
|
|
const panel = builder.querySelector('.custom-weekly-panel');
|
|
const interval = parseInt(panel.querySelector('.custom-interval').value, 10) || 1;
|
|
const days = Array.from(panel.querySelectorAll('.custom-weekday:checked')).map(cb => cb.value);
|
|
if (days.length === 0) {
|
|
ruleInput.value = '';
|
|
return true;
|
|
}
|
|
ruleInput.value = `FREQ=WEEKLY;INTERVAL=${interval};BYDAY=${days.join(',')}`;
|
|
} else {
|
|
const panel = builder.querySelector('.custom-monthly-panel');
|
|
const interval = parseInt(panel.querySelector('.custom-interval').value, 10) || 1;
|
|
const monthlyMode = panel.querySelector('.custom-monthly-mode:checked');
|
|
const mode = monthlyMode ? monthlyMode.value : 'day';
|
|
|
|
if (mode === 'day') {
|
|
const day = parseInt(panel.querySelector('.custom-bymonthday').value, 10) || 1;
|
|
ruleInput.value = `FREQ=MONTHLY;INTERVAL=${interval};BYMONTHDAY=${day}`;
|
|
} else {
|
|
const ordinal = panel.querySelector('.custom-nth-ordinal').value;
|
|
const weekday = panel.querySelector('.custom-nth-weekday').value;
|
|
ruleInput.value = `FREQ=MONTHLY;INTERVAL=${interval};BYDAY=${ordinal}${weekday}`;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/* ============================================
|
|
Mobile Navigation
|
|
============================================ */
|
|
|
|
function toggleSidebar() {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const overlay = document.getElementById('mobile-overlay');
|
|
|
|
if (sidebar) {
|
|
sidebar.classList.toggle('open');
|
|
}
|
|
if (overlay) {
|
|
overlay.classList.toggle('visible', sidebar.classList.contains('open'));
|
|
}
|
|
}
|
|
|
|
function closeMobileMenus() {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const detailPane = document.getElementById('detail-pane');
|
|
const overlay = document.getElementById('mobile-overlay');
|
|
|
|
if (sidebar) {
|
|
sidebar.classList.remove('open');
|
|
}
|
|
if (detailPane) {
|
|
detailPane.classList.remove('open');
|
|
}
|
|
if (overlay) {
|
|
overlay.classList.remove('visible');
|
|
}
|
|
}
|
|
|
|
/* ============================================
|
|
Timer Displays
|
|
============================================ */
|
|
|
|
function initTimerDisplays() {
|
|
// Handle running timer in header/dashboard
|
|
const runningTimer = document.getElementById('running-timer');
|
|
if (runningTimer) {
|
|
const startedAt = new Date(runningTimer.dataset.started);
|
|
updateTimerDisplay(runningTimer, startedAt);
|
|
setInterval(() => updateTimerDisplay(runningTimer, startedAt), 1000);
|
|
}
|
|
}
|
|
|
|
function updateTimerDisplay(element, startedAt) {
|
|
const now = new Date();
|
|
const diff = Math.floor((now - startedAt) / 1000);
|
|
const hours = Math.floor(diff / 3600).toString().padStart(2, '0');
|
|
const minutes = Math.floor((diff % 3600) / 60).toString().padStart(2, '0');
|
|
const seconds = (diff % 60).toString().padStart(2, '0');
|
|
element.textContent = `${hours}:${minutes}:${seconds}`;
|
|
}
|
|
|
|
/* ============================================
|
|
Utility Functions
|
|
============================================ */
|
|
|
|
function formatDuration(seconds) {
|
|
if (seconds < 60) {
|
|
return `${seconds}s`;
|
|
} else if (seconds < 3600) {
|
|
const mins = Math.floor(seconds / 60);
|
|
return `${mins}m`;
|
|
} else {
|
|
const hours = Math.floor(seconds / 3600);
|
|
const mins = Math.floor((seconds % 3600) / 60);
|
|
return `${hours}h ${mins}m`;
|
|
}
|
|
}
|
|
|
|
function formatRelativeDate(dateString) {
|
|
const date = new Date(dateString);
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
const tomorrow = new Date(today);
|
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
|
|
const yesterday = new Date(today);
|
|
yesterday.setDate(yesterday.getDate() - 1);
|
|
|
|
const dateOnly = new Date(date);
|
|
dateOnly.setHours(0, 0, 0, 0);
|
|
|
|
if (dateOnly.getTime() === today.getTime()) {
|
|
return 'Today';
|
|
} else if (dateOnly.getTime() === tomorrow.getTime()) {
|
|
return 'Tomorrow';
|
|
} else if (dateOnly.getTime() === yesterday.getTime()) {
|
|
return 'Yesterday';
|
|
} else {
|
|
return date.toLocaleDateString();
|
|
}
|
|
}
|
|
|
|
/* ============================================
|
|
Handle Browser Back/Forward
|
|
============================================ */
|
|
|
|
window.addEventListener('popstate', function(e) {
|
|
const url = new URL(window.location);
|
|
const selectedTaskId = url.searchParams.get('selected');
|
|
|
|
if (selectedTaskId) {
|
|
// Show the selected task
|
|
document.querySelectorAll('.task-item').forEach(item => {
|
|
item.classList.remove('selected');
|
|
});
|
|
const selectedItem = document.querySelector(`.task-item[data-task-id="${selectedTaskId}"]`);
|
|
if (selectedItem) {
|
|
selectedItem.classList.add('selected');
|
|
}
|
|
loadTaskDetail(selectedTaskId);
|
|
} else {
|
|
// Close detail panel
|
|
closeDetail();
|
|
}
|
|
});
|
|
|
|
/* ============================================
|
|
Group Modal
|
|
============================================ */
|
|
|
|
let currentGroupId = null;
|
|
let selectedColor = '#3b82f6';
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
initGroupModal();
|
|
});
|
|
|
|
function initGroupModal() {
|
|
// Setup backdrop click to close
|
|
const backdrop = document.getElementById('group-modal-backdrop');
|
|
if (backdrop) {
|
|
backdrop.addEventListener('click', function(e) {
|
|
if (e.target === backdrop) {
|
|
closeGroupModal();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Setup color preset buttons
|
|
document.querySelectorAll('.color-preset').forEach(btn => {
|
|
btn.addEventListener('click', function() {
|
|
selectColor(this.dataset.color);
|
|
});
|
|
});
|
|
|
|
// Setup custom color picker
|
|
const colorPicker = document.getElementById('group-color');
|
|
if (colorPicker) {
|
|
colorPicker.addEventListener('input', function() {
|
|
selectColor(this.value, true);
|
|
});
|
|
}
|
|
|
|
// Setup name field for preview
|
|
const nameField = document.getElementById('group-name');
|
|
if (nameField) {
|
|
nameField.addEventListener('input', updateGroupPreview);
|
|
}
|
|
|
|
// Setup form submission
|
|
const form = document.getElementById('group-form');
|
|
if (form) {
|
|
form.addEventListener('submit', handleGroupSubmit);
|
|
}
|
|
}
|
|
|
|
// Make openGroupModal globally available for onclick handlers
|
|
window.openGroupModal = function(groupId = null, name = '', color = '#3b82f6') {
|
|
console.log('openGroupModal called:', { groupId, name, color });
|
|
|
|
const backdrop = document.getElementById('group-modal-backdrop');
|
|
const title = document.getElementById('group-modal-title');
|
|
const nameField = document.getElementById('group-name');
|
|
const deleteBtn = document.getElementById('group-delete-btn');
|
|
const submitBtn = document.getElementById('group-submit-btn');
|
|
const groupIdField = document.getElementById('group-id');
|
|
|
|
if (!backdrop) {
|
|
console.error('Modal backdrop not found!');
|
|
return;
|
|
}
|
|
|
|
currentGroupId = groupId;
|
|
|
|
if (groupId) {
|
|
// Edit mode
|
|
if (title) title.textContent = 'Edit Group';
|
|
if (nameField) nameField.value = name;
|
|
if (deleteBtn) deleteBtn.style.display = 'inline-flex';
|
|
if (submitBtn) submitBtn.textContent = 'Save';
|
|
if (groupIdField) groupIdField.value = groupId;
|
|
} else {
|
|
// Create mode
|
|
if (title) title.textContent = 'New Group';
|
|
if (nameField) nameField.value = '';
|
|
if (deleteBtn) deleteBtn.style.display = 'none';
|
|
if (submitBtn) submitBtn.textContent = 'Create';
|
|
if (groupIdField) groupIdField.value = '';
|
|
color = '#3b82f6';
|
|
}
|
|
|
|
selectColor(color);
|
|
updateGroupPreview();
|
|
|
|
backdrop.classList.add('open');
|
|
console.log('Modal should now be open');
|
|
if (nameField) nameField.focus();
|
|
};
|
|
|
|
// Make closeGroupModal globally available for onclick handlers
|
|
window.closeGroupModal = function() {
|
|
const backdrop = document.getElementById('group-modal-backdrop');
|
|
if (backdrop) {
|
|
backdrop.classList.remove('open');
|
|
}
|
|
currentGroupId = null;
|
|
};
|
|
|
|
function selectColor(color, isCustom = false) {
|
|
selectedColor = color;
|
|
|
|
// Update preset button selection
|
|
document.querySelectorAll('.color-preset').forEach(btn => {
|
|
btn.classList.toggle('selected', btn.dataset.color.toLowerCase() === color.toLowerCase());
|
|
});
|
|
|
|
// Update custom color picker if not already from custom
|
|
if (!isCustom) {
|
|
const colorPicker = document.getElementById('group-color');
|
|
if (colorPicker) {
|
|
colorPicker.value = color;
|
|
}
|
|
}
|
|
|
|
// Update hidden field
|
|
const hiddenField = document.getElementById('group-color-hidden');
|
|
if (hiddenField) {
|
|
hiddenField.value = color;
|
|
}
|
|
|
|
// Update preview
|
|
updateGroupPreview();
|
|
}
|
|
|
|
function updateGroupPreview() {
|
|
const nameField = document.getElementById('group-name');
|
|
const previewDot = document.getElementById('group-preview-dot');
|
|
const previewName = document.getElementById('group-preview-name');
|
|
|
|
if (previewDot) {
|
|
previewDot.style.backgroundColor = selectedColor;
|
|
}
|
|
if (previewName && nameField) {
|
|
previewName.textContent = nameField.value || 'Group Preview';
|
|
previewName.style.color = nameField.value ? 'var(--text-primary)' : 'var(--text-muted)';
|
|
}
|
|
}
|
|
|
|
function handleGroupSubmit(e) {
|
|
e.preventDefault();
|
|
|
|
const nameField = document.getElementById('group-name');
|
|
const name = nameField.value.trim();
|
|
|
|
if (!name) {
|
|
nameField.focus();
|
|
return;
|
|
}
|
|
|
|
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
|
const formData = new FormData();
|
|
formData.append('name', name);
|
|
formData.append('color', selectedColor);
|
|
formData.append('csrfmiddlewaretoken', csrfToken);
|
|
|
|
let url, method;
|
|
if (currentGroupId) {
|
|
// Update existing group
|
|
url = `/groups/${currentGroupId}/`;
|
|
formData.append('next', window.location.pathname + window.location.search);
|
|
} else {
|
|
// Create new group
|
|
url = '/groups/';
|
|
formData.append('next', window.location.pathname + window.location.search);
|
|
}
|
|
|
|
fetch(url, {
|
|
method: 'POST',
|
|
body: formData,
|
|
headers: {
|
|
'X-Requested-With': 'XMLHttpRequest'
|
|
}
|
|
})
|
|
.then(response => {
|
|
if (response.redirected) {
|
|
window.location.href = response.url;
|
|
} else if (response.ok) {
|
|
window.location.reload();
|
|
} else {
|
|
throw new Error('Failed to save group');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error saving group:', error);
|
|
alert('Failed to save group. Please try again.');
|
|
});
|
|
}
|
|
|
|
// Make deleteGroup globally available for onclick handlers
|
|
window.deleteGroup = function() {
|
|
if (!currentGroupId) return;
|
|
|
|
if (!confirm('Are you sure you want to delete this group?\n\nTasks in this group will not be deleted.')) {
|
|
return;
|
|
}
|
|
|
|
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
|
const formData = new FormData();
|
|
formData.append('csrfmiddlewaretoken', csrfToken);
|
|
formData.append('next', window.location.pathname + window.location.search);
|
|
|
|
fetch(`/groups/${currentGroupId}/delete/`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
headers: {
|
|
'X-Requested-With': 'XMLHttpRequest'
|
|
}
|
|
})
|
|
.then(response => {
|
|
if (response.redirected) {
|
|
window.location.href = response.url;
|
|
} else if (response.ok) {
|
|
window.location.reload();
|
|
} else {
|
|
throw new Error('Failed to delete group');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error deleting group:', error);
|
|
alert('Failed to delete group. Please try again.');
|
|
});
|
|
};
|
|
|
|
/* ============================================
|
|
Tag Modal
|
|
============================================ */
|
|
|
|
let currentTagId = null;
|
|
let selectedTagColor = '#3b82f6';
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
initTagModal();
|
|
});
|
|
|
|
function initTagModal() {
|
|
// Setup backdrop click to close
|
|
const backdrop = document.getElementById('tag-modal-backdrop');
|
|
if (backdrop) {
|
|
backdrop.addEventListener('click', function(e) {
|
|
if (e.target === backdrop) {
|
|
closeTagModal();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Setup color preset buttons
|
|
document.querySelectorAll('.color-preset').forEach(btn => {
|
|
btn.addEventListener('click', function() {
|
|
selectTagColor(this.dataset.color);
|
|
});
|
|
});
|
|
|
|
// Setup custom color picker
|
|
const colorPicker = document.getElementById('tag-color');
|
|
if (colorPicker) {
|
|
colorPicker.addEventListener('input', function() {
|
|
selectTagColor(this.value, true);
|
|
});
|
|
}
|
|
|
|
// Setup form submission
|
|
const form = document.getElementById('tag-form');
|
|
if (form) {
|
|
form.addEventListener('submit', handleTagSubmit);
|
|
}
|
|
}
|
|
|
|
function handleTagSubmit(e) {
|
|
e.preventDefault();
|
|
|
|
const nameField = document.getElementById('tag-name');
|
|
const name = nameField.value.trim();
|
|
|
|
if (!name) {
|
|
nameField.focus();
|
|
return;
|
|
}
|
|
|
|
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
|
const formData = new FormData();
|
|
formData.append('name', name);
|
|
formData.append('color', selectedTagColor);
|
|
formData.append('csrfmiddlewaretoken', csrfToken);
|
|
|
|
let url;
|
|
if (currentTagId) {
|
|
// Update existing tag
|
|
url = `/tags/${currentTagId}/`;
|
|
formData.append('next', window.location.pathname + window.location.search);
|
|
} else {
|
|
// Create new tag
|
|
url = '/tags/';
|
|
formData.append('next', window.location.pathname + window.location.search);
|
|
}
|
|
|
|
fetch(url, {
|
|
method: 'POST',
|
|
body: formData,
|
|
headers: {
|
|
'X-Requested-With': 'XMLHttpRequest'
|
|
}
|
|
})
|
|
.then(response => {
|
|
if (response.redirected) {
|
|
window.location.href = response.url;
|
|
} else if (response.ok) {
|
|
window.location.reload();
|
|
} else {
|
|
throw new Error('Failed to save tag');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error saving tag:', error);
|
|
alert('Failed to save tag. Please try again.');
|
|
});
|
|
}
|
|
|
|
// Make openTagModal globally available for onclick handlers
|
|
window.openTagModal = function(tagId = null, name = '', color = '#3b82f6') {
|
|
console.log('openTagModal called:', { tagId, name, color });
|
|
|
|
const backdrop = document.getElementById('tag-modal-backdrop');
|
|
const title = document.getElementById('tag-modal-title');
|
|
const nameField = document.getElementById('tag-name');
|
|
const deleteBtn = document.getElementById('tag-delete-btn');
|
|
const submitBtn = document.getElementById('tag-submit-btn');
|
|
const tagIdField = document.getElementById('tag-id');
|
|
|
|
if (!backdrop) {
|
|
console.error('Tag modal backdrop not found!');
|
|
return;
|
|
}
|
|
|
|
currentTagId = tagId;
|
|
|
|
if (tagId) {
|
|
// Edit mode
|
|
if (title) title.textContent = 'Edit Tag';
|
|
if (nameField) nameField.value = name;
|
|
if (deleteBtn) deleteBtn.style.display = 'inline-flex';
|
|
if (submitBtn) submitBtn.textContent = 'Save';
|
|
if (tagIdField) tagIdField.value = tagId;
|
|
} else {
|
|
// Create mode
|
|
if (title) title.textContent = 'New Tag';
|
|
if (nameField) nameField.value = '';
|
|
if (deleteBtn) deleteBtn.style.display = 'none';
|
|
if (submitBtn) submitBtn.textContent = 'Create';
|
|
if (tagIdField) tagIdField.value = '';
|
|
color = '#3b82f6';
|
|
}
|
|
|
|
selectTagColor(color);
|
|
backdrop.classList.add('open');
|
|
if (nameField) nameField.focus();
|
|
};
|
|
|
|
// Make closeTagModal globally available for onclick handlers
|
|
window.closeTagModal = function() {
|
|
const backdrop = document.getElementById('tag-modal-backdrop');
|
|
if (backdrop) {
|
|
backdrop.classList.remove('open');
|
|
}
|
|
currentTagId = null;
|
|
};
|
|
|
|
function selectTagColor(color) {
|
|
selectedTagColor = color;
|
|
|
|
// Update preset button selection
|
|
document.querySelectorAll('.color-preset').forEach(btn => {
|
|
btn.classList.toggle('selected', btn.dataset.color.toLowerCase() === color.toLowerCase());
|
|
});
|
|
|
|
// Update custom color picker
|
|
const colorPicker = document.getElementById('tag-color');
|
|
if (colorPicker) {
|
|
colorPicker.value = color;
|
|
}
|
|
|
|
// Update hidden field
|
|
const hiddenField = document.getElementById('tag-color-hidden');
|
|
if (hiddenField) {
|
|
hiddenField.value = color;
|
|
}
|
|
}
|
|
|
|
// Make deleteTag globally available for onclick handlers
|
|
window.deleteTag = function() {
|
|
if (!currentTagId) return;
|
|
|
|
if (!confirm('Are you sure you want to delete this tag?\n\nTasks with this tag will not be deleted.')) {
|
|
return;
|
|
}
|
|
|
|
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
|
const formData = new FormData();
|
|
formData.append('csrfmiddlewaretoken', csrfToken);
|
|
formData.append('next', window.location.pathname + window.location.search);
|
|
|
|
fetch(`/tags/${currentTagId}/delete/`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
headers: {
|
|
'X-Requested-With': 'XMLHttpRequest'
|
|
}
|
|
})
|
|
.then(response => {
|
|
if (response.redirected) {
|
|
window.location.href = response.url;
|
|
} else if (response.ok) {
|
|
window.location.reload();
|
|
} else {
|
|
throw new Error('Failed to delete tag');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error deleting tag:', error);
|
|
alert('Failed to delete tag. Please try again.');
|
|
});
|
|
};
|