253 lines
8.0 KiB
JavaScript
253 lines
8.0 KiB
JavaScript
// ===== Task Manager Frontend Application =====
|
|
|
|
const API_BASE = '/api';
|
|
let tasks = [];
|
|
let currentFilter = 'all';
|
|
|
|
// ===== DOM Elements =====
|
|
const taskList = document.getElementById('task-list');
|
|
const emptyState = document.getElementById('empty-state');
|
|
const loadingState = document.getElementById('loading-state');
|
|
const addTaskForm = document.getElementById('add-task-form');
|
|
const taskTitleInput = document.getElementById('task-title');
|
|
const taskDescInput = document.getElementById('task-description');
|
|
const filterTabs = document.querySelectorAll('.filter-tab');
|
|
const toast = document.getElementById('toast');
|
|
|
|
// ===== API Functions =====
|
|
async function apiRequest(url, options = {}) {
|
|
try {
|
|
const response = await fetch(`${API_BASE}${url}`, {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
...options,
|
|
});
|
|
if (!response.ok) {
|
|
const err = await response.json().catch(() => ({}));
|
|
throw new Error(err.error || `HTTP ${response.status}`);
|
|
}
|
|
return await response.json();
|
|
} catch (err) {
|
|
if (err.name === 'TypeError') {
|
|
throw new Error('Cannot connect to server. Is the API running?');
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function fetchTasks() {
|
|
const data = await apiRequest('/tasks');
|
|
tasks = data.tasks || [];
|
|
// Update source indicator
|
|
const sourceEl = document.querySelector('#stat-source .stat-value');
|
|
if (sourceEl) {
|
|
sourceEl.textContent = data.source === 'cache' ? '⚡ Cache' : '🗄️ DB';
|
|
}
|
|
return tasks;
|
|
}
|
|
|
|
async function createTask(title, description) {
|
|
const data = await apiRequest('/tasks', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ title, description }),
|
|
});
|
|
return data.task;
|
|
}
|
|
|
|
async function updateTask(id, updates) {
|
|
const data = await apiRequest(`/tasks/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(updates),
|
|
});
|
|
return data.task;
|
|
}
|
|
|
|
async function deleteTask(id) {
|
|
await apiRequest(`/tasks/${id}`, { method: 'DELETE' });
|
|
}
|
|
|
|
// ===== UI Rendering =====
|
|
function getFilteredTasks() {
|
|
switch (currentFilter) {
|
|
case 'active':
|
|
return tasks.filter(t => !t.completed);
|
|
case 'completed':
|
|
return tasks.filter(t => t.completed);
|
|
default:
|
|
return tasks;
|
|
}
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleDateString('en-US', {
|
|
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
|
});
|
|
}
|
|
|
|
function renderTasks() {
|
|
const filtered = getFilteredTasks();
|
|
|
|
// Update stats
|
|
const total = tasks.length;
|
|
const completed = tasks.filter(t => t.completed).length;
|
|
const active = total - completed;
|
|
|
|
document.querySelector('#stat-total .stat-value').textContent = total;
|
|
document.querySelector('#stat-active .stat-value').textContent = active;
|
|
document.querySelector('#stat-completed .stat-value').textContent = completed;
|
|
|
|
// Show/hide states
|
|
loadingState.style.display = 'none';
|
|
|
|
if (filtered.length === 0) {
|
|
taskList.innerHTML = '';
|
|
emptyState.style.display = 'block';
|
|
if (tasks.length > 0) {
|
|
emptyState.querySelector('p').textContent = `No ${currentFilter} tasks.`;
|
|
} else {
|
|
emptyState.querySelector('p').textContent = 'No tasks yet. Add your first task above!';
|
|
}
|
|
return;
|
|
}
|
|
|
|
emptyState.style.display = 'none';
|
|
|
|
taskList.innerHTML = filtered.map(task => `
|
|
<div class="task-item ${task.completed ? 'completed' : ''}" data-id="${task.id}">
|
|
<label class="task-checkbox">
|
|
<input type="checkbox"
|
|
${task.completed ? 'checked' : ''}
|
|
onchange="handleToggle(${task.id}, this.checked)"
|
|
aria-label="Mark task as ${task.completed ? 'incomplete' : 'complete'}">
|
|
<span class="checkmark">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
|
<polyline points="20 6 9 17 4 12"/>
|
|
</svg>
|
|
</span>
|
|
</label>
|
|
<div class="task-content">
|
|
<div class="task-title">${escapeHtml(task.title)}</div>
|
|
${task.description ? `<div class="task-description">${escapeHtml(task.description)}</div>` : ''}
|
|
<div class="task-meta">${formatDate(task.created_at)}</div>
|
|
</div>
|
|
<div class="task-actions">
|
|
<button class="btn-icon" onclick="handleDelete(${task.id})" title="Delete task" aria-label="Delete task">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<polyline points="3 6 5 6 21 6"/>
|
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
|
<line x1="10" y1="11" x2="10" y2="17"/>
|
|
<line x1="14" y1="11" x2="14" y2="17"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// ===== Toast Notifications =====
|
|
let toastTimeout;
|
|
function showToast(message, type = 'info') {
|
|
clearTimeout(toastTimeout);
|
|
toast.textContent = message;
|
|
toast.className = `toast ${type} show`;
|
|
toastTimeout = setTimeout(() => {
|
|
toast.classList.remove('show');
|
|
}, 3000);
|
|
}
|
|
|
|
// ===== Event Handlers =====
|
|
async function handleToggle(id, completed) {
|
|
try {
|
|
await updateTask(id, { completed });
|
|
await fetchTasks();
|
|
renderTasks();
|
|
showToast(completed ? '✅ Task completed!' : '🔄 Task reopened', 'success');
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
await fetchTasks();
|
|
renderTasks();
|
|
}
|
|
}
|
|
|
|
async function handleDelete(id) {
|
|
try {
|
|
await deleteTask(id);
|
|
const el = document.querySelector(`.task-item[data-id="${id}"]`);
|
|
if (el) {
|
|
el.style.transform = 'translateX(100px)';
|
|
el.style.opacity = '0';
|
|
await new Promise(r => setTimeout(r, 250));
|
|
}
|
|
await fetchTasks();
|
|
renderTasks();
|
|
showToast('🗑️ Task deleted', 'success');
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
}
|
|
|
|
// ===== Form Submit =====
|
|
addTaskForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const title = taskTitleInput.value.trim();
|
|
const description = taskDescInput.value.trim();
|
|
if (!title) return;
|
|
|
|
try {
|
|
await createTask(title, description);
|
|
taskTitleInput.value = '';
|
|
taskDescInput.value = '';
|
|
taskTitleInput.focus();
|
|
await fetchTasks();
|
|
renderTasks();
|
|
showToast('✨ Task created!', 'success');
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
});
|
|
|
|
// ===== Filter Tabs =====
|
|
filterTabs.forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
filterTabs.forEach(t => t.classList.remove('active'));
|
|
tab.classList.add('active');
|
|
currentFilter = tab.dataset.filter;
|
|
renderTasks();
|
|
});
|
|
});
|
|
|
|
// ===== Initialize =====
|
|
async function init() {
|
|
loadingState.style.display = 'block';
|
|
emptyState.style.display = 'none';
|
|
|
|
try {
|
|
await fetchTasks();
|
|
renderTasks();
|
|
} catch (err) {
|
|
loadingState.style.display = 'none';
|
|
showToast('Failed to load tasks: ' + err.message, 'error');
|
|
emptyState.style.display = 'block';
|
|
emptyState.querySelector('p').textContent = 'Unable to connect to the server.';
|
|
}
|
|
}
|
|
|
|
// Start the application
|
|
init();
|
|
|
|
// Auto-refresh every 30 seconds
|
|
setInterval(async () => {
|
|
try {
|
|
await fetchTasks();
|
|
renderTasks();
|
|
} catch (err) {
|
|
// Silent fail on auto-refresh
|
|
}
|
|
}, 30000);
|