Internal
Public Access
The real dashboard is server-rendered before any client JS runs, so the first page load after reconnecting always reflects the pre-sync database state -- the background sync then completes silently, but nothing told that already-rendered page to update. A task created offline would appear to vanish until the next navigation happened to load fresh data. runBackgroundSync() now reports whether it pushed pending changes, and both places that trigger it automatically reload the page when it did. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
882 lines
27 KiB
JavaScript
882 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();
|
|
maybeBackgroundSync();
|
|
});
|
|
|
|
window.addEventListener('online', async function() {
|
|
const pushedChanges = await runBackgroundSync();
|
|
if (pushedChanges) {
|
|
window.location.reload();
|
|
}
|
|
});
|
|
|
|
/* ============================================
|
|
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.');
|
|
});
|
|
};
|