feat: implement encrypted data export/import and editable table UI with sorting

This commit is contained in:
Greg 2025-05-14 00:39:35 +02:00
parent 644b091ea2
commit ba2861cc73
4 changed files with 1356 additions and 48 deletions

497
app.js
View File

@ -105,6 +105,65 @@ function renderForm() {
// Autocomplete for categories
const datalist = document.getElementById('category-list');
datalist.innerHTML = categories.map(cat => `<option value="${cat}">`).join('');
// Ensure addTodo is always bound after form render
form.removeEventListener('submit', addTodo); // prevent duplicates
form.addEventListener('submit', addTodo);
}
let sortState = { column: null, asc: true };
const sortableColumns = [
{ key: 'id', label: 'ID' },
{ key: 'task', label: 'Description' },
{ key: 'creationDate', label: 'Creation Date' },
{ key: 'nextDate', label: 'Next Date' },
{ key: 'dueDate', label: 'Due Date' },
{ key: 'urgent', label: 'Urgent' },
{ key: 'importance', label: 'Importance' },
{ key: 'prio', label: 'Prio' }
];
function sortTodos(column) {
if (sortState.column === column) {
sortState.asc = !sortState.asc;
} else {
sortState.column = column;
sortState.asc = true;
}
todos.sort((a, b) => {
let valA = a[column];
let valB = b[column];
// For date columns, compare as dates
if (["creationDate","nextDate","dueDate"].includes(column)) {
valA = valA || '';
valB = valB || '';
return sortState.asc ? valA.localeCompare(valB) : valB.localeCompare(valA);
} else if (typeof valA === 'string') {
return sortState.asc ? valA.localeCompare(valB) : valB.localeCompare(valA);
} else {
return sortState.asc ? valA - valB : valB - valA;
}
});
renderTodos();
}
function formatDisplayDate(dateStr) {
if (!dateStr) return '';
// Accepts YYYY-MM-DD or ISO string
let d = dateStr.length > 10 ? new Date(dateStr) : null;
let year, month, day;
if (d && !isNaN(d)) {
year = d.getFullYear().toString().slice(-2);
month = String(d.getMonth() + 1).padStart(2, '0');
day = String(d.getDate()).padStart(2, '0');
} else if (/^\d{4}-\d{2}-\d{2}/.test(dateStr)) {
year = dateStr.slice(2,4);
month = dateStr.slice(5,7);
day = dateStr.slice(8,10);
} else {
return dateStr;
}
return `${day}-${month}-${year}`;
}
function renderTodos() {
@ -113,27 +172,214 @@ function renderTodos() {
list.innerHTML = '<em>No todos yet.</em>';
return;
}
list.innerHTML = todos.map(todo => `
<div class="todo-item">
<div class="field"><b>ID:</b> ${todo.id}</div>
<div class="field"><b>Created:</b> ${todo.creationDate}</div>
<div class="field"><b>Next:</b> ${todo.nextDate || ''}</div>
<div class="field"><b>Due:</b> ${todo.dueDate || ''}</div>
<div class="field status-${todo.status || 'blank'}"><b>Status:</b> ${todo.status || ''}</div>
<div class="field urgent"><b>Urgent:</b> ${todo.urgent}</div>
<div class="field importance"><b>Importance:</b> ${todo.importance}</div>
<div class="field prio"><b>Prio:</b> ${todo.prio}</div>
<div class="field"><b>Est.:</b> ${todo.timeEstimation}m</div>
<div class="field"><b>Spent:</b> ${todo.actualTime}m</div>
<div class="field"><b>Category:</b> ${todo.category || ''}</div>
<div class="field"><b>Task:</b> ${todo.task}</div>
<div class="field"><b>Coms:</b> ${todo.coms ? `<a href="${todo.coms}" target="_blank">Link</a>` : ''}</div>
<div class="field"><b>Link:</b> ${todo.link ? `<a href="${todo.link}" target="_blank">Link</a>` : ''}</div>
<div class="field"><b>Comments:</b> ${todo.comments || ''}</div>
<div class="field"><b>Linked:</b> ${todo.linkedTasks || ''}</div>
const columns = [
{ key: 'id', label: 'ID', sortable: true },
{ key: 'task', label: 'Description', sortable: true },
{ key: 'creationDate', label: 'Creation Date', sortable: true },
{ key: 'nextDate', label: 'Next Date', sortable: true },
{ key: 'dueDate', label: 'Due Date', sortable: true },
{ key: 'status', label: 'Status' },
{ key: 'urgent', label: 'Urgent', sortable: true },
{ key: 'importance', label: 'Importance', sortable: true },
{ key: 'prio', label: 'Prio', sortable: true },
{ key: 'timeEstimation', label: 'Time Est. (min)' },
{ key: 'actualTime', label: 'Actual Time (min)' },
{ key: 'category', label: 'Category' },
{ key: 'coms', label: 'Coms' },
{ key: 'link', label: 'Link' },
{ key: 'comments', label: 'Comments' },
{ key: 'linkedTasks', label: 'Linked Tasks' },
];
list.innerHTML = `
<div class="table-responsive">
<table class="todos-table">
<thead>
<tr>
${columns.map(col =>
col.sortable ?
`<th class="sortable" data-col="${col.key}">${col.label} ${sortState.column === col.key ? (sortState.asc ? '▲' : '▼') : ''}</th>` :
`<th>${col.label}</th>`
).join('')}
</tr>
</thead>
<tbody>
${todos.map((todo, rowIdx) => `
<tr>
${columns.map(col => renderEditableCell(todo, col.key, rowIdx)).join('')}
</tr>
`).join('')}
</tbody>
</table>
</div>
`).join('');
`;
// Add sorting event listeners
document.querySelectorAll('.todos-table th.sortable').forEach(th => {
th.addEventListener('click', () => sortTodos(th.dataset.col));
});
// Add modal editing listeners
document.querySelectorAll('.todos-table td[data-editable="true"]').forEach(td => {
td.addEventListener('click', function(e) {
const rowIdx = +td.dataset.row;
const key = td.dataset.key;
if (window.showEditTodoModal) {
window.showEditTodoModal(rowIdx, key);
}
});
});
}
function renderEditableCell(todo, key, rowIdx) {
// Non-editable fields: id, prio
const nonEditable = ['id', 'prio'];
// Editable fields
const editable = [
'task', 'creationDate', 'nextDate', 'dueDate', 'status', 'urgent', 'importance',
'timeEstimation', 'actualTime', 'category', 'coms', 'link', 'comments', 'linkedTasks'
];
let value = todo[key] || '';
if (["creationDate","nextDate","dueDate"].includes(key)) {
value = formatDisplayDate(todo[key]);
}
if (key === 'coms' && value) value = `<a href="${value}" target="_blank">Link</a>`;
if (key === 'link' && value) value = `<a href="${value}" target="_blank">Link</a>`;
if (key === 'status') return `<td class="status-${todo.status || 'blank'}" data-editable="true" data-row="${rowIdx}" data-key="${key}">${todo.status || ''}</td>`;
if (nonEditable.includes(key)) return `<td>${todo[key]}</td>`;
return `<td data-editable="true" data-row="${rowIdx}" data-key="${key}">${value}</td>`;
}
function handleCellEdit(e) {
const td = e.currentTarget;
const rowIdx = +td.dataset.row;
const key = td.dataset.key;
const todo = todos[rowIdx];
let input;
let oldValue = todo[key] || '';
// Helper to format date as YYYY-MM-DD
function formatDate(val) {
if (!val) return '';
if (val.length === 10 && val.match(/^\d{4}-\d{2}-\d{2}$/)) return val;
const d = new Date(val);
if (isNaN(d)) return '';
return d.toISOString().slice(0, 10);
}
if (["creationDate","nextDate","dueDate"].includes(key)) {
input = document.createElement('input');
input.type = 'date';
input.value = formatDate(oldValue);
let changed = false;
input.addEventListener('change', () => {
changed = true;
saveCellEdit(td, rowIdx, key, input.value);
});
input.addEventListener('blur', () => {
// If the user clicked the date picker, allow change to fire first
setTimeout(() => {
if (!changed) saveCellEdit(td, rowIdx, key, input.value);
}, 150);
});
} else if (["urgent","importance","timeEstimation","actualTime"].includes(key)) {
input = document.createElement('input');
input.type = 'number';
input.value = oldValue;
if (["urgent","importance"].includes(key)) {
input.min = 1; input.max = 7;
}
if (["timeEstimation","actualTime"].includes(key)) {
input.min = 0;
}
// Save on both change (for arrows) and blur (for manual input)
input.addEventListener('change', () => saveCellEdit(td, rowIdx, key, input.value));
input.addEventListener('blur', () => saveCellEdit(td, rowIdx, key, input.value));
input.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter') {
evt.preventDefault();
input.blur();
}
});
} else if (key === 'status') {
input = document.createElement('select');
['','Busy','Done','W4A'].forEach(opt => {
const option = document.createElement('option');
option.value = opt;
option.text = opt || '(Blank)';
if (opt === oldValue) option.selected = true;
input.appendChild(option);
});
input.addEventListener('change', () => saveCellEdit(td, rowIdx, key, input.value));
} else if (key === 'category') {
input = document.createElement('input');
input.type = 'text';
input.value = oldValue;
input.setAttribute('list','category-list');
input.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter') {
evt.preventDefault();
input.blur();
}
});
} else if (key === 'comments' || key === 'linkedTasks') {
input = document.createElement('textarea');
input.value = oldValue;
input.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter' && !evt.shiftKey) {
evt.preventDefault();
input.blur();
}
});
} else {
input = document.createElement('input');
input.type = 'text';
input.value = oldValue;
input.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter') {
evt.preventDefault();
input.blur();
}
});
}
input.className = 'table-edit-input';
td.innerHTML = '';
td.appendChild(input);
// For date input, ensure the picker opens
if (["creationDate","nextDate","dueDate"].includes(key)) {
setTimeout(() => {
input.focus();
if (typeof input.showPicker === 'function') {
input.showPicker();
}
}, 0);
// For date fields, do NOT save or close on blur at all—only on change
} else {
input.focus();
input.addEventListener('blur', () => saveCellEdit(td, rowIdx, key, input.value));
}
}
function saveCellEdit(td, rowIdx, key, value) {
// Type conversions
if (["urgent","importance","timeEstimation","actualTime"].includes(key)) {
value = parseInt(value) || 0;
}
if (["creationDate","nextDate","dueDate"].includes(key)) {
value = value || '';
}
if (key === 'category') {
if (value && !categories.includes(value)) {
categories.push(value);
}
}
todos[rowIdx][key] = value;
// Recalculate prio if urgency or importance changed
if (key === 'urgent' || key === 'importance') {
todos[rowIdx].prio = (parseInt(todos[rowIdx].urgent) || 1) * (parseInt(todos[rowIdx].importance) || 1);
}
saveTodos();
renderTodos();
}
function addTodo(e) {
e.preventDefault();
@ -180,14 +426,221 @@ function addTodo(e) {
document.getElementById('todo-form').reset();
}
function switchTab(tab) {
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(tc => tc.classList.remove('active'));
if (tab === 'list') {
document.getElementById('tab-list-btn').classList.add('active');
document.getElementById('tab-content-list').classList.add('active');
} else if (tab === 'form') {
document.getElementById('tab-form-btn').classList.add('active');
document.getElementById('tab-content-form').classList.add('active');
} else if (tab === 'admin') {
document.getElementById('tab-admin-btn').classList.add('active');
document.getElementById('tab-content-admin').classList.add('active');
}
}
function init() {
loadTodos();
renderForm();
renderTodos();
document.getElementById('todo-form').addEventListener('submit', addTodo);
document.getElementById('tab-list-btn').addEventListener('click', () => switchTab('list'));
document.getElementById('tab-form-btn').addEventListener('click', () => switchTab('form'));
const adminBtn = document.getElementById('tab-admin-btn');
if (adminBtn) adminBtn.addEventListener('click', () => switchTab('admin'));
// --- Password Modal Helper ---
function showPasswordModal({title, confirm=false}) {
return new Promise((resolve, reject) => {
const modal = document.getElementById('password-modal');
const titleEl = document.getElementById('password-modal-title');
const input = document.getElementById('password-modal-input');
const confirmInput = document.getElementById('password-modal-confirm');
const okBtn = document.getElementById('password-modal-ok');
const cancelBtn = document.getElementById('password-modal-cancel');
const errorEl = document.getElementById('password-modal-error');
titleEl.textContent = title;
input.value = '';
confirmInput.value = '';
errorEl.style.display = 'none';
confirmInput.style.display = confirm ? '' : 'none';
modal.style.display = 'flex';
input.focus();
function cleanup() {
modal.style.display = 'none';
okBtn.onclick = null;
cancelBtn.onclick = null;
input.onkeydown = null;
confirmInput.onkeydown = null;
}
okBtn.onclick = () => {
const pw = input.value;
if (confirm) {
const pw2 = confirmInput.value;
if (!pw || !pw2) {
errorEl.textContent = 'Please enter and confirm your password.';
errorEl.style.display = 'block';
return;
}
if (pw !== pw2) {
errorEl.textContent = 'Passwords do not match.';
errorEl.style.display = 'block';
return;
}
} else {
if (!pw) {
errorEl.textContent = 'Please enter your password.';
errorEl.style.display = 'block';
return;
}
}
cleanup();
resolve(pw);
};
cancelBtn.onclick = () => { cleanup(); reject('cancel'); };
input.onkeydown = e => { if (e.key === 'Enter') okBtn.onclick(); };
confirmInput.onkeydown = e => { if (e.key === 'Enter') okBtn.onclick(); };
});
}
// --- Encryption helpers ---
async function getKeyFromPassword(password, salt) {
const enc = new TextEncoder();
const keyMaterial = await window.crypto.subtle.importKey(
'raw', enc.encode(password), {name: 'PBKDF2'}, false, ['deriveKey']);
return window.crypto.subtle.deriveKey(
{name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256'},
keyMaterial,
{name: 'AES-GCM', length: 256},
false,
['encrypt', 'decrypt']
);
}
async function encryptData(data, password) {
const enc = new TextEncoder();
const salt = window.crypto.getRandomValues(new Uint8Array(16));
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const key = await getKeyFromPassword(password, salt);
const ciphertext = await window.crypto.subtle.encrypt(
{name: 'AES-GCM', iv},
key,
enc.encode(data)
);
// Output as base64: salt + iv + ciphertext
function toB64(buf) { return btoa(String.fromCharCode(...new Uint8Array(buf))); }
return toB64(salt) + ':' + toB64(iv) + ':' + toB64(ciphertext);
}
async function decryptData(b64, password) {
function fromB64(b64) { return Uint8Array.from(atob(b64), c => c.charCodeAt(0)); }
const [saltB64, ivB64, ctB64] = b64.split(':');
const salt = fromB64(saltB64);
const iv = fromB64(ivB64);
const ciphertext = fromB64(ctB64);
const key = await getKeyFromPassword(password, salt);
const dec = new TextDecoder();
const plaintext = await window.crypto.subtle.decrypt(
{name: 'AES-GCM', iv},
key,
ciphertext
);
return dec.decode(plaintext);
}
// Export JSON (password-protected)
const exportBtn = document.getElementById('export-json-btn');
if (exportBtn) exportBtn.addEventListener('click', async () => {
let password;
try {
password = await showPasswordModal({title: 'Set Export Password', confirm: true});
} catch { return; }
if (!password) return;
const data = {
todos,
categories
};
const json = JSON.stringify(data, null, 2);
try {
const encrypted = await encryptData(json, password);
const blob = new Blob([encrypted], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'todos-export.json';
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
// Optionally show in <pre>
const pre = document.getElementById('export-json-output');
if (pre) {
pre.style.display = 'block';
pre.textContent = encrypted;
}
} catch (err) {
alert('Encryption failed: ' + err.message);
}
});
// Import JSON (password-protected)
const importBtn = document.getElementById('import-json-btn');
const importInput = document.getElementById('import-json-input');
if (importBtn && importInput) {
importBtn.addEventListener('click', () => importInput.click());
importInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
let password;
try {
password = await showPasswordModal({title: 'Enter Password to Import', confirm: false});
} catch { return; }
if (!password) return;
const reader = new FileReader();
reader.onload = async function(evt) {
try {
const encrypted = evt.target.result;
let json;
try {
json = await decryptData(encrypted, password);
} catch (err) {
throw new Error('Incorrect password or corrupt file.');
}
const data = JSON.parse(json);
if (!data.todos || !Array.isArray(data.todos)) throw new Error('Missing or invalid todos array.');
if (!data.categories || !Array.isArray(data.categories)) throw new Error('Missing or invalid categories array.');
todos = data.todos;
categories = data.categories;
saveTodos();
renderTodos();
renderForm();
alert('Import successful!');
switchTab('list');
} catch (err) {
alert('Import failed: ' + err.message);
}
};
reader.readAsText(file);
// Reset input so same file can be imported again
importInput.value = '';
});
}
// Delete all data
const delBtn = document.getElementById('delete-all-btn');
if (delBtn) delBtn.addEventListener('click', () => {
if (!confirm('Are you sure you want to delete ALL todos and categories? This cannot be undone.')) return;
todos = [];
categories = [];
saveTodos();
renderTodos();
renderForm();
alert('All data deleted.');
switchTab('list');
});
}
document.addEventListener('DOMContentLoaded', init);
// Password protection placeholder (to be implemented if needed)
// If you want to enable password, set a flag in localStorage and show/hide #password-section accordingly.

View File

@ -5,21 +5,51 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Todo App (Local)</title>
<link rel="stylesheet" href="style.css">
<meta name="description" content="A modern todo application with admin features">
</head>
<body>
<div id="app-container">
<h1>Todo App</h1>
<div id="password-section" style="display:none;">
<label for="password-input">Enter password:</label>
<input type="password" id="password-input" placeholder="Enter password">
<button id="password-submit">Unlock</button>
</div>
<div id="todo-section">
<div class="tab-bar">
<button class="tab-btn active" id="tab-list-btn" type="button">Todos List</button>
<button class="tab-btn" id="tab-form-btn" type="button">Add/Edit Todo</button>
<button class="tab-btn" id="tab-admin-btn" type="button">Admin</button>
</div>
<div id="tab-content-list" class="tab-content active">
<div id="todos-list"></div>
</div>
<div id="tab-content-form" class="tab-content">
<form id="todo-form">
<!-- Fields will be rendered by JS -->
</form>
<div id="todos-list"></div>
</div>
<div id="tab-content-admin" class="tab-content">
<h2>Admin</h2>
<button id="export-json-btn" type="button">Export Todos as JSON</button>
<button id="import-json-btn" type="button" style="margin-left:8px;">Import JSON</button>
<input id="import-json-input" type="file" accept="application/json" style="display:none;" />
<button id="delete-all-btn" type="button" style="margin-left:16px;color:#fff;background:#d33;">Delete All Data</button>
<pre id="export-json-output" style="display:none;margin-top:16px;"></pre>
</div>
</div>
<div id="password-modal" class="modal" style="display:none;">
<div class="modal-content">
<h3 id="password-modal-title">Enter Password</h3>
<input type="password" id="password-modal-input" placeholder="Password">
<input type="password" id="password-modal-confirm" placeholder="Confirm Password" style="display:none; margin-top: 8px;">
<div style="margin-top: 14px;">
<button id="password-modal-ok">OK</button>
<button id="password-modal-cancel" style="margin-left:12px;">Cancel</button>
</div>
<div id="password-modal-error" style="color:#d33; margin-top:8px; display:none;"></div>
</div>
</div>
<script src="app.js"></script>
<script src="modal.js"></script>
</body>
</html>

133
modal.js Normal file
View File

@ -0,0 +1,133 @@
// Modal logic for editing a todo cell in a popup
// Create and show a modal with the Add/Edit Todo form, focusing the relevant field
function showEditTodoModal(rowIdx, key) {
// Find the todo
const todo = window.todos[rowIdx];
if (!todo) return;
// Build modal overlay
let modal = document.createElement('div');
modal.className = 'modal';
modal.style.zIndex = 2000;
modal.innerHTML = `
<div class="modal-content modal-edit-todo">
<button class="modal-close-btn" aria-label="Close">&times;</button>
<h2>Edit Todo</h2>
<form id="modal-todo-form">
<div class="form-section">
<label for="modal-task">Task Description</label>
<input type="text" id="modal-task" name="task" required value="${todo.task || ''}">
</div>
<div class="form-section grid-3">
<div>
<label for="modal-creation-date">Creation Date</label>
<input type="date" id="modal-creation-date" name="creationDate" value="${todo.creationDate || ''}">
</div>
<div>
<label for="modal-next-date">Next Planned Work Date</label>
<input type="date" id="modal-next-date" name="nextDate" value="${todo.nextDate || ''}">
</div>
<div>
<label for="modal-due-date">Due Date</label>
<input type="date" id="modal-due-date" name="dueDate" value="${todo.dueDate || ''}">
</div>
</div>
<div class="form-section grid-3">
<div>
<label for="modal-status">Status</label>
<select id="modal-status" name="status">
<option value="" ${!todo.status ? 'selected' : ''}>(Blank)</option>
<option value="Busy" ${todo.status==="Busy"?'selected':''}>Busy</option>
<option value="Done" ${todo.status==="Done"?'selected':''}>Done</option>
<option value="W4A" ${todo.status==="W4A"?'selected':''}>W4A</option>
</select>
</div>
<div>
<label for="modal-urgent">Urgent (1-7)</label>
<input type="number" id="modal-urgent" name="urgent" min="1" max="7" value="${todo.urgent || ''}">
</div>
<div>
<label for="modal-importance">Importance (1-7)</label>
<input type="number" id="modal-importance" name="importance" min="1" max="7" value="${todo.importance || ''}">
</div>
</div>
<div class="form-section grid-2">
<div>
<label for="modal-time-estimation">Time Estimation (min)</label>
<input type="number" id="modal-time-estimation" name="timeEstimation" min="0" value="${todo.timeEstimation || ''}">
</div>
<div>
<label for="modal-actual-time">Actual Time Spent (min)</label>
<input type="number" id="modal-actual-time" name="actualTime" min="0" value="${todo.actualTime || ''}">
</div>
</div>
<div class="form-section grid-2">
<div>
<label for="modal-category">Category</label>
<input type="text" id="modal-category" name="category" value="${todo.category || ''}">
</div>
<div>
<label for="modal-linked-tasks">Link with other tasks (IDs, comma separated)</label>
<input type="text" id="modal-linked-tasks" name="linkedTasks" value="${todo.linkedTasks || ''}">
</div>
</div>
<div class="form-section grid-2">
<div>
<label for="modal-coms">Coms URL</label>
<input type="url" id="modal-coms" name="coms" value="${todo.coms || ''}">
</div>
<div>
<label for="modal-link">Link URL</label>
<input type="url" id="modal-link" name="link" value="${todo.link || ''}">
</div>
</div>
<div class="form-section">
<label for="modal-comments">Comments</label>
<textarea id="modal-comments" name="comments">${todo.comments || ''}</textarea>
</div>
<button type="submit" class="add-todo-btn">Save Changes</button>
</form>
</div>
`;
// Close modal on overlay click or close button
function closeModal() {
modal.remove();
document.body.style.overflow = '';
}
modal.querySelector('.modal-close-btn').onclick = closeModal;
modal.onclick = (e) => { if (e.target === modal) closeModal(); };
// Save changes
modal.querySelector('#modal-todo-form').onsubmit = function(evt) {
evt.preventDefault();
const formData = new FormData(this);
const updated = {};
for (let [k, v] of formData.entries()) updated[k] = v;
// Type conversions
updated.urgent = parseInt(updated.urgent) || 1;
updated.importance = parseInt(updated.importance) || 1;
updated.prio = updated.urgent * updated.importance;
updated.timeEstimation = parseInt(updated.timeEstimation) || 0;
updated.actualTime = parseInt(updated.actualTime) || 0;
window.todos[rowIdx] = { ...window.todos[rowIdx], ...updated };
window.localStorage.setItem('todos-v1', JSON.stringify(window.todos));
closeModal();
window.renderTodos && window.renderTodos();
};
// Insert and focus correct field
document.body.appendChild(modal);
document.body.style.overflow = 'hidden';
setTimeout(() => {
let focusId = 'modal-' + key.replace(/([A-Z])/g, '-$1').toLowerCase();
let focusElem = modal.querySelector('#' + focusId);
if (focusElem) focusElem.focus();
}, 80);
}
// Attach to window for use in app.js
document.addEventListener('DOMContentLoaded', () => {
window.showEditTodoModal = showEditTodoModal;
});

724
style.css
View File

@ -1,17 +1,670 @@
:root {
--accent: #222222;
--accent-dark: #111111;
--accent-light: #f4f4f4;
--bg: #fafbfc;
--card-bg: #fff;
--border: #e0e0e0;
--border-light: #f3f3f3;
--shadow-sm: 0 2px 8px rgba(0,0,0,0.04);
--shadow: 0 8px 30px rgba(0,0,0,0.07);
--shadow-lg: 0 12px 42px rgba(0,0,0,0.13);
--text: #181818;
--text-heading: #111111;
--text-secondary: #444;
--muted: #888;
--danger: #222;
--danger-light: #f8f8f8;
--success: #222;
--success-light: #f4f4f4;
--warning: #222;
--warning-light: #f4f4f4;
}
body {
font-family: Arial, sans-serif;
background: #f8f9fa;
background: var(--bg);
color: var(--text);
}
body {
background: var(--bg);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', 'Segoe UI', 'Arial', 'Helvetica Neue', Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
margin: 0;
padding: 0;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app-container {
max-width: 700px;
margin: 40px auto;
padding: 24px;
max-width: 1200px;
width: 94vw;
min-height: 90vh;
margin: 48px auto;
padding: 48px 48px 56px 48px;
background: var(--card-bg);
border-radius: 32px;
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
border: 1.5px solid var(--border);
}
@media (max-width: 700px) {
#app-container {
padding: 12px 2vw 28px 2vw;
min-width: 0;
border-radius: 18px;
}
}
#tab-content-list {
background: #fafbfc;
color: #181818;
border-radius: 18px;
box-shadow: 0 4px 32px rgba(0,0,0,0.07);
border: 1.5px solid #e0e0e0;
padding: 32px 18px 32px 18px;
margin-bottom: 32px;
}
#tab-content-list .table-responsive {
background: #fff;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
border: 1.5px solid #e0e0e0;
padding: 12px 0;
}
#tab-content-list .todos-table th, #tab-content-list .todos-table td {
color: #181818;
}
#tab-content-list .todos-table th {
background: #222;
color: #fff;
border-bottom: 2px solid #e0e0e0;
font-weight: 700;
}
#tab-content-list .todos-table td {
background: #fff;
}
#tab-content-list .todos-table tbody tr:nth-child(even) td {
background: #f4f4f4;
}
#tab-content-list .todos-table tbody tr:hover td {
background: #e0e0e0;
color: #111;
font-weight: 600;
}
@media (max-width: 700px) {
#app-container {
padding: 8px 2vw 24px 2vw;
min-width: 0;
}
}
h1, h2, h3 {
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-heading);
margin-bottom: 0.8em;
}
h1 {
font-size: 2.2rem;
margin-bottom: 1.2rem;
color: var(--accent-dark);
}
h2 {
font-size: 1.8rem;
color: var(--accent);
}
h3 {
font-size: 1.4rem;
color: var(--text-secondary);
}
.tab-bar {
display: flex;
background: #f4f4f4;
border-radius: 999px;
padding: 4px;
margin-bottom: 40px;
box-shadow: var(--shadow-sm);
gap: 0;
align-items: center;
justify-content: flex-start;
width: fit-content;
position: relative;
z-index: 1;
}
.tab-btn {
background: none;
border: none;
outline: none;
color: var(--muted);
font-size: 1.05em;
font-weight: 600;
padding: 12px 32px;
border-radius: 999px;
cursor: pointer;
transition: all 0.2s ease-in-out;
position: relative;
overflow: hidden;
}
.tab-btn:hover {
color: var(--text);
}
.tab-btn.active, #tab-list-btn.tab-btn.active {
background: #222;
color: #fff;
box-shadow: 0 4px 16px rgba(0,0,0,0.13);
border: none;
}
.tab-btn:focus-visible, #tab-list-btn:focus-visible {
outline: 2px solid #222;
outline-offset: 2px;
}
.tab-btn, #tab-list-btn {
border: none;
box-shadow: none;
}
.tab-btn:hover, #tab-list-btn:hover {
background: #444;
color: #fff;
box-shadow: 0 2px 8px #e0e0e0;
}
.tab-btn {
color: #222;
background: transparent;
font-weight: 700;
}
.tab-btn:hover {
background: #e0e0e0;
color: #111;
box-shadow: 0 2px 8px #e0e0e0;
}
.tab-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.tab-content {
display: none;
animation: fadeIn 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
width: 100%;
}
.tab-content.active {
display: block;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: none; }
}
.add-todo-btn, button[type="button"], button {
background: #222;
color: #fff;
border: none;
border-radius: 12px;
padding: 12px 28px;
font-size: 1em;
font-weight: 600;
cursor: pointer;
margin-top: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.11);
transition: all 0.2s ease-in-out;
position: relative;
overflow: hidden;
letter-spacing: 0.01em;
}
.add-todo-btn:hover, button[type="button"]:hover, button:hover {
background: #444;
color: #fff;
box-shadow: 0 4px 16px rgba(0,0,0,0.13);
border: 2px solid #e0e0e0;
transform: translateY(-1px) scale(1.03);
}
button:active {
background: #111;
color: #fff;
box-shadow: 0 1px 2px #222;
border: 2px solid #e0e0e0;
transform: translateY(1px) scale(0.97);
}
button:focus-visible {
outline: 2px solid #222;
outline-offset: 2px;
}
button.secondary {
background: #f4f4f4;
color: #222;
border: 2px solid #888;
}
button.secondary:hover {
background: #222;
color: #fff;
border: 2px solid #e0e0e0;
}
button.danger {
background: #111;
color: #fff;
border: 2px solid #888;
}
button.danger:hover {
background: #444;
color: #fff;
border: 2px solid #222;
}
.add-todo-btn:hover, button[type="button"]:hover, button:hover {
background: var(--accent-dark);
box-shadow: 0 4px 12px rgba(100,27,46,0.14);
transform: translateY(-1px);
}
button:active {
background: #641B2E;
transform: translateY(1px);
box-shadow: 0 2px 4px rgba(100,27,46,0.13);
}
.add-todo-btn:hover, button[type="button"]:hover, button:hover {
background: var(--accent-dark);
box-shadow: 0 4px 12px rgba(108,99,255,0.15);
transform: translateY(-1px);
}
button:active {
background: #3b36a5;
transform: translateY(1px);
box-shadow: 0 2px 4px rgba(108,99,255,0.1);
}
button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Secondary button style */
button.secondary {
background: transparent;
color: var(--accent);
border: 1.5px solid var(--accent);
box-shadow: none;
}
button.secondary:hover {
background: var(--accent-light);
color: var(--accent-dark);
box-shadow: 0 2px 8px rgba(108,99,255,0.07);
}
button.danger {
background: var(--danger);
}
button.danger:hover {
background: #c53030;
box-shadow: 0 4px 12px rgba(229,62,62,0.15);
}
input, select, textarea {
font-family: inherit;
font-size: 1em;
padding: 12px 16px;
border: 1.5px solid var(--border);
border-radius: 10px;
margin-top: 6px;
margin-bottom: 18px;
background: #fafbff;
color: var(--text);
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0,0,0,0.02);
width: 100%;
}
input:hover, select:hover, textarea:hover {
border-color: #d1d5db;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(108,99,255,0.15);
background: #fff;
}
label {
font-weight: 600;
font-size: 0.95em;
color: var(--text-secondary);
margin-bottom: 4px;
display: block;
}
::placeholder {
color: var(--muted);
opacity: 0.7;
}
.table-responsive {
width: 100%;
overflow-x: auto;
max-width: 100vw;
margin: 0;
padding: 0;
}
.todos-table {
width: 100%;
min-width: 1100px;
border-collapse: separate;
border-spacing: 0;
font-size: 0.95em;
background: #fff;
border-radius: 16px;
overflow: hidden;
margin-bottom: 24px;
table-layout: auto;
}
.todos-table th {
background: linear-gradient(90deg, #641B2E 0%, #8A2D3B 40%, #BE5B50 80%, #FBDB93 100%);
color: #fff;
font-weight: 700;
font-size: 0.97em;
padding: 18px 16px;
position: sticky;
top: 0;
z-index: 2;
border: none;
letter-spacing: 0.01em;
text-shadow: 0 2px 6px #8A2D3B, 0 1px 1px #641B2E;
text-transform: uppercase;
border-bottom: 3px solid #FBDB93;
}
.todos-table td {
border: none;
padding: 16px;
background: #fff;
color: #641B2E;
vertical-align: middle;
transition: all 0.2s ease;
border-bottom: 1px solid #FBDB93;
}
.todos-table tbody tr:nth-child(even) td {
background: #FBDB93;
color: #641B2E;
}
.todos-table tbody tr:hover td {
background: #BE5B50;
color: #fff;
}
.todos-table tbody tr:last-child td {
border-bottom: none;
}
.todos-table tbody tr:nth-child(even) td {
background: #fafbff;
}
.todos-table tbody tr:hover td {
background: #f0f0ff;
}
.todos-table tbody tr:last-child td {
border-bottom: none;
}
.todos-table th.sortable {
cursor: pointer;
user-select: none;
position: relative;
}
.todos-table th.sortable:after {
content: '';
display: inline-block;
margin-left: 8px;
border: 4px solid transparent;
border-top-color: #fff;
vertical-align: middle;
opacity: 0.6;
}
.todos-table th.sortable[data-col]:hover {
background: #5146e1;
}
.table-edit-input {
background: #f7f8fe;
border: 1.5px solid var(--accent);
border-radius: 7px;
padding: 7px 10px;
font-size: 1em;
box-shadow: 0 1px 4px rgba(108,99,255,0.07);
}
/* Modal (black/white/grey palette) */
.modal {
position: fixed;
z-index: 2000;
left: 0; top: 0; right: 0; bottom: 0;
background: rgba(20,20,20,0.55);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
animation: fadeIn 0.2s ease-out;
}
.modal-content {
background: #fff;
padding: 36px 28px 28px 28px;
border-radius: 18px;
box-shadow: 0 8px 40px rgba(0,0,0,0.18);
min-width: 340px;
max-width: 96vw;
max-height: 90vh;
overflow-y: auto;
animation: modalSlideIn 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
border: 2px solid #e0e0e0;
}
.modal-edit-todo h2 {
margin-top: 0;
margin-bottom: 24px;
color: #111;
font-size: 1.4em;
font-weight: 700;
text-align: left;
}
.modal-close-btn {
position: absolute;
top: 18px;
right: 18px;
background: none;
border: none;
font-size: 1.9em;
color: #888;
cursor: pointer;
transition: color 0.15s;
z-index: 10;
}
.modal-close-btn:hover {
color: #222;
}
#modal-todo-form input:focus, #modal-todo-form textarea:focus, #modal-todo-form select:focus {
outline: 2px solid #222;
border-color: #222;
background: #f4f4f4;
}
#modal-todo-form input, #modal-todo-form textarea, #modal-todo-form select {
background: #fafbfc;
border: 1.5px solid #e0e0e0;
color: #181818;
border-radius: 8px;
padding: 10px 12px;
font-size: 1em;
margin-bottom: 10px;
width: 100%;
box-sizing: border-box;
transition: border-color 0.15s, background 0.15s;
}
#modal-todo-form label {
font-weight: 600;
color: #444;
margin-bottom: 4px;
display: block;
}
#modal-todo-form .add-todo-btn {
margin-top: 18px;
width: 100%;
background: #222;
color: #fff;
border: none;
border-radius: 8px;
font-size: 1.1em;
font-weight: 700;
padding: 12px 0;
transition: background 0.15s;
}
#modal-todo-form .add-todo-btn:hover {
background: #444;
}
@keyframes modalSlideIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
#password-modal input[type='password'] {
font-size: 1em;
padding: 14px 16px;
margin-bottom: 16px;
border: 1.5px solid var(--border);
border-radius: 10px;
width: 100%;
background: #fff;
}
#password-modal-error {
color: var(--danger);
margin-top: 12px;
font-size: 0.9em;
padding: 10px 14px;
background: var(--danger-light);
border-radius: 8px;
font-weight: 500;
}
::-webkit-scrollbar {
width: 10px;
background: #f4f6fa;
}
::-webkit-scrollbar-thumb {
background: #d5d7e3;
border-radius: 6px;
}
.form-section {
margin-bottom: 16px;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.grid-3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
}
@media (max-width: 900px) {
.todos-table {
min-width: 800px;
}
.grid-3 {
grid-template-columns: 1fr;
}
.grid-2 {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
.todos-table {
min-width: 500px;
}
#app-container {
padding: 4px 0 18px 0;
}
}
#app-container {
max-width: 98vw;
width: 98vw;
min-height: 90vh;
margin: 16px auto;
padding: 16px 16px 32px 16px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
box-sizing: border-box;
display: flex;
flex-direction: column;
}
@media (min-width: 1200px) {
#app-container {
max-width: 1600px;
width: 98vw;
}
}
.tab-bar {
display: flex;
gap: 0;
margin-bottom: 24px;
border-bottom: 2px solid #e5e7eb;
}
.tab-btn {
flex: 1;
padding: 12px 0;
background: none;
border: none;
border-bottom: 3px solid transparent;
font-size: 1.1rem;
font-weight: 500;
cursor: pointer;
transition: border-bottom 0.2s, background 0.2s;
}
.tab-btn.active {
border-bottom: 3px solid #2563eb;
background: #f1f5fb;
color: #2563eb;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
h1 {
text-align: center;
}
@ -73,19 +726,58 @@ form input, form select, form textarea {
#todos-list {
margin-top: 24px;
}
.todo-item {
background: #f1f3f4;
border-radius: 4px;
padding: 12px;
margin-bottom: 12px;
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
.table-responsive {
width: 100%;
overflow-x: auto;
max-width: 100vw;
margin: 0;
padding: 0;
}
.todo-item .field {
min-width: 120px;
.todos-table {
width: 100%;
min-width: 1100px;
border-collapse: collapse;
font-size: 1rem;
background: #fff;
table-layout: auto;
}
.todos-table th, .todos-table td {
border: 1px solid #e5e7eb;
padding: 8px 10px;
text-align: left;
vertical-align: top;
}
.todos-table th {
background: #f1f5fb;
position: sticky;
top: 0;
z-index: 1;
}
.todos-table tbody tr:nth-child(even) {
background: #f8f9fa;
}
.todos-table a {
color: #2563eb;
text-decoration: underline;
}
.todos-table .status-Busy {
color: #d97706;
font-weight: bold;
}
.todos-table .status-Done {
color: #16a34a;
font-weight: bold;
}
.todos-table .status-W4A {
color: #2563eb;
font-weight: bold;
}
.todos-table .status-blank {
color: #6b7280;
}
.status-Busy { color: #d97706; }
.status-Done { color: #16a34a; }
.status-W4A { color: #2563eb; }