feat: implement encrypted data export/import and editable table UI with sorting
This commit is contained in:
parent
644b091ea2
commit
ba2861cc73
513
app.js
513
app.js
@ -105,36 +105,282 @@ 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() {
|
||||
const list = document.getElementById('todos-list');
|
||||
if (!todos.length) {
|
||||
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>
|
||||
</div>
|
||||
`).join('');
|
||||
const list = document.getElementById('todos-list');
|
||||
if (!todos.length) {
|
||||
list.innerHTML = '<em>No todos yet.</em>';
|
||||
return;
|
||||
}
|
||||
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>
|
||||
`;
|
||||
// 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();
|
||||
const task = document.getElementById('task').value.trim();
|
||||
@ -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();
|
||||
loadTodos();
|
||||
renderForm();
|
||||
renderTodos();
|
||||
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();
|
||||
document.getElementById('todo-form').addEventListener('submit', addTodo);
|
||||
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.
|
||||
|
||||
34
index.html
34
index.html
@ -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
133
modal.js
Normal 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">×</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
724
style.css
@ -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; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user