Initial WhatToDo app
Self-hosted personal task list: FastAPI + SQLite in Docker, mobile-friendly PWA frontend, deadlines, priorities, categories, tags, notes, checklists, recurring tasks, single-password login, and JSON export/import. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,607 @@
|
||||
'use strict';
|
||||
|
||||
const VIEWS = [
|
||||
{ id: 'today', label: 'Today', icon: '☀️' },
|
||||
{ id: 'upcoming', label: 'Upcoming', icon: '📅' },
|
||||
{ id: 'all', label: 'All open', icon: '📋' },
|
||||
{ id: 'done', label: 'Done', icon: '✅' },
|
||||
];
|
||||
const PRIORITY_LABELS = ['', 'Low', 'Medium', 'High'];
|
||||
const REPEAT_UNITS = { daily: 'day', weekly: 'week', monthly: 'month', yearly: 'year' };
|
||||
|
||||
const state = { tasks: [], categories: [], view: 'today', categoryId: null, search: '' };
|
||||
let editing = null; // task open in the dialog; null when creating
|
||||
let draftSubtasks = [];
|
||||
let toastTimer = null;
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
|
||||
// --- Small DOM helper ---------------------------------------------------------
|
||||
|
||||
function h(tag, attrs, ...children) {
|
||||
const el = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(attrs || {})) {
|
||||
if (v == null || v === false) continue;
|
||||
if (k === 'class') el.className = v;
|
||||
else if (k === 'style') Object.assign(el.style, v);
|
||||
else if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
|
||||
else if (k === 'value' || k === 'checked') el[k] = v;
|
||||
else el.setAttribute(k, v === true ? '' : v);
|
||||
}
|
||||
for (const c of children.flat()) {
|
||||
if (c != null && c !== false) el.append(c instanceof Node ? c : String(c));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
// --- Dates (all local, as YYYY-MM-DD strings) ---------------------------------
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const fmtDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
const parseDate = (s) => { const [y, m, d] = s.split('-').map(Number); return new Date(y, m - 1, d); };
|
||||
const todayStr = () => fmtDate(new Date());
|
||||
const addDays = (s, n) => { const d = parseDate(s); d.setDate(d.getDate() + n); return fmtDate(d); };
|
||||
const dayDiff = (a, b) => {
|
||||
const [ay, am, ad] = a.split('-').map(Number);
|
||||
const [by, bm, bd] = b.split('-').map(Number);
|
||||
return Math.round((Date.UTC(by, bm - 1, bd) - Date.UTC(ay, am - 1, ad)) / 86400000);
|
||||
};
|
||||
|
||||
function dueInfo(due) {
|
||||
const diff = dayDiff(todayStr(), due);
|
||||
const d = parseDate(due);
|
||||
let text;
|
||||
if (diff === 0) text = 'Today';
|
||||
else if (diff === 1) text = 'Tomorrow';
|
||||
else if (diff === -1) text = 'Yesterday';
|
||||
else if (diff > 1 && diff < 7) text = d.toLocaleDateString(undefined, { weekday: 'long' });
|
||||
else {
|
||||
const opts = { weekday: 'short', day: 'numeric', month: 'short' };
|
||||
if (d.getFullYear() !== new Date().getFullYear()) opts.year = 'numeric';
|
||||
text = d.toLocaleDateString(undefined, opts);
|
||||
}
|
||||
if (diff < -1) text += ` · ${-diff} days late`;
|
||||
return { text, cls: diff < 0 ? 'overdue' : diff === 0 ? 'today' : '' };
|
||||
}
|
||||
|
||||
// --- API ----------------------------------------------------------------------
|
||||
|
||||
async function api(path, { method = 'GET', body } = {}) {
|
||||
const headers = { 'X-WhatToDo': '1' };
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
const res = await fetch(path, {
|
||||
method, headers, credentials: 'same-origin',
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (res.status === 401 && path !== 'api/login') {
|
||||
showLogin();
|
||||
throw new Error('Please log in');
|
||||
}
|
||||
if (!res.ok) {
|
||||
let msg = `${res.status} ${res.statusText}`;
|
||||
try {
|
||||
const j = await res.json();
|
||||
if (typeof j.detail === 'string') msg = j.detail;
|
||||
else if (Array.isArray(j.detail)) msg = j.detail.map((e) => `${e.loc.slice(1).join('.')}: ${e.msg}`).join('; ');
|
||||
} catch { /* not JSON */ }
|
||||
throw new Error(msg);
|
||||
}
|
||||
return res.status === 204 ? null : res.json();
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const [categories, tasks] = await Promise.all([api('api/categories'), api('api/tasks')]);
|
||||
state.categories = categories;
|
||||
state.tasks = tasks;
|
||||
}
|
||||
|
||||
// Run an action, report errors as a toast, then refresh.
|
||||
async function act(fn) {
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
try {
|
||||
await loadAll();
|
||||
render();
|
||||
} catch { /* login screen already shown */ }
|
||||
}
|
||||
|
||||
// --- Persisted UI prefs -------------------------------------------------------
|
||||
|
||||
function savePrefs() {
|
||||
try { localStorage.setItem('whattodo.view', JSON.stringify({ view: state.view, categoryId: state.categoryId })); } catch { /* ignore */ }
|
||||
}
|
||||
function loadPrefs() {
|
||||
try {
|
||||
const p = JSON.parse(localStorage.getItem('whattodo.view') || 'null');
|
||||
if (p && typeof p.view === 'string') { state.view = p.view; state.categoryId = p.categoryId ?? null; }
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// --- Filtering & grouping -----------------------------------------------------
|
||||
|
||||
const catById = (id) => state.categories.find((c) => c.id === id);
|
||||
|
||||
function matchesSearch(t) {
|
||||
const terms = state.search.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
return terms.every((term) => {
|
||||
if (term.startsWith('#') && term.length > 1) return t.tags.some((tag) => tag.startsWith(term.slice(1)));
|
||||
const cat = catById(t.category_id);
|
||||
const hay = [t.title, t.notes, cat ? cat.name : '', ...t.tags, ...t.subtasks.map((s) => s.title)].join(' ').toLowerCase();
|
||||
return hay.includes(term);
|
||||
});
|
||||
}
|
||||
|
||||
function cmpOpen(a, b) {
|
||||
if (a.due_date !== b.due_date) {
|
||||
if (!a.due_date) return 1;
|
||||
if (!b.due_date) return -1;
|
||||
return a.due_date < b.due_date ? -1 : 1;
|
||||
}
|
||||
return (b.priority - a.priority) || (a.id - b.id);
|
||||
}
|
||||
|
||||
function viewTasks() {
|
||||
let tasks = state.tasks;
|
||||
if (state.view === 'cat') tasks = tasks.filter((t) => (t.category_id || 0) === state.categoryId);
|
||||
if (state.search.trim()) tasks = tasks.filter(matchesSearch);
|
||||
return tasks;
|
||||
}
|
||||
|
||||
function buildGroups(tasks) {
|
||||
const t = todayStr();
|
||||
const tomorrow = addDays(t, 1);
|
||||
const week = addDays(t, 7);
|
||||
if (state.view === 'done') {
|
||||
const done = tasks.filter((x) => x.done).sort((a, b) => (b.done_at || '').localeCompare(a.done_at || ''));
|
||||
return [{ title: 'Completed', tasks: done }];
|
||||
}
|
||||
const open = tasks.filter((x) => !x.done).sort(cmpOpen);
|
||||
const group = (title, pred) => ({ title, tasks: open.filter(pred) });
|
||||
const overdue = (x) => x.due_date && x.due_date < t;
|
||||
const today = (x) => x.due_date === t;
|
||||
if (state.view === 'today') {
|
||||
return [
|
||||
group('Overdue', overdue),
|
||||
group('Today', today),
|
||||
group('High priority', (x) => x.priority === 3 && (!x.due_date || x.due_date > t)),
|
||||
];
|
||||
}
|
||||
if (state.view === 'upcoming') {
|
||||
return [
|
||||
group('Tomorrow', (x) => x.due_date === tomorrow),
|
||||
group('Next 7 days', (x) => x.due_date && x.due_date > tomorrow && x.due_date <= week),
|
||||
group('Later', (x) => x.due_date && x.due_date > week),
|
||||
];
|
||||
}
|
||||
return [
|
||||
group('Overdue', overdue),
|
||||
group('Today', today),
|
||||
group('Next 7 days', (x) => x.due_date && x.due_date > t && x.due_date <= week),
|
||||
group('Later', (x) => x.due_date && x.due_date > week),
|
||||
group('No deadline', (x) => !x.due_date),
|
||||
];
|
||||
}
|
||||
|
||||
function counts() {
|
||||
const t = todayStr();
|
||||
const open = state.tasks.filter((x) => !x.done);
|
||||
const c = {
|
||||
today: open.filter((x) => (x.due_date && x.due_date <= t) || x.priority === 3).length,
|
||||
upcoming: open.filter((x) => x.due_date && x.due_date > t).length,
|
||||
all: open.length,
|
||||
done: null,
|
||||
overdue: open.filter((x) => x.due_date && x.due_date < t).length,
|
||||
cats: {},
|
||||
};
|
||||
for (const x of open) c.cats[x.category_id || 0] = (c.cats[x.category_id || 0] || 0) + 1;
|
||||
return c;
|
||||
}
|
||||
|
||||
// --- Rendering ----------------------------------------------------------------
|
||||
|
||||
function render() {
|
||||
if (state.view === 'cat' && state.categoryId && !catById(state.categoryId)) state.view = 'today';
|
||||
if (state.view !== 'cat' && !VIEWS.some((v) => v.id === state.view)) state.view = 'today';
|
||||
const c = counts();
|
||||
const navItem = (active, icon, label, count, onclick, color, alert) => h('button', {
|
||||
type: 'button', class: 'nav-item' + (active ? ' active' : ''), onclick,
|
||||
},
|
||||
color ? h('span', { class: 'dot', style: { background: color } }) : h('span', { class: 'nav-icon' }, icon),
|
||||
h('span', { class: 'nav-label' }, label),
|
||||
count ? h('span', { class: 'count' + (alert ? ' alert' : '') }, count) : null);
|
||||
|
||||
$('#views').replaceChildren(...VIEWS.map((v) => navItem(
|
||||
state.view === v.id, v.icon, v.label, c[v.id], () => setView(v.id), null, v.id === 'today' && c.overdue > 0,
|
||||
)));
|
||||
|
||||
const catItems = state.categories.map((cat) => navItem(
|
||||
state.view === 'cat' && state.categoryId === cat.id, null, cat.name, c.cats[cat.id], () => setView('cat', cat.id), cat.color,
|
||||
));
|
||||
if (c.cats[0]) {
|
||||
catItems.push(navItem(state.view === 'cat' && state.categoryId === 0, null, 'No category', c.cats[0],
|
||||
() => setView('cat', 0), 'var(--muted)'));
|
||||
}
|
||||
$('#cats').replaceChildren(...catItems);
|
||||
|
||||
let title = (VIEWS.find((v) => v.id === state.view) || {}).label;
|
||||
if (state.view === 'cat') title = state.categoryId ? (catById(state.categoryId) || {}).name : 'No category';
|
||||
$('#view-title').textContent = title || '';
|
||||
document.title = `${title} · WhatToDo`;
|
||||
$('#quick-add').hidden = state.view === 'done';
|
||||
|
||||
const groups = buildGroups(viewTasks()).filter((g) => g.tasks.length);
|
||||
if (!groups.length) {
|
||||
const msg = state.search ? 'No tasks match your search.'
|
||||
: state.view === 'done' ? 'Nothing completed yet.'
|
||||
: state.view === 'today' ? 'Nothing due today. 🎉' : 'No tasks here.';
|
||||
$('#list').replaceChildren(h('div', { class: 'empty' }, msg));
|
||||
return;
|
||||
}
|
||||
$('#list').replaceChildren(...groups.map((g) => h('section', { class: 'group' },
|
||||
h('h3', { class: 'group-title' + (g.title === 'Overdue' ? ' overdue' : '') }, g.title, h('span', { class: 'muted' }, ` ${g.tasks.length}`)),
|
||||
g.tasks.map(taskRow))));
|
||||
}
|
||||
|
||||
function taskRow(task) {
|
||||
const meta = [];
|
||||
const cat = catById(task.category_id);
|
||||
if (cat && state.view !== 'cat') {
|
||||
meta.push(h('span', { class: 'chip' }, h('span', { class: 'dot', style: { background: cat.color } }), cat.name));
|
||||
}
|
||||
if (task.due_date) {
|
||||
const d = dueInfo(task.due_date);
|
||||
meta.push(h('span', { class: `chip due ${task.done ? '' : d.cls}` }, '📅 ', d.text));
|
||||
}
|
||||
if (task.priority) meta.push(h('span', { class: `chip prio-label p${task.priority}` }, '⚑ ', PRIORITY_LABELS[task.priority]));
|
||||
if (task.recurrence !== 'none') {
|
||||
const unit = REPEAT_UNITS[task.recurrence];
|
||||
meta.push(h('span', { class: 'chip' }, '↻ ', task.recur_interval > 1 ? `every ${task.recur_interval} ${unit}s` : `every ${unit}`));
|
||||
}
|
||||
if (task.subtasks.length) {
|
||||
const doneCount = task.subtasks.filter((s) => s.done).length;
|
||||
meta.push(h('span', { class: 'chip' }, `☑ ${doneCount}/${task.subtasks.length}`));
|
||||
}
|
||||
if (task.notes.trim()) meta.push(h('span', { class: 'chip', title: 'Has notes' }, '📝'));
|
||||
for (const tag of task.tags) {
|
||||
meta.push(h('button', {
|
||||
type: 'button', class: 'chip tag',
|
||||
onclick: (e) => { e.stopPropagation(); setSearch(`#${tag}`); },
|
||||
}, `#${tag}`));
|
||||
}
|
||||
|
||||
return h('div', {
|
||||
class: `task p${task.priority}` + (task.done ? ' done' : ''),
|
||||
tabindex: '0',
|
||||
onclick: () => openTask(task),
|
||||
onkeydown: (e) => { if (e.key === 'Enter' && e.target === e.currentTarget) openTask(task); },
|
||||
},
|
||||
h('button', {
|
||||
type: 'button', class: 'check', 'aria-label': task.done ? 'Mark as not done' : 'Mark as done',
|
||||
onclick: (e) => { e.stopPropagation(); toggleDone(task); },
|
||||
}, task.done ? '✓' : ''),
|
||||
h('div', { class: 'task-body' },
|
||||
h('div', { class: 'task-title' }, task.title),
|
||||
meta.length ? h('div', { class: 'task-meta' }, meta) : null));
|
||||
}
|
||||
|
||||
// --- Actions ------------------------------------------------------------------
|
||||
|
||||
function setView(view, categoryId = null) {
|
||||
state.view = view;
|
||||
state.categoryId = categoryId;
|
||||
savePrefs();
|
||||
document.body.classList.remove('nav-open');
|
||||
render();
|
||||
$('#list').scrollTop = 0;
|
||||
}
|
||||
|
||||
function setSearch(text) {
|
||||
state.search = text;
|
||||
$('#search').value = text;
|
||||
render();
|
||||
}
|
||||
|
||||
function toggleDone(task) {
|
||||
if (task.done) {
|
||||
act(() => api(`api/tasks/${task.id}/reopen`, { method: 'POST' }));
|
||||
return;
|
||||
}
|
||||
act(async () => {
|
||||
const res = await api(`api/tasks/${task.id}/complete`, { method: 'POST', body: { today: todayStr() } });
|
||||
const extra = res.next ? ` Next one: ${dueInfo(res.next.due_date).text}.` : '';
|
||||
toast(`Done: ${task.title}.${extra}`, {
|
||||
label: 'Undo',
|
||||
fn: () => act(async () => {
|
||||
if (res.next) await api(`api/tasks/${res.next.id}`, { method: 'DELETE' });
|
||||
await api(`api/tasks/${task.id}/reopen`, { method: 'POST' });
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function quickAdd(title) {
|
||||
const body = { title };
|
||||
if (state.view === 'cat' && state.categoryId) body.category_id = state.categoryId;
|
||||
if (state.view === 'today') body.due_date = todayStr();
|
||||
if (state.view === 'upcoming') body.due_date = addDays(todayStr(), 1);
|
||||
act(() => api('api/tasks', { method: 'POST', body }));
|
||||
}
|
||||
|
||||
function toast(message, action) {
|
||||
const el = $('#toast');
|
||||
el.replaceChildren(h('span', {}, message), action ? h('button', {
|
||||
type: 'button', class: 'link',
|
||||
onclick: () => { el.hidden = true; action.fn(); },
|
||||
}, action.label) : null);
|
||||
el.hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { el.hidden = true; }, action ? 6000 : 4000);
|
||||
}
|
||||
|
||||
// --- Task dialog --------------------------------------------------------------
|
||||
|
||||
function fillCategorySelect(select, selected) {
|
||||
select.replaceChildren(
|
||||
h('option', { value: '' }, '— None —'),
|
||||
...state.categories.map((c) => h('option', { value: String(c.id) }, c.name)),
|
||||
);
|
||||
select.value = selected ? String(selected) : '';
|
||||
}
|
||||
|
||||
function updateIntervalVisibility() {
|
||||
$('#t-interval-wrap').hidden = $('#t-repeat').value === 'none';
|
||||
}
|
||||
|
||||
function openTask(task, defaults = {}) {
|
||||
editing = task;
|
||||
const t = task || {
|
||||
title: '', notes: '', category_id: null, priority: 0, due_date: null,
|
||||
tags: [], recurrence: 'none', recur_interval: 1, subtasks: [], ...defaults,
|
||||
};
|
||||
$('#task-dialog-title').textContent = task ? 'Edit task' : 'New task';
|
||||
$('#t-title').value = t.title;
|
||||
fillCategorySelect($('#t-category'), t.category_id);
|
||||
$('#t-priority').value = String(t.priority);
|
||||
$('#t-due').value = t.due_date || '';
|
||||
$('#t-repeat').value = t.recurrence;
|
||||
$('#t-interval').value = t.recur_interval;
|
||||
$('#t-tags').value = t.tags.join(', ');
|
||||
$('#t-notes').value = t.notes;
|
||||
$('#t-subtask-new').value = '';
|
||||
$('#t-delete').hidden = !task;
|
||||
draftSubtasks = t.subtasks.map((s) => ({ title: s.title, done: s.done }));
|
||||
renderDraftSubtasks();
|
||||
updateIntervalVisibility();
|
||||
$('#task-dialog').showModal();
|
||||
if (!task) $('#t-title').focus();
|
||||
}
|
||||
|
||||
function renderDraftSubtasks() {
|
||||
$('#t-subtasks').replaceChildren(...draftSubtasks.map((s, i) => h('li', { class: 'subtask' },
|
||||
h('input', { type: 'checkbox', checked: s.done, 'aria-label': 'Done', onchange: (e) => { s.done = e.target.checked; } }),
|
||||
h('input', { type: 'text', value: s.title, maxlength: '500', oninput: (e) => { s.title = e.target.value; } }),
|
||||
h('button', {
|
||||
type: 'button', class: 'icon-btn', 'aria-label': 'Remove step',
|
||||
onclick: () => { draftSubtasks.splice(i, 1); renderDraftSubtasks(); },
|
||||
}, '✕'))));
|
||||
}
|
||||
|
||||
function addDraftSubtask() {
|
||||
const input = $('#t-subtask-new');
|
||||
const title = input.value.trim();
|
||||
if (!title) return;
|
||||
draftSubtasks.push({ title, done: false });
|
||||
input.value = '';
|
||||
renderDraftSubtasks();
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function saveTask(e) {
|
||||
e.preventDefault();
|
||||
addDraftSubtask();
|
||||
const body = {
|
||||
title: $('#t-title').value.trim(),
|
||||
category_id: $('#t-category').value ? Number($('#t-category').value) : null,
|
||||
priority: Number($('#t-priority').value),
|
||||
due_date: $('#t-due').value || null,
|
||||
recurrence: $('#t-repeat').value,
|
||||
recur_interval: Math.max(1, Number($('#t-interval').value) || 1),
|
||||
tags: $('#t-tags').value.split(',').map((s) => s.trim()).filter(Boolean),
|
||||
notes: $('#t-notes').value,
|
||||
subtasks: draftSubtasks.map((s) => ({ title: s.title.trim(), done: s.done })).filter((s) => s.title),
|
||||
};
|
||||
if (!body.title) return;
|
||||
const task = editing;
|
||||
$('#task-dialog').close();
|
||||
act(() => (task
|
||||
? api(`api/tasks/${task.id}`, { method: 'PATCH', body })
|
||||
: api('api/tasks', { method: 'POST', body })));
|
||||
}
|
||||
|
||||
function deleteTask() {
|
||||
const task = editing;
|
||||
if (!task || !confirm(`Delete "${task.title}"?`)) return;
|
||||
$('#task-dialog').close();
|
||||
act(() => api(`api/tasks/${task.id}`, { method: 'DELETE' }));
|
||||
}
|
||||
|
||||
// --- Settings dialog ----------------------------------------------------------
|
||||
|
||||
function renderSettingsCategories() {
|
||||
const cats = state.categories;
|
||||
const patch = (cat, body) => act(async () => { await api(`api/categories/${cat.id}`, { method: 'PATCH', body }); })
|
||||
.then(renderSettingsCategories);
|
||||
const move = (i, dir) => {
|
||||
const other = cats[i + dir];
|
||||
if (!other) return;
|
||||
const order = cats.map((c) => c.id);
|
||||
[order[i], order[i + dir]] = [order[i + dir], order[i]];
|
||||
act(() => Promise.all(order.map((id, pos) => api(`api/categories/${id}`, { method: 'PATCH', body: { position: pos } }))))
|
||||
.then(renderSettingsCategories);
|
||||
};
|
||||
$('#s-cats').replaceChildren(...cats.map((cat, i) => h('li', { class: 'cat-edit' },
|
||||
h('input', { type: 'color', value: cat.color, 'aria-label': 'Color', onchange: (e) => patch(cat, { color: e.target.value }) }),
|
||||
h('input', {
|
||||
type: 'text', value: cat.name, maxlength: '50', 'aria-label': 'Name',
|
||||
onchange: (e) => { const name = e.target.value.trim(); if (name) patch(cat, { name }); },
|
||||
}),
|
||||
h('button', { type: 'button', class: 'icon-btn', 'aria-label': 'Move up', disabled: i === 0, onclick: () => move(i, -1) }, '↑'),
|
||||
h('button', { type: 'button', class: 'icon-btn', 'aria-label': 'Move down', disabled: i === cats.length - 1, onclick: () => move(i, 1) }, '↓'),
|
||||
h('button', {
|
||||
type: 'button', class: 'icon-btn danger', 'aria-label': 'Delete category',
|
||||
onclick: () => {
|
||||
if (!confirm(`Delete category "${cat.name}"? Its tasks are kept but will have no category.`)) return;
|
||||
act(() => api(`api/categories/${cat.id}`, { method: 'DELETE' })).then(renderSettingsCategories);
|
||||
},
|
||||
}, '🗑'))));
|
||||
}
|
||||
|
||||
async function importFile() {
|
||||
const file = $('#s-import-file').files[0];
|
||||
if (!file) { toast('Choose an export file first.'); return; }
|
||||
const mode = $('#s-import-mode').value;
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(await file.text());
|
||||
} catch {
|
||||
toast('That file is not valid JSON.');
|
||||
return;
|
||||
}
|
||||
const n = Array.isArray(data.tasks) ? data.tasks.length : 0;
|
||||
const warning = mode === 'replace'
|
||||
? `REPLACE: this deletes ALL current tasks and categories and loads ${n} tasks from the file. Continue?`
|
||||
: `MERGE: add ${n} tasks from the file to your current data. Continue?`;
|
||||
if (!confirm(warning)) return;
|
||||
await act(async () => {
|
||||
const res = await api(`api/import?mode=${mode}`, { method: 'POST', body: data });
|
||||
toast(`Imported ${res.tasks} tasks and ${res.categories} categories (${res.mode}).`);
|
||||
});
|
||||
$('#s-import-file').value = '';
|
||||
renderSettingsCategories();
|
||||
}
|
||||
|
||||
// --- Login --------------------------------------------------------------------
|
||||
|
||||
function showLogin() {
|
||||
$('#app').hidden = true;
|
||||
$('#login').hidden = false;
|
||||
for (const d of document.querySelectorAll('dialog[open]')) d.close();
|
||||
$('#login-password').focus();
|
||||
}
|
||||
|
||||
async function showApp() {
|
||||
await loadAll();
|
||||
$('#login').hidden = true;
|
||||
$('#app').hidden = false;
|
||||
render();
|
||||
}
|
||||
|
||||
// --- Wiring -------------------------------------------------------------------
|
||||
|
||||
function bind() {
|
||||
$('#login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const err = $('#login-error');
|
||||
err.hidden = true;
|
||||
try {
|
||||
await api('api/login', { method: 'POST', body: { password: $('#login-password').value } });
|
||||
$('#login-password').value = '';
|
||||
await showApp();
|
||||
} catch (ex) {
|
||||
err.textContent = ex.message;
|
||||
err.hidden = false;
|
||||
}
|
||||
});
|
||||
|
||||
$('#quick-add').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const title = $('#quick-title').value.trim();
|
||||
if (!title) return;
|
||||
$('#quick-title').value = '';
|
||||
quickAdd(title);
|
||||
});
|
||||
$('#new-detailed').addEventListener('click', () => {
|
||||
const defaults = {};
|
||||
if (state.view === 'cat' && state.categoryId) defaults.category_id = state.categoryId;
|
||||
if (state.view === 'today') defaults.due_date = todayStr();
|
||||
defaults.title = $('#quick-title').value.trim();
|
||||
$('#quick-title').value = '';
|
||||
openTask(null, defaults);
|
||||
});
|
||||
|
||||
$('#search').addEventListener('input', (e) => { state.search = e.target.value; render(); });
|
||||
|
||||
$('#menu-btn').addEventListener('click', () => document.body.classList.add('nav-open'));
|
||||
$('#scrim').addEventListener('click', () => document.body.classList.remove('nav-open'));
|
||||
|
||||
// Task dialog
|
||||
$('#task-form').addEventListener('submit', saveTask);
|
||||
$('#t-cancel').addEventListener('click', () => $('#task-dialog').close());
|
||||
$('#t-delete').addEventListener('click', deleteTask);
|
||||
$('#t-repeat').addEventListener('change', updateIntervalVisibility);
|
||||
$('#t-subtask-add').addEventListener('click', addDraftSubtask);
|
||||
$('#t-subtask-new').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); addDraftSubtask(); }
|
||||
});
|
||||
for (const btn of document.querySelectorAll('.quick-dates .chip-btn')) {
|
||||
btn.addEventListener('click', () => {
|
||||
$('#t-due').value = btn.dataset.days === '' ? '' : addDays(todayStr(), Number(btn.dataset.days));
|
||||
});
|
||||
}
|
||||
|
||||
// Settings dialog
|
||||
$('#settings-btn').addEventListener('click', () => {
|
||||
document.body.classList.remove('nav-open');
|
||||
renderSettingsCategories();
|
||||
$('#settings-dialog').showModal();
|
||||
});
|
||||
$('#s-close').addEventListener('click', () => $('#settings-dialog').close());
|
||||
$('#s-cat-add').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const name = $('#s-cat-name').value.trim();
|
||||
if (!name) return;
|
||||
act(() => api('api/categories', { method: 'POST', body: { name, color: $('#s-cat-color').value } }))
|
||||
.then(() => { $('#s-cat-name').value = ''; renderSettingsCategories(); });
|
||||
});
|
||||
$('#s-import-btn').addEventListener('click', importFile);
|
||||
$('#s-logout').addEventListener('click', async () => {
|
||||
try { await api('api/logout', { method: 'POST' }); } catch { /* ignore */ }
|
||||
$('#settings-dialog').close();
|
||||
showLogin();
|
||||
});
|
||||
|
||||
// Clicking the backdrop closes a dialog.
|
||||
for (const dlg of document.querySelectorAll('dialog')) {
|
||||
dlg.addEventListener('click', (e) => { if (e.target === dlg) dlg.close(); });
|
||||
}
|
||||
|
||||
// Keyboard shortcuts: "/" search, "n" new task.
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (document.querySelector('dialog[open]') || $('#app').hidden) return;
|
||||
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) return;
|
||||
if (e.key === '/') { e.preventDefault(); $('#search').focus(); }
|
||||
if (e.key === 'n') { e.preventDefault(); $('#quick-title').focus(); }
|
||||
});
|
||||
|
||||
// Refresh when returning to the app (e.g. phone unlocked, day changed).
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible' && !$('#app').hidden) act(async () => {});
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
loadPrefs();
|
||||
bind();
|
||||
if ('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(() => {});
|
||||
try {
|
||||
await showApp();
|
||||
} catch (e) {
|
||||
// A 401 already switched to the login screen; anything else is worth reporting.
|
||||
if ($('#login').hidden) toast(`Could not load: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="22" fill="#4f46e5"/>
|
||||
<path d="M28 53 L44 68 L73 36" fill="none" stroke="#fff" stroke-width="9" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1,172 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#4f46e5">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="WhatToDo">
|
||||
<title>WhatToDo</title>
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="icon" href="icons/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="icons/icon-180.png">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script src="app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="login" class="login" hidden>
|
||||
<form id="login-form" class="login-card">
|
||||
<img src="icons/icon.svg" alt="" width="56" height="56">
|
||||
<h1>WhatToDo</h1>
|
||||
<input type="password" id="login-password" placeholder="Password" autocomplete="current-password" required>
|
||||
<button type="submit" class="btn primary">Log in</button>
|
||||
<p id="login-error" class="error" hidden></p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="app" class="app" hidden>
|
||||
<aside id="sidebar" class="sidebar">
|
||||
<div class="brand"><img src="icons/icon.svg" alt="" width="28" height="28"> WhatToDo</div>
|
||||
<nav id="views" class="nav"></nav>
|
||||
<div class="nav-heading">Categories</div>
|
||||
<nav id="cats" class="nav"></nav>
|
||||
<div class="sidebar-foot">
|
||||
<button id="settings-btn" class="nav-item" type="button"><span class="nav-icon">⚙️</span> Settings</button>
|
||||
</div>
|
||||
</aside>
|
||||
<div id="scrim" class="scrim"></div>
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<button id="menu-btn" class="icon-btn menu-btn" type="button" aria-label="Open menu">☰</button>
|
||||
<h2 id="view-title"></h2>
|
||||
<input id="search" type="search" placeholder="Search or #tag" autocomplete="off">
|
||||
</header>
|
||||
<form id="quick-add" class="quick-add">
|
||||
<input id="quick-title" placeholder="Add a task…" autocomplete="off" maxlength="500">
|
||||
<button class="btn primary" type="submit">Add</button>
|
||||
<button id="new-detailed" class="btn" type="button" title="New task with details">+ Details</button>
|
||||
</form>
|
||||
<div id="list" class="list"></div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<dialog id="task-dialog">
|
||||
<form id="task-form" class="dialog-body">
|
||||
<h3 id="task-dialog-title">Task</h3>
|
||||
<label class="field">
|
||||
<span>Title</span>
|
||||
<input id="t-title" required maxlength="500" autocomplete="off">
|
||||
</label>
|
||||
<div class="row">
|
||||
<label class="field">
|
||||
<span>Category</span>
|
||||
<select id="t-category"></select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Priority</span>
|
||||
<select id="t-priority">
|
||||
<option value="0">None</option>
|
||||
<option value="1">Low</option>
|
||||
<option value="2">Medium</option>
|
||||
<option value="3">High</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="field">
|
||||
<span>Deadline</span>
|
||||
<input id="t-due" type="date">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Repeat</span>
|
||||
<select id="t-repeat">
|
||||
<option value="none">Never</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field narrow" id="t-interval-wrap">
|
||||
<span>Every</span>
|
||||
<input id="t-interval" type="number" min="1" max="365" value="1">
|
||||
</label>
|
||||
</div>
|
||||
<div class="quick-dates">
|
||||
<button type="button" class="chip-btn" data-days="0">Today</button>
|
||||
<button type="button" class="chip-btn" data-days="1">Tomorrow</button>
|
||||
<button type="button" class="chip-btn" data-days="7">Next week</button>
|
||||
<button type="button" class="chip-btn" data-days="">No date</button>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>Tags <small>(comma separated)</small></span>
|
||||
<input id="t-tags" placeholder="e.g. editing, urgent" autocomplete="off">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Notes</span>
|
||||
<textarea id="t-notes" rows="4" maxlength="20000"></textarea>
|
||||
</label>
|
||||
<div class="field">
|
||||
<span>Checklist</span>
|
||||
<ul id="t-subtasks" class="subtasks"></ul>
|
||||
<div class="subtask-add">
|
||||
<input id="t-subtask-new" placeholder="Add a step and press Enter" autocomplete="off" maxlength="500">
|
||||
<button id="t-subtask-add" type="button" class="btn">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<button id="t-delete" type="button" class="btn danger">Delete</button>
|
||||
<span class="spacer"></span>
|
||||
<button id="t-cancel" type="button" class="btn">Cancel</button>
|
||||
<button type="submit" class="btn primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="settings-dialog">
|
||||
<div class="dialog-body">
|
||||
<h3>Settings</h3>
|
||||
|
||||
<section class="settings-section">
|
||||
<h4>Categories</h4>
|
||||
<ul id="s-cats" class="cat-edit-list"></ul>
|
||||
<form id="s-cat-add" class="cat-add">
|
||||
<input id="s-cat-color" type="color" value="#0ea5e9" aria-label="Color">
|
||||
<input id="s-cat-name" placeholder="New category" maxlength="50" required autocomplete="off">
|
||||
<button class="btn" type="submit">Add</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h4>Export / import</h4>
|
||||
<p class="muted">Export downloads all tasks and categories as a JSON file. Import it on another
|
||||
install, such as production, to move your data there.</p>
|
||||
<div class="row wrap">
|
||||
<a id="s-export" class="btn primary" href="api/export" download>Export data</a>
|
||||
</div>
|
||||
<div class="row wrap import-row">
|
||||
<input id="s-import-file" type="file" accept="application/json,.json">
|
||||
<select id="s-import-mode" aria-label="Import mode">
|
||||
<option value="merge">Merge (add to current data)</option>
|
||||
<option value="replace">Replace (delete current data first)</option>
|
||||
</select>
|
||||
<button id="s-import-btn" class="btn" type="button">Import</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h4>Session</h4>
|
||||
<button id="s-logout" class="btn" type="button">Log out</button>
|
||||
</section>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<span class="spacer"></span>
|
||||
<button id="s-close" class="btn primary" type="button">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<div id="toast" class="toast" hidden></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "WhatToDo",
|
||||
"short_name": "WhatToDo",
|
||||
"description": "Personal task list",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#f5f6fa",
|
||||
"theme_color": "#4f46e5",
|
||||
"icons": [
|
||||
{ "src": "icons/icon.svg", "sizes": "any", "type": "image/svg+xml" },
|
||||
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
:root {
|
||||
--bg: #f5f6fa;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f0f2f7;
|
||||
--text: #1d2130;
|
||||
--muted: #6b7183;
|
||||
--border: #e2e5ee;
|
||||
--accent: #4f46e5;
|
||||
--accent-text: #ffffff;
|
||||
--accent-weak: #ecedff;
|
||||
--danger: #dc2626;
|
||||
--overdue: #dc2626;
|
||||
--today: #d97706;
|
||||
--ok: #16a34a;
|
||||
--p1: #60a5fa;
|
||||
--p2: #f59e0b;
|
||||
--p3: #ef4444;
|
||||
--radius: 10px;
|
||||
--shadow: 0 1px 2px rgba(16, 24, 40, 0.06);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #111319;
|
||||
--panel: #1a1d26;
|
||||
--panel-2: #222633;
|
||||
--text: #e6e8ef;
|
||||
--muted: #9399ad;
|
||||
--border: #2c303d;
|
||||
--accent: #818cf8;
|
||||
--accent-text: #0f1020;
|
||||
--accent-weak: #262a45;
|
||||
--danger: #f87171;
|
||||
--overdue: #f87171;
|
||||
--today: #fbbf24;
|
||||
--ok: #4ade80;
|
||||
--shadow: none;
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
input, select, textarea {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 9px 11px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: transparent; }
|
||||
input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--accent); flex: none; }
|
||||
input[type="color"] { width: 40px; height: 38px; padding: 3px; flex: none; cursor: pointer; }
|
||||
textarea { resize: vertical; }
|
||||
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 9px 14px; border-radius: 8px; border: 1px solid var(--border);
|
||||
background: var(--panel); cursor: pointer; text-decoration: none; white-space: nowrap;
|
||||
}
|
||||
.btn:hover { background: var(--panel-2); }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
||||
.btn.primary:hover { filter: brightness(1.08); }
|
||||
.btn.danger { color: var(--danger); }
|
||||
.icon-btn {
|
||||
background: none; border: none; cursor: pointer; padding: 6px 8px; border-radius: 6px;
|
||||
color: var(--muted); flex: none;
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) { background: var(--panel-2); color: var(--text); }
|
||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.icon-btn.danger:hover { color: var(--danger); }
|
||||
.link { background: none; border: none; color: var(--accent); font-weight: 600; cursor: pointer; padding: 0 4px; }
|
||||
.muted { color: var(--muted); }
|
||||
.error { color: var(--danger); margin: 0; }
|
||||
|
||||
/* Login */
|
||||
.login { min-height: 100%; display: grid; place-items: center; padding: 16px; }
|
||||
.login-card {
|
||||
width: 100%; max-width: 340px; background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 16px; padding: 28px; display: flex; flex-direction: column; gap: 12px;
|
||||
align-items: center; box-shadow: var(--shadow);
|
||||
}
|
||||
.login-card h1 { margin: 0 0 8px; font-size: 22px; }
|
||||
.login-card .btn { width: 100%; }
|
||||
|
||||
/* App layout */
|
||||
.app { display: grid; grid-template-columns: 250px 1fr; height: 100dvh; }
|
||||
.sidebar {
|
||||
background: var(--panel); border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column; padding: 16px 10px; overflow-y: auto;
|
||||
padding-top: max(16px, env(safe-area-inset-top));
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 18px; padding: 4px 10px 16px; }
|
||||
.nav { display: flex; flex-direction: column; gap: 2px; }
|
||||
.nav-heading { font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); padding: 18px 10px 6px; }
|
||||
.nav-item {
|
||||
display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
|
||||
background: none; border: none; border-radius: 8px; padding: 8px 10px; cursor: pointer;
|
||||
}
|
||||
.nav-item:hover { background: var(--panel-2); }
|
||||
.nav-item.active { background: var(--accent-weak); color: var(--accent); font-weight: 600; }
|
||||
.nav-icon { width: 20px; text-align: center; }
|
||||
.nav-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.count { font-size: 12px; color: var(--muted); background: var(--panel-2); border-radius: 999px; padding: 1px 8px; }
|
||||
.count.alert { background: var(--overdue); color: #fff; }
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex: none; margin: 0 5px; }
|
||||
.chip .dot { width: 8px; height: 8px; margin: 0 2px 0 0; }
|
||||
.sidebar-foot { margin-top: auto; padding-top: 16px; }
|
||||
.scrim { display: none; }
|
||||
|
||||
.main { display: flex; flex-direction: column; min-width: 0; height: 100dvh; }
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 12px; padding: 16px 24px 8px;
|
||||
padding-top: max(16px, env(safe-area-inset-top));
|
||||
}
|
||||
.topbar h2 { margin: 0; font-size: 22px; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.topbar input[type="search"] { max-width: 260px; }
|
||||
.menu-btn { display: none; font-size: 20px; color: var(--text); }
|
||||
|
||||
.quick-add { display: flex; gap: 8px; padding: 8px 24px 12px; }
|
||||
.list { flex: 1; overflow-y: auto; padding: 0 24px 40px; padding-bottom: max(40px, env(safe-area-inset-bottom)); }
|
||||
|
||||
.group { margin-top: 14px; }
|
||||
.group-title { font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 8px; color: var(--muted); }
|
||||
.group-title.overdue { color: var(--overdue); }
|
||||
|
||||
.task {
|
||||
display: flex; gap: 12px; align-items: flex-start;
|
||||
background: var(--panel); border: 1px solid var(--border); border-left: 4px solid var(--border);
|
||||
border-radius: var(--radius); padding: 11px 12px; margin-bottom: 8px; cursor: pointer; box-shadow: var(--shadow);
|
||||
}
|
||||
.task:hover { border-color: var(--accent); }
|
||||
.task:focus-visible { outline: 2px solid var(--accent); }
|
||||
.task.p1 { border-left-color: var(--p1); }
|
||||
.task.p2 { border-left-color: var(--p2); }
|
||||
.task.p3 { border-left-color: var(--p3); }
|
||||
.check {
|
||||
flex: none; width: 24px; height: 24px; margin-top: 1px; border-radius: 50%;
|
||||
border: 2px solid var(--muted); background: none; cursor: pointer; padding: 0;
|
||||
color: #fff; font-size: 14px; line-height: 1; display: grid; place-items: center;
|
||||
}
|
||||
.check:hover { border-color: var(--ok); background: color-mix(in srgb, var(--ok) 15%, transparent); }
|
||||
.task.done .check { background: var(--ok); border-color: var(--ok); }
|
||||
.task.done .task-title { text-decoration: line-through; color: var(--muted); }
|
||||
.task-body { flex: 1; min-width: 0; }
|
||||
.task-title { overflow-wrap: anywhere; }
|
||||
.task-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; font-size: 12px; color: var(--muted);
|
||||
background: var(--panel-2); border: none; border-radius: 999px; padding: 2px 8px; white-space: nowrap;
|
||||
}
|
||||
.chip.tag { cursor: pointer; color: var(--accent); }
|
||||
.chip.tag:hover { background: var(--accent-weak); }
|
||||
.chip.due.overdue { color: #fff; background: var(--overdue); }
|
||||
.chip.due.today { color: var(--today); font-weight: 600; }
|
||||
.chip.p3 { color: var(--p3); }
|
||||
.chip.p2 { color: var(--p2); }
|
||||
.empty { text-align: center; color: var(--muted); padding: 60px 16px; font-size: 16px; }
|
||||
|
||||
/* Dialogs */
|
||||
dialog {
|
||||
border: none; border-radius: 14px; padding: 0; width: min(560px, calc(100vw - 24px));
|
||||
max-height: calc(100dvh - 24px); background: var(--panel); color: var(--text);
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
dialog::backdrop { background: rgba(10, 12, 20, 0.5); }
|
||||
.dialog-body { display: flex; flex-direction: column; gap: 14px; padding: 20px; }
|
||||
.dialog-body h3 { margin: 0; font-size: 19px; }
|
||||
.field { display: flex; flex-direction: column; gap: 5px; flex: 1; min-width: 0; }
|
||||
.field > span { font-size: 13px; color: var(--muted); font-weight: 600; }
|
||||
.field.narrow { flex: 0 0 80px; }
|
||||
.row { display: flex; gap: 12px; }
|
||||
.row.wrap { flex-wrap: wrap; align-items: center; }
|
||||
.quick-dates { display: flex; flex-wrap: wrap; gap: 6px; margin-top: -6px; }
|
||||
.chip-btn {
|
||||
font-size: 13px; background: var(--panel-2); border: 1px solid var(--border);
|
||||
border-radius: 999px; padding: 4px 10px; cursor: pointer;
|
||||
}
|
||||
.chip-btn:hover { border-color: var(--accent); }
|
||||
.subtasks { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.subtask { display: flex; align-items: center; gap: 8px; }
|
||||
.subtask input[type="text"] { padding: 6px 9px; }
|
||||
.subtask-add { display: flex; gap: 8px; }
|
||||
.dialog-actions { display: flex; gap: 8px; align-items: center; padding-top: 4px; }
|
||||
.spacer { flex: 1; }
|
||||
|
||||
.settings-section { border-top: 1px solid var(--border); padding-top: 12px; }
|
||||
.settings-section h4 { margin: 0 0 10px; }
|
||||
.settings-section p { margin: 0 0 10px; font-size: 14px; }
|
||||
.cat-edit-list { list-style: none; margin: 0 0 10px; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.cat-edit, .cat-add { display: flex; align-items: center; gap: 6px; }
|
||||
.import-row { margin-top: 10px; }
|
||||
.import-row input[type="file"] { flex: 1 1 200px; padding: 6px; }
|
||||
.import-row select { flex: 1 1 200px; width: auto; }
|
||||
|
||||
.toast {
|
||||
position: fixed; left: 50%; transform: translateX(-50%);
|
||||
bottom: max(20px, env(safe-area-inset-bottom)); z-index: 50;
|
||||
background: #1d2130; color: #fff; padding: 10px 16px; border-radius: 10px;
|
||||
display: flex; gap: 12px; align-items: center; max-width: calc(100vw - 32px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.toast .link { color: #a5b4fc; }
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 800px) {
|
||||
.app { grid-template-columns: 1fr; }
|
||||
.sidebar {
|
||||
position: fixed; inset: 0 auto 0 0; width: 280px; max-width: 85vw; z-index: 30;
|
||||
transform: translateX(-100%); transition: transform 0.2s ease;
|
||||
}
|
||||
body.nav-open .sidebar { transform: none; box-shadow: 0 0 40px rgba(0, 0, 0, 0.3); }
|
||||
body.nav-open .scrim { display: block; position: fixed; inset: 0; z-index: 20; background: rgba(10, 12, 20, 0.4); }
|
||||
.menu-btn { display: block; }
|
||||
.topbar { padding: 12px 16px 6px; padding-top: max(12px, env(safe-area-inset-top)); flex-wrap: wrap; }
|
||||
.topbar h2 { font-size: 20px; }
|
||||
.topbar input[type="search"] { max-width: none; flex-basis: 100%; order: 3; }
|
||||
.quick-add { padding: 6px 16px 10px; }
|
||||
#new-detailed { padding: 9px 10px; }
|
||||
.list { padding: 0 16px 40px; }
|
||||
.row { flex-wrap: wrap; }
|
||||
.row .field { flex: 1 1 140px; }
|
||||
.row .field.narrow { flex: 0 0 80px; }
|
||||
.dialog-body { padding: 16px; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Network-first service worker: always fresh when online, app shell still opens offline.
|
||||
const CACHE = 'whattodo-v1';
|
||||
const SHELL = [
|
||||
'./', 'index.html', 'style.css', 'app.js', 'manifest.webmanifest',
|
||||
'icons/icon.svg', 'icons/icon-192.png', 'icons/icon-512.png',
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const req = event.request;
|
||||
const url = new URL(req.url);
|
||||
if (req.method !== 'GET' || url.origin !== self.location.origin || url.pathname.includes('/api/')) return;
|
||||
event.respondWith(
|
||||
fetch(req)
|
||||
.then((res) => {
|
||||
if (res.ok) {
|
||||
const copy = res.clone();
|
||||
caches.open(CACHE).then((c) => c.put(req, copy));
|
||||
}
|
||||
return res;
|
||||
})
|
||||
.catch(() => caches.match(req).then((hit) => hit || caches.match('index.html'))),
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user