<?php
declare(strict_types=1);
/*
UX upgrades (fully implemented)
- Inline edits: Enter commits, Esc cancels and reverts, Tab moves to next, Shift+Tab to prev.
- Title: Enter saves, Esc cancels.
- Keyboard on list: Space toggles, Delete removes, E starts edit for focused item.
- Drag-and-drop reorder (disabled while filtering/searching); persists via new "reorder" API op.
- Filters & search: All/Active/Completed + text search; persists in localStorage.
- Bulk ops: "Complete all"/"Uncheck all", "Clear completed", "Clear all" (with confirm).
- Undo for delete and clear completed (10s window).
- Recent boards history with datalist for quick switching.
- Accessibility and feedback: aria-live, counts, last updated, consistent flash messages.
- PWA retained with single-file assets; safer limits and headers; ETag + Last-Modified + 304 for fetch.
*/
/////////////////////////////
// Security & headers
/////////////////////////////
header('X-Content-Type-Options: nosniff');
$isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https');
if ($isHttps) {
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
}
/////////////////////////////
// Config and helpers
/////////////////////////////
$scheme = ($isHttps ? 'https' : 'http');
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$origin = $scheme . '://' . $host;
$dataDir = realpath(__DIR__ . '/..') ? (realpath(__DIR__ . '/..') . '/data') : (__DIR__ . '/../data');
if (!is_dir($dataDir)) {
@mkdir($dataDir, 0755, true);
}
const MAX_TEXT_LEN = 2000;
const MAX_TITLE_LEN = 200;
function id(): string {
return rtrim(strtr(base64_encode(random_bytes(6)), '+/', '-_'), '=');
}
function safeSlug(string $b): string {
$b = preg_replace('/[^a-zA-Z0-9_-]/', '_', $b);
$b = substr($b, 0, 64);
return $b !== '' ? $b : 'public';
}
function pathOf(string $b): string {
return $GLOBALS['dataDir'] . '/' . safeSlug($b) . '.json';
}
function emptyBoard(): array {
$t = time();
return ['title' => 'My Todo', 'tasks' => [], 'created' => $t, 'updated' => $t];
}
function loadBoard(string $b): array {
$f = pathOf($b);
if (!is_file($f)) return emptyBoard();
$raw = file_get_contents($f);
$j = $raw ? json_decode($raw, true) : null;
return is_array($j) ? $j : emptyBoard();
}
function saveBoard(string $b, array $d): void {
$f = pathOf($b);
$d['updated'] = time();
$h = fopen($f, 'c+');
if ($h) {
flock($h, LOCK_EX);
ftruncate($h, 0);
fwrite($h, json_encode($d, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
fflush($h);
flock($h, LOCK_UN);
fclose($h);
@chmod($f, 0644);
}
}
function emitJSON($x, array $headers = []): never {
foreach ($headers as $k => $v) header("$k: $v");
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-cache, must-revalidate');
echo json_encode($x, JSON_UNESCAPED_UNICODE);
exit;
}
function notModified(): never {
header('HTTP/1.1 304 Not Modified');
exit;
}
function htmlHeader(): void {
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: no-cache');
}
/////////////////////////////
// Asset router (manifest, sw, icons, favicon)
/////////////////////////////
if (isset($_GET['asset'])) {
$asset = $_GET['asset'];
if ($asset === 'manifest') {
header('Content-Type: application/manifest+json; charset=utf-8');
header('Cache-Control: public, max-age=3600');
// Minimal base64 1x1 PNG
$icon1x1 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=';
echo json_encode([
'name' => 'memor.ia.br — Todos',
'short_name' => 'memor',
'start_url' => '/?b=public',
'display' => 'standalone',
'background_color' => '#ffffff',
'theme_color' => '#111111',
'icons' => [
['src' => $icon1x1, 'sizes' => '192x192', 'type' => 'image/png'],
['src' => $icon1x1, 'sizes' => '512x512', 'type' => 'image/png'],
['src' => $origin . '/?asset=favicon', 'sizes' => 'any', 'type' => 'image/svg+xml', 'purpose' => 'any maskable'],
],
'scope' => '/',
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
if ($asset === 'sw') {
header('Content-Type: application/javascript; charset=utf-8');
header('Cache-Control: public, max-age=3600');
?>
// Service Worker for memor.ia.br
const CACHE_NAME = 'memor-cache-v1.2';
const SHELL = [
'/?b=public',
'/?asset=manifest',
'/?asset=favicon'
];
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(CACHE_NAME).then(c => c.addAll(SHELL)));
self.skipWaiting();
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then(keys => Promise.all(keys.map(k => (k!==CACHE_NAME ? caches.delete(k) : Promise.resolve()))))
);
self.clients.claim();
});
self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url);
// API fetch for board -> stale-while-revalidate
if (url.searchParams.get('action') === 'fetch' && url.searchParams.get('b')) {
e.respondWith((async () => {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(e.request);
const fetchPromise = fetch(e.request).then(res => {
if (res && (res.status === 200 || res.status === 304)) {
cache.put(e.request, res.clone());
}
return res;
}).catch(() => cached);
return cached || fetchPromise;
})());
return;
}
// HTML/other GET: network first; fallback to cache; simple offline page if none
if (e.request.method === 'GET') {
e.respondWith((async () => {
try {
const net = await fetch(e.request);
const cache = await caches.open(CACHE_NAME);
cache.put(e.request, net.clone());
return net;
} catch (err) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(e.request);
return cached || new Response('<!doctype html><meta charset="utf-8"><title>Offline</title><h1>Offline</h1><p>The app is offline. Retry when back online.</p>', { headers: {'Content-Type':'text/html; charset=utf-8'} });
}
})());
}
});
<?php
exit;
}
if ($asset === 'favicon') {
header('Content-Type: image/svg+xml; charset=utf-8');
header('Cache-Control: public, max-age=86400');
echo '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect fill="#111" width="64" height="64" rx="12"/><path d="M48 20L27 41l-11-9" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>';
exit;
}
header('HTTP/1.1 404 Not Found');
exit;
}
/////////////////////////////
// API: Board fetch (GET) with ETag/Last-Modified
/////////////////////////////
$action = $_GET['action'] ?? '';
$bParam = $_GET['b'] ?? '';
if ($action === 'new') {
$bid = id();
header('Location: ' . $origin . '/?b=' . urlencode($bid));
exit;
}
if ($action === 'fetch' && $bParam !== '') {
$b = safeSlug($bParam);
$f = pathOf($b);
$board = loadBoard($b);
$raw = json_encode($board, JSON_UNESCAPED_UNICODE);
$etag = '"' . md5($raw) . '"';
$lastModTs = file_exists($f) ? filemtime($f) : $board['updated'];
$lastMod = gmdate('D, d M Y H:i:s', $lastModTs) . ' GMT';
$inm = $_SERVER['HTTP_IF_NONE_MATCH'] ?? '';
$ims = $_SERVER['HTTP_IF_MODIFIED_SINCE'] ?? '';
if ($inm === $etag || $ims === $lastMod) {
header('ETag: ' . $etag);
header('Last-Modified: ' . $lastMod);
notModified();
}
emitJSON($board, [
'ETag' => $etag,
'Last-Modified' => $lastMod
]);
}
/////////////////////////////
// POST ops
/////////////////////////////
if ($bParam !== '' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$b = safeSlug($bParam);
$in = json_decode((string)file_get_contents('php://input'), true) ?: [];
$op = $in['op'] ?? '';
$board = loadBoard($b);
if ($op === 'add') {
$t = trim((string)($in['text'] ?? ''));
if ($t !== '') {
$t = mb_substr($t, 0, MAX_TEXT_LEN, 'UTF-8');
$board['tasks'][] = ['id' => id(), 'text' => $t, 'done' => false, 'ts' => time()];
saveBoard($b, $board);
}
emitJSON($board);
}
if ($op === 'toggle') {
$idv = (string)($in['id'] ?? '');
foreach ($board['tasks'] as &$t) {
if ($t['id'] === $idv) {
$t['done'] = !$t['done'];
break;
}
}
unset($t);
saveBoard($b, $board);
emitJSON($board);
}
if ($op === 'edit') {
$idv = (string)($in['id'] ?? '');
$txt = trim((string)($in['text'] ?? ''));
$txt = mb_substr($txt, 0, MAX_TEXT_LEN, 'UTF-8');
foreach ($board['tasks'] as &$t) {
if ($t['id'] === $idv) {
$t['text'] = $txt;
break;
}
}
unset($t);
saveBoard($b, $board);
emitJSON($board);
}
if ($op === 'del') {
$idv = (string)($in['id'] ?? '');
$board['tasks'] = array_values(array_filter($board['tasks'], fn($t) => $t['id'] !== $idv));
saveBoard($b, $board);
emitJSON($board);
}
if ($op === 'title') {
$ttl = trim((string)($in['title'] ?? ''));
if ($ttl !== '') $ttl = mb_substr($ttl, 0, MAX_TITLE_LEN, 'UTF-8');
$board['title'] = $ttl !== '' ? $ttl : 'My Todo';
saveBoard($b, $board);
emitJSON($board);
}
if ($op === 'clear_done') {
$board['tasks'] = array_values(array_filter($board['tasks'], fn($t) => empty($t['done'])));
saveBoard($b, $board);
emitJSON($board);
}
// New: set all done/undone
if ($op === 'set_all') {
$done = (bool)($in['done'] ?? false);
foreach ($board['tasks'] as &$t) {
$t['done'] = $done;
}
unset($t);
saveBoard($b, $board);
emitJSON($board);
}
// New: clear all
if ($op === 'clear_all') {
$board['tasks'] = [];
saveBoard($b, $board);
emitJSON($board);
}
// New: reorder tasks by id list
if ($op === 'reorder') {
$order = $in['order'] ?? [];
if (is_array($order)) {
$idx = [];
foreach ($order as $pos => $tid) { $idx[(string)$tid] = (int)$pos; }
usort($board['tasks'], function($a, $b) use ($idx) {
$ai = $idx[$a['id']] ?? PHP_INT_MAX;
$bi = $idx[$b['id']] ?? PHP_INT_MAX;
return $ai <=> $bi;
});
saveBoard($b, $board);
}
emitJSON($board);
}
emitJSON(['error' => 'unknown op']);
}
/////////////////////////////
// Quick add by URL (?add=Text) -> add to board and redirect
/////////////////////////////
if (isset($_GET['add'])) {
$b = safeSlug($bParam !== '' ? $bParam : 'public');
$txt = trim((string)$_GET['add']);
if ($txt !== '') {
$txt = mb_substr($txt, 0, MAX_TEXT_LEN, 'UTF-8');
$board = loadBoard($b);
$board['tasks'][] = ['id' => id(), 'text' => $txt, 'done' => false, 'ts' => time()];
saveBoard($b, $board);
}
header('Location: ' . $origin . '/?b=' . urlencode($b), true, 303);
exit;
}
/////////////////////////////
// HTML App
/////////////////////////////
$boardSlug = safeSlug($bParam !== '' ? $bParam : 'public');
htmlHeader();
?>
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex">
<link rel="manifest" href="<?= htmlspecialchars($origin, ENT_QUOTES, 'UTF-8') ?>/?asset=manifest">
<link rel="icon" href="<?= htmlspecialchars($origin, ENT_QUOTES, 'UTF-8') ?>/?asset=favicon" type="image/svg+xml">
<meta name="theme-color" content="#111111">
<title>memor.ia.br — Todos</title>
<style>
:root { color-scheme: light dark; --bg:#fff; --fg:#111; --muted:#777; --border:#ddd; --accent:#111; --ok:#0a7f2e; --warn:#a00; --chip:#f1f1f1; --chipA:#d1e9ff; --focus:#5b9cff; }
@media (prefers-color-scheme: dark) {
:root { --bg:#0c0c0c; --fg:#eee; --muted:#aaa; --border:#222; --accent:#e5e5e5; --ok:#4cc27f; --warn:#ff736a; --chip:#171717; --chipA:#0f3050; --focus:#6aa3ff; }
}
html,body{height:100%}
body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,Segoe UI,Roboto,Arial}
.wrap{max-width:920px;margin:0 auto;padding:18px}
.box{border:1px solid var(--border);border-radius:12px;padding:14px;background:rgba(0,0,0,0.02)}
.row{display:flex;gap:.5rem;align-items:center}
input[type=text], input[type=search]{flex:1;padding:.65rem;border:1px solid var(--border);border-radius:10px;background:transparent;color:var(--fg)}
button{padding:.6rem .9rem;border:0;border-radius:10px;background:var(--accent);color:#fff;cursor:pointer}
button.secondary{background:transparent;color:var(--fg);border:1px solid var(--border)}
button.warn{background:var(--warn)}
button.ok{background:var(--ok)}
ul{list-style:none;padding:0;margin:12px 0}
li{display:flex;align-items:center;gap:.5rem;padding:.45rem .3rem;border-bottom:1px solid var(--border)}
li:focus{outline:2px solid var(--focus);outline-offset:2px;border-radius:8px}
li.done .txt{text-decoration:line-through;color:var(--muted)}
.txt{flex:1;outline:none}
.txt:focus{background:rgba(127,127,127,.1);border-radius:4px;padding:2px}
.meta{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:10px}
.small{font-size:.9rem;color:var(--muted)}
.pill{padding:.1rem .5rem;border:1px solid var(--border);border-radius:999px}
.share{display:inline-flex;gap:.5rem;align-items:center}
.offline{display:none;margin:12px 0;padding:10px;border-radius:10px;background:#fffbcc;color:#333;border:1px solid #e0dca6}
.offline.show{display:block}
.right{margin-left:auto}
.kbd{font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;background:rgba(127,127,127,.18);border:1px solid var(--border);border-bottom-color:rgba(0,0,0,.3);border-radius:6px;padding:0 .3rem}
.toolbar{display:flex;gap:.5rem;flex-wrap:wrap;align-items:center;margin-bottom:.6rem}
.chips{display:flex;gap:.4rem;flex-wrap:wrap}
.chip{padding:.35rem .6rem;border-radius:999px;border:1px solid var(--border);background:var(--chip);cursor:pointer}
.chip.active{background:var(--chipA)}
.drag{cursor:grab;user-select:none;color:var(--muted);padding:0 .4rem}
.count{margin-left:.4rem}
.status{min-height:1.4rem}
.status button{margin-left:.5rem}
.sr{position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;}
</style>
<div class="wrap">
<div class="toolbar">
<div class="row" style="flex:1;gap:.5rem">
<span class="small pill">Board</span>
<input id="slug" type="text" value="<?= htmlspecialchars($boardSlug, ENT_QUOTES, 'UTF-8') ?>" aria-label="Board slug" style="max-width:240px" list="boards">
<datalist id="boards"></datalist>
<button class="secondary" id="switch" title="Open board by slug">Open</button>
<button class="secondary" id="newBoard" title="Create a new random board">New</button>
</div>
<div class="row right">
<span class="small">Shortcuts: <span class="kbd">Enter</span> add/edit, <span class="kbd">Esc</span> cancel, <span class="kbd">Ctrl</span>+<span class="kbd">/</span> focus</span>
</div>
</div>
<div id="offline" class="offline" role="status" aria-live="polite">You are offline. Changes will queue and sync when back online.</div>
<div class="box" aria-label="Todo app">
<div class="row" style="margin-bottom:.6rem">
<input id="title" type="text" value="My Todo" aria-label="List title" title="Title (Enter to save, Esc to cancel)">
<button id="saveTitle" class="secondary" title="Save title">Save title</button>
<button id="toggleAll" class="secondary" title="Toggle all tasks">Complete all</button>
<button id="clearDone" class="secondary" title="Remove completed tasks">Clear completed</button>
<button id="clearAll" class="warn" title="Remove all tasks">Clear all</button>
</div>
<div class="row" style="margin-bottom:.6rem">
<input id="newtodo" type="text" placeholder="Add a task" autofocus aria-label="New task">
<button id="add">Add</button>
<input id="search" type="search" placeholder="Search" aria-label="Search tasks" style="max-width:240px">
<div class="chips" role="tablist" aria-label="Filter tasks">
<button class="chip active" data-filter="all" role="tab" aria-selected="true">All</button>
<button class="chip" data-filter="active" role="tab" aria-selected="false">Active</button>
<button class="chip" data-filter="done" role="tab" aria-selected="false">Completed</button>
</div>
</div>
<ul id="list" aria-live="polite"></ul>
<div class="meta">
<div>
<span class="small">Share this board URL:</span>
<input id="shareUrl" type="text" readonly style="max-width:380px" aria-label="Share URL">
<button class="secondary" id="copy">Copy</button>
<span class="small count" id="counts"></span>
</div>
<span class="small right" id="updated"></span>
</div>
<div class="meta">
<div class="status small" id="status" aria-live="polite"></div>
<button id="undo" class="secondary" style="display:none">Undo</button>
<span class="sr" id="srmsg" aria-live="polite"></span>
</div>
</div>
</div>
<script>
const origin = <?= json_encode($origin) ?>;
const bid = <?= json_encode($boardSlug) ?>;
const $ = s => document.querySelector(s);
const list = $('#list'), input = $('#newtodo'), title = $('#title'), statusEl = $('#status');
const offlineBanner = $('#offline'), slugInput = $('#slug'), shareUrl = $('#shareUrl');
const countsEl = $('#counts'), updatedEl = $('#updated'), undoBtn = $('#undo'), srmsg = $('#srmsg');
const chips = document.querySelectorAll('.chip');
const boardsDL = $('#boards');
let boardETag = null;
let boardLastMod = null;
let isSyncing = false;
let boardData = {title:'My Todo', tasks:[], created:Date.now()/1000|0, updated:Date.now()/1000|0};
let filter = localStorage.getItem('memor_filter') || 'all';
let searchQuery = localStorage.getItem('memor_search') || '';
let pendingEdit = null; // {id, orig}
let lastAction = null; // for undo
let undoTimer = null;
const outboxKey = (b) => `memor_outbox_${b}`;
const recentBoardsKey = 'memor_recent_boards_v1';
// Register SW
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/?asset=sw', {scope:'/'});
});
}
// Online/offline indicators
function updateOnlineUI() {
if (navigator.onLine) {
offlineBanner.classList.remove('show');
} else {
offlineBanner.classList.add('show');
}
}
window.addEventListener('online', () => { updateOnlineUI(); flushOutbox(); fetchBoard(true); });
window.addEventListener('offline', updateOnlineUI);
updateOnlineUI();
// Copy/share URL setup
const boardURL = `${origin}/?b=${encodeURIComponent(bid)}`;
shareUrl.value = boardURL;
$('#copy').onclick = async () => {
try { await navigator.clipboard.writeText(boardURL); flash('Copied link', 1200); } catch(e){ flash('Copy failed', 1500); }
};
// Helper: small status message
let flashTimer = null;
function flash(msg, persistMs=1500) {
clearTimeout(flashTimer);
statusEl.textContent = msg;
if (persistMs > 0) {
flashTimer = setTimeout(() => statusEl.textContent = '', persistMs);
}
srmsg.textContent = msg;
}
// Undo handling
function showUndo(action) {
lastAction = action; // {type, payload}
undoBtn.style.display = 'inline-block';
clearTimeout(undoTimer);
undoTimer = setTimeout(() => { hideUndo(); }, 10000);
}
function hideUndo() {
lastAction = null;
undoBtn.style.display = 'none';
}
undoBtn.onclick = async () => {
if (!lastAction) return;
const act = lastAction;
hideUndo();
if (act.type === 'del') {
const t = act.payload;
await op({op:'add', text: t.text});
flash('Undid delete', 1000);
} else if (act.type === 'clear_done') {
for (const t of act.payload) {
await op({op:'add', text: t.text});
}
flash('Restored completed', 1200);
}
};
// Render helpers
function fmtRelTime(ts) {
const s = Math.max(0, Math.floor(Date.now()/1000 - ts));
if (s < 5) return 'just now';
if (s < 60) return `${s}s ago`;
const m = Math.floor(s/60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m/60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h/24);
return `${d}d ago`;
}
function updateMeta(d) {
const activeCount = (d.tasks || []).filter(t => !t.done).length;
const total = (d.tasks || []).length;
countsEl.textContent = total ? `${activeCount} item${activeCount!==1?'s':''} left • ${total} total` : 'No tasks';
updatedEl.textContent = `Updated ${fmtRelTime(d.updated||Date.now()/1000|0)}`;
$('#toggleAll').textContent = activeCount > 0 ? 'Complete all' : 'Uncheck all';
}
// Filtering
function applyFilter(tasks) {
let arr = tasks.slice();
const q = (searchQuery||'').toLowerCase();
if (filter === 'active') arr = arr.filter(t => !t.done);
if (filter === 'done') arr = arr.filter(t => t.done);
if (q) arr = arr.filter(t => (t.text||'').toLowerCase().includes(q));
return arr;
}
function updateFilterUI() {
chips.forEach(ch => {
const isActive = ch.getAttribute('data-filter') === filter;
ch.classList.toggle('active', isActive);
ch.setAttribute('aria-selected', isActive ? 'true' : 'false');
});
$('#search').value = searchQuery;
}
function reorderAllowed() {
return filter === 'all' && !searchQuery;
}
// Recent boards
function pushRecentBoard(slug) {
try {
const raw = localStorage.getItem(recentBoardsKey);
let arr = raw ? JSON.parse(raw) : [];
arr = arr.filter(s => s !== slug);
arr.unshift(slug);
if (arr.length > 10) arr.length = 10;
localStorage.setItem(recentBoardsKey, JSON.stringify(arr));
renderBoardsList();
} catch (e) {}
}
function renderBoardsList() {
try {
const raw = localStorage.getItem(recentBoardsKey);
const arr = raw ? JSON.parse(raw) : [];
boardsDL.innerHTML = arr.map(s => `<option value="${s}"></option>`).join('');
} catch (e) {}
}
// Render board
function render(d) {
boardData = d;
title.value = d.title || 'My Todo';
updateMeta(d);
renderBoardsList();
const tasks = applyFilter(d.tasks || []);
list.innerHTML = '';
const allowDrag = reorderAllowed();
if (!allowDrag) {
list.setAttribute('data-drag-disabled', 'true');
} else {
list.removeAttribute('data-drag-disabled');
}
tasks.forEach((t) => {
const li = document.createElement('li');
li.setAttribute('tabindex', '0');
if (t.done) li.classList.add('done');
li.dataset.id = t.id;
li.draggable = allowDrag;
li.innerHTML =
`<span class="drag" title="${allowDrag?'Drag to reorder':'Reorder disabled while filtering/searching'}">⋮⋮</span>
<input type="checkbox" ${t.done?'checked':''} data-id="${t.id}" aria-label="Mark task done">
<span class="txt" contenteditable="true" data-id="${t.id}" spellcheck="false" role="textbox" aria-multiline="false"></span>
<button class="secondary" data-del="${t.id}" aria-label="Delete task">Delete</button>`;
li.querySelector('.txt').textContent = t.text;
// Drag events
if (allowDrag) {
li.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', t.id);
li.classList.add('dragging');
});
li.addEventListener('dragend', () => {
li.classList.remove('dragging');
});
li.addEventListener('dragover', (e) => {
e.preventDefault();
const dragging = list.querySelector('.dragging');
if (!dragging || dragging === li) return;
const rect = li.getBoundingClientRect();
const before = (e.clientY - rect.top) < rect.height / 2;
list.insertBefore(dragging, before ? li : li.nextSibling);
});
li.addEventListener('drop', async (e) => {
e.preventDefault();
const order = Array.from(list.querySelectorAll('li')).map(node => node.dataset.id);
await op({op:'reorder', order});
flash('Reordered', 800);
});
}
list.appendChild(li);
});
}
// Fetch with ETag support
async function fetchBoard(force=false) {
const url = `?action=fetch&b=${encodeURIComponent(bid)}`;
const headers = {};
if (boardETag && !force) headers['If-None-Match'] = boardETag;
if (boardLastMod && !force) headers['If-Modified-Since'] = boardLastMod;
try {
const r = await fetch(url, {headers});
if (r.status === 304) return;
if (!r.ok) throw new Error('Network error');
const d = await r.json();
boardETag = r.headers.get('ETag');
boardLastMod = r.headers.get('Last-Modified');
render(d);
pushRecentBoard(bid);
flash('Loaded', 800);
} catch (e) {
flash('Offline (cached view)', 1500);
}
}
// Queue ops if offline/errors
function queueOp(payload) {
const k = outboxKey(bid);
const arr = JSON.parse(localStorage.getItem(k) || '[]');
arr.push(payload);
localStorage.setItem(k, JSON.stringify(arr));
offlineBanner.classList.add('show');
}
// Flush queued ops
async function flushOutbox() {
if (isSyncing) return;
const k = outboxKey(bid);
let arr = JSON.parse(localStorage.getItem(k) || '[]');
if (!arr.length || !navigator.onLine) return;
isSyncing = true;
try {
while (arr.length && navigator.onLine) {
const p = arr[0];
await op(p, {silent:true});
arr.shift();
localStorage.setItem(k, JSON.stringify(arr));
}
if (arr.length === 0) {
offlineBanner.classList.remove('show');
flash('Synced', 1000);
fetchBoard(true);
}
} catch (e) {
// keep queue for retry
} finally {
isSyncing = false;
}
}
// Perform operation
async function op(p, opts={}) {
try {
const r = await fetch(`?b=${encodeURIComponent(bid)}`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(p)
});
if (!r.ok) throw new Error('Bad response');
const d = await r.json();
if (!opts.silent) render(d);
return d;
} catch (e) {
queueOp(p);
if (!opts.silent) flash('Queued (offline)', 1500);
return null;
}
}
// Event handlers
document.addEventListener('DOMContentLoaded', async () => {
// Initialize filter UI
chips.forEach(ch => {
ch.addEventListener('click', () => {
filter = ch.getAttribute('data-filter');
localStorage.setItem('memor_filter', filter);
updateFilterUI();
render(boardData);
});
});
$('#search').addEventListener('input', (e) => {
searchQuery = e.target.value.trim();
localStorage.setItem('memor_search', searchQuery);
render(boardData);
});
updateFilterUI();
await fetchBoard(true);
await flushOutbox();
// Add new task
$('#add').onclick = () => {
const v = input.value.trim();
if (v) {
op({op:'add', text: v}).then(() => { input.value=''; input.focus(); flash('Added', 800); });
}
};
input.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); $('#add').click(); }
if (e.key === 'Escape') { input.value=''; input.blur(); }
if (e.ctrlKey && e.key === '/') { e.preventDefault(); input.focus(); }
});
// Title save/cancel
let titleOrig = '';
title.addEventListener('focus', () => { titleOrig = title.value; });
$('#saveTitle').onclick = () => {
const ttl = title.value.trim() || 'My Todo';
op({op:'title', title: ttl}).then(()=>flash('Saved', 900));
};
title.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); $('#saveTitle').click(); title.blur(); }
if (e.key === 'Escape') { e.preventDefault(); title.value = titleOrig; title.blur(); flash('Canceled', 800); }
});
title.addEventListener('blur', () => $('#saveTitle').click());
// Bulk buttons
$('#clearDone').onclick = () => {
const cleared = (boardData.tasks||[]).filter(t=>t.done);
if (!cleared.length) { flash('No completed tasks', 1200); return; }
op({op:'clear_done'}).then(()=>{ flash('Cleared completed', 1000); showUndo({type:'clear_done', payload: cleared}); });
};
$('#clearAll').onclick = () => {
const total = (boardData.tasks||[]).length;
if (!total) { flash('No tasks to clear', 1200); return; }
if (confirm(`Delete all ${total} tasks?`)) {
op({op:'clear_all'}).then(()=>flash('All cleared', 1000));
}
};
$('#toggleAll').onclick = () => {
const anyActive = (boardData.tasks||[]).some(t=>!t.done);
op({op:'set_all', done: anyActive}).then(()=>flash(anyActive?'Completed all':'Unchecked all', 1000));
};
// Switch/open board by slug
$('#switch').onclick = () => {
const s = (slugInput.value||'').trim();
if (!s) return;
window.location = `${origin}/?b=${encodeURIComponent(s)}`;
};
$('#newBoard').onclick = () => window.location = `${origin}/?action=new`;
// Global keyboard
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === '/') { e.preventDefault(); input.focus(); }
if (e.key === 'n' && !e.ctrlKey && !e.metaKey && !e.altKey) {
if (document.activeElement !== input && document.activeElement !== title) {
input.focus();
}
}
});
// List interactions
list.addEventListener('change', e => {
const id = e.target.getAttribute('data-id');
if (id && e.target.type === 'checkbox') { op({op:'toggle', id}); }
});
list.addEventListener('click', e => {
const id = e.target.getAttribute('data-del');
if (id) {
const t = (boardData.tasks||[]).find(x=>x.id===id);
op({op:'del', id}).then(()=>{ flash('Deleted', 900); if (t) showUndo({type:'del', payload: t}); });
}
});
// List keyboard (item-level)
list.addEventListener('keydown', (e) => {
const li = e.target.closest('li');
if (!li) return;
// Per-item shortcuts when LI itself is focused
if (e.target === li) {
if (e.key === ' ') { e.preventDefault(); const cb = li.querySelector('input[type=checkbox]'); if (cb) { cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); } }
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
const id = li.querySelector('[data-del]')?.getAttribute('data-del');
if (id) {
const t = (boardData.tasks||[]).find(x=>x.id===id);
op({op:'del', id}).then(()=>{ flash('Deleted', 900); if (t) showUndo({type:'del', payload: t}); });
}
}
if (e.key.toLowerCase() === 'e') {
e.preventDefault();
const txt = li.querySelector('.txt');
if (txt) txt.focus();
}
}
// Contenteditable shortcuts
if (e.target.classList.contains('txt')) {
if (e.key === 'Enter') {
e.preventDefault();
e.target.blur(); // commit on blur
} else if (e.key === 'Escape') {
e.preventDefault();
if (pendingEdit && pendingEdit.id === e.target.getAttribute('data-id')) {
e.target.textContent = pendingEdit.orig;
}
e.target.blur();
flash('Canceled', 800);
} else if (e.key === 'Tab') {
e.preventDefault();
const els = Array.from(list.querySelectorAll('.txt'));
const i = els.indexOf(e.target);
const next = els[i + (e.shiftKey ? -1 : 1)];
e.target.blur();
if (next) next.focus();
}
}
});
// Inline edit lifecycle (commit-on-blur if changed)
list.addEventListener('focusin', (e) => {
if (e.target.classList.contains('txt')) {
pendingEdit = { id: e.target.getAttribute('data-id'), orig: e.target.textContent };
const range = document.createRange();
range.selectNodeContents(e.target);
range.collapse(false);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
});
list.addEventListener('blur', e => {
if (e.target.classList.contains('txt')) {
const id = e.target.getAttribute('data-id');
const text = e.target.textContent.trim();
if (pendingEdit && pendingEdit.id === id) {
if (text !== pendingEdit.orig) {
op({op:'edit', id, text}).then(()=>flash('Saved', 800));
}
}
pendingEdit = null;
}
}, true);
});
// Initialize UI state
(function initUI() {
updateFilterUI();
})();
</script>