diff --git a/config.php b/config.php index 2cac192..7b3ed08 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@
+
+ + +
diff --git a/public/js/app.js b/public/js/app.js index 094620b..15ed114 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -21,6 +21,7 @@ import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar import { loadSettings as loadCaldavSettings } from './modules/caldav.js'; import { startBackgroundSync, triggerSync, loadRelaySettings } from './modules/sync.js'; import { openPluginSettings } from './modules/plugin-settings.js'; +import { initShopping } from './modules/shopping.js'; // ── Global API (for inline onclick handlers and cross-module calls) ── @@ -217,6 +218,7 @@ async function checkAuth() { loadBuckets(); loadCards(); startBackgroundSync(); + initShopping(); } // ── Keyboard shortcuts (central registry) ── diff --git a/public/js/modules/shopping.js b/public/js/modules/shopping.js new file mode 100644 index 0000000..1709085 --- /dev/null +++ b/public/js/modules/shopping.js @@ -0,0 +1,225 @@ +// ── Shopping list (phone-synced card) ── +// +// One card flagged as #einkauf can be "active" at a time. Its body +// is pushed to the relay on every sync.tick; the relay serves a +// phone-friendly checklist at /shopping. Phone taps toggle items in +// place on the relay; hyprcrd pulls those toggles back on the next +// tick and patches the local card. +// +// Phone never changes item text — only check state. Text edits stay +// in hyprcrd, which keeps conflict resolution trivial (last-writer +// wins by timestamp; near-simultaneous edits are extremely unlikely +// for a shopping list). + +import { $, api, esc, getCsrf } from './util.js'; +import { state } from './state.js'; + +const ACTIVE_KEY = 'hyprcrd-shopping-active-id'; +const LAST_PUSHED_KEY = 'hyprcrd-shopping-last-pushed-ts'; +const LAST_SERVER_KEY = 'hyprcrd-shopping-last-server-ts'; + +function hasEinkaufTag(card) { + return (card.tags || '').split(',').map(s => s.trim()).includes('einkauf'); +} + +function getActiveId() { + const v = localStorage.getItem(ACTIVE_KEY); + return v ? String(v) : null; +} + +function setActiveId(id) { + if (id === null || id === '') localStorage.removeItem(ACTIVE_KEY); + else localStorage.setItem(ACTIVE_KEY, String(id)); + // Reset timestamp cursors so the switch pushes fresh on next tick. + localStorage.removeItem(LAST_PUSHED_KEY); + localStorage.removeItem(LAST_SERVER_KEY); +} + +function getActiveCard() { + const id = getActiveId(); + if (!id) return null; + return state.cards.find(c => String(c.id) === id) || null; +} + +// ── Dialog ──────────────────────────────────────────────────────── + +function openDialog() { + document.querySelector('.shopping-picker-overlay')?.remove(); + + const overlay = document.createElement('div'); + overlay.className = 'shopping-picker-overlay'; + overlay.style.cssText = 'position:fixed;inset:0;background:var(--t-overlay,rgba(0,0,0,0.6));z-index:200;display:flex;align-items:center;justify-content:center;padding:24px;'; + + const panel = document.createElement('div'); + panel.style.cssText = 'background:var(--t-surface);border:1px solid var(--t-border);box-shadow:0 8px 32px rgba(0,0,0,0.6);width:100%;max-width:520px;max-height:80vh;display:flex;flex-direction:column;'; + panel.innerHTML = ` +
+ +

Einkaufsliste

+ +
+
+ Karten mit dem Tag #einkauf können als aktive Liste + fürs Telefon gewählt werden. Nur eine ist aktiv; ihr Body wird bei jedem Sync zum + Relay geschickt und unter /shopping angezeigt. +
+
+
+ + + +
+ `; + overlay.appendChild(panel); + document.body.appendChild(overlay); + + const list = panel.querySelector('#shopping-picker-list'); + const status = panel.querySelector('#shopping-picker-status'); + const close = () => overlay.remove(); + panel.querySelector('.shopping-picker-close').addEventListener('click', close); + panel.querySelector('.shopping-picker-cancel').addEventListener('click', close); + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); + document.addEventListener('keydown', function esc(e) { + if (e.key === 'Escape') { close(); document.removeEventListener('keydown', esc); } + }); + + const candidates = state.cards.filter(hasEinkaufTag); + const currentActive = getActiveId(); + let selectedId = currentActive; + + if (!candidates.length) { + list.innerHTML = `
+ Keine Karten mit dem Tag #einkauf. + Lege eine Karte mit Checkbox-Items an und gib ihr den Tag, dann erscheint sie hier. +
`; + } else { + list.innerHTML = candidates.map(c => { + const id = String(c.id); + const checked = id === selectedId ? 'checked' : ''; + const items = (c.body || '').split('\n').filter(l => /^\s*-\s*\[[ xX]\]/.test(l)); + const open = items.filter(l => /^\s*-\s*\[\s\]/.test(l)).length; + return ``; + }).join(''); + // "No active" option — explicit way to stop syncing. + list.insertAdjacentHTML('beforeend', ``); + list.querySelectorAll('input[name="shopping-active"]').forEach(r => { + r.addEventListener('change', () => { selectedId = r.value || null; }); + }); + } + + panel.querySelector('.shopping-picker-save').addEventListener('click', async () => { + const prev = getActiveId(); + setActiveId(selectedId); + status.textContent = 'saving…'; + try { + if (selectedId) await pushActive(); + else await clearRemote(); + status.textContent = 'saved'; + setTimeout(close, 400); + } catch (e) { + status.textContent = 'error: ' + (e?.message || e); + status.style.color = 'var(--t-danger)'; + setActiveId(prev); + } + }); +} + +// ── Sync ─────────────────────────────────────────────────────── + +async function pushActive() { + const card = getActiveCard(); + if (!card) return; + const localTs = card.updated_at || new Date().toISOString().replace('T', ' ').slice(0, 19); + const lastPushed = localStorage.getItem(LAST_PUSHED_KEY); + if (lastPushed && lastPushed >= localTs) return; // nothing newer + const res = await api('api/shopping', { + method: 'POST', + body: { card_uid: String(card.id), title: card.title || '', body: card.body || '', updated_at: localTs }, + }); + if (res?.ok) { + localStorage.setItem(LAST_PUSHED_KEY, localTs); + if (res.updated_at_server) localStorage.setItem(LAST_SERVER_KEY, res.updated_at_server); + } +} + +async function pullActive() { + const card = getActiveCard(); + if (!card) return; + const res = await api('api/shopping', { method: 'GET' }); + if (!res?.ok || !res.state) return; + const { card_uid, body, updated_at_server } = res.state; + if (String(card_uid) !== String(card.id)) return; // relay still has a previous list + const seen = localStorage.getItem(LAST_SERVER_KEY); + if (seen && seen >= updated_at_server) return; // no news + if ((body || '') === (card.body || '')) { + localStorage.setItem(LAST_SERVER_KEY, updated_at_server); + return; + } + // Phone toggled something — patch the local card. + await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body } }); + localStorage.setItem(LAST_SERVER_KEY, updated_at_server); + localStorage.setItem(LAST_PUSHED_KEY, updated_at_server); // suppress immediate re-push + await window._reloadAll?.(); +} + +async function clearRemote() { + // No DELETE endpoint — send an empty list that "displaces" the + // previous active card on the relay so the phone sees nothing. + await api('api/shopping', { + method: 'POST', + body: { card_uid: '__inactive__', title: '', body: '', updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) }, + }); + localStorage.removeItem(LAST_PUSHED_KEY); + localStorage.removeItem(LAST_SERVER_KEY); +} + +async function tick() { + if (!getActiveId()) return; + // Pull first so phone toggles land before we overwrite with a push. + try { await pullActive(); } catch {} + try { await pushActive(); } catch {} +} + +// ── Hamburger menu wiring ───────────────────────────────────── + +function initMenu() { + const btn = $('header-menu-btn'); + const menu = $('header-menu'); + if (!btn || !menu) return; + btn.addEventListener('click', (e) => { + e.stopPropagation(); + menu.classList.toggle('hidden'); + }); + document.addEventListener('click', (e) => { + if (!menu.contains(e.target) && e.target !== btn) menu.classList.add('hidden'); + }); + $('menu-shopping')?.addEventListener('click', () => { + menu.classList.add('hidden'); + openDialog(); + }); +} + +export function initShopping() { + initMenu(); + // Hook into the shared sync heartbeat so push/pull happens on the + // same cadence as every other poller — one timer rules them all. + window.hyprcrd?.hooks?.on?.('sync.tick', () => { tick().catch(() => {}); }); + // Also push right after a save of the active card so the phone + // sees edits without waiting for the next tick. + window.addEventListener('hyprcrd:editor-close', (e) => { + const active = getActiveId(); + if (active && String(e?.detail?.cardId) === active) { + // Small delay so state.cards has the post-save body. + setTimeout(() => tick().catch(() => {}), 300); + } + }); +} diff --git a/relay/inc/bootstrap.php b/relay/inc/bootstrap.php index 1f63f58..c1166c4 100644 --- a/relay/inc/bootstrap.php +++ b/relay/inc/bootstrap.php @@ -233,6 +233,20 @@ $db->exec('CREATE TABLE IF NOT EXISTS chat_room_members ( )'); $db->exec('CREATE INDEX IF NOT EXISTS idx_chat_room_member_tenant ON chat_room_members(tenant_id, room_uid)'); +// Shopping list: one active card per tenant, body mirrored from the +// client. Phone toggles checkboxes in-place via the session UI — no +// multi-list fan-out, no history, no attachments. hyprcrd remains the +// only surface that edits item text. +$db->exec('CREATE TABLE IF NOT EXISTS shopping_state ( + tenant_id INTEGER PRIMARY KEY, + card_uid TEXT NOT NULL, + title TEXT NOT NULL DEFAULT "", + body TEXT NOT NULL DEFAULT "", + updated_at_client TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at_server TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE +)'); + // Sync-event notifications: replaces the old bot-chat mechanism. When a // client syncs a card they received, they POST here; the sender polls // GET /api/sync-events and surfaces each as a transient toast. Rows are diff --git a/relay/inc/routes/shopping.php b/relay/inc/routes/shopping.php new file mode 100644 index 0000000..ec27062 --- /dev/null +++ b/relay/inc/routes/shopping.php @@ -0,0 +1,267 @@ +prepare('SELECT card_uid, title, body, updated_at_client, updated_at_server FROM shopping_state WHERE tenant_id = :t'); + $stmt->execute(['t' => $tenantId]); + $row = $stmt->fetch(); + return $row ?: null; +} + +// ── POST /api/shopping ──────────────────────────────────────── +// Client pushes the currently-active card's content. `updated_at` +// is the client's timestamp — used for lose-the-race checks. + +if ($route === '/api/shopping' && $method === 'POST') { + if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); + $input = json_decode(file_get_contents('php://input'), true) ?: []; + $uid = trim((string)($input['card_uid'] ?? '')); + $title = (string)($input['title'] ?? ''); + $body = (string)($input['body'] ?? ''); + $clientTs = trim((string)($input['updated_at'] ?? '')) ?: date('Y-m-d H:i:s'); + if ($uid === '') jsonResponse(['error' => 'card_uid required'], 400); + + $existing = shoppingLoad($db, (int)$clientTenant['id']); + // Skip the write when the client is pushing an older version of + // the same card than what's already stored (phone toggled + // meanwhile). Still return 200 so the client doesn't retry in a + // loop — they just haven't pulled yet. + if ($existing && $existing['card_uid'] === $uid + && strcmp($existing['updated_at_server'], $clientTs) > 0) { + jsonResponse([ + 'ok' => true, + 'stored' => false, + 'reason' => 'server newer — pull first', + 'updated_at_server' => $existing['updated_at_server'], + ]); + } + $now = date('Y-m-d H:i:s'); + $db->prepare(' + INSERT INTO shopping_state (tenant_id, card_uid, title, body, updated_at_client, updated_at_server) + VALUES (:t, :u, :ti, :b, :c, :s) + ON CONFLICT(tenant_id) DO UPDATE SET + card_uid = excluded.card_uid, + title = excluded.title, + body = excluded.body, + updated_at_client = excluded.updated_at_client, + updated_at_server = excluded.updated_at_server + ')->execute([ + 't' => $clientTenant['id'], + 'u' => $uid, + 'ti' => $title, + 'b' => $body, + 'c' => $clientTs, + 's' => $now, + ]); + jsonResponse(['ok' => true, 'stored' => true, 'updated_at_server' => $now]); +} + +// ── GET /api/shopping ──────────────────────────────────────── +// Both the client (to pull phone-made toggles back) and the +// session-auth'd phone UI poll this. + +if ($route === '/api/shopping' && $method === 'GET') { + if (!$tenant) jsonResponse(['error' => 'unauthorized'], 401); + $state = shoppingLoad($db, (int)$tenant['id']); + if (!$state) jsonResponse(['ok' => true, 'state' => null]); + jsonResponse(['ok' => true, 'state' => $state]); +} + +// ── POST /api/shopping/check ───────────────────────────────── +// Phone-side toggle. Body: { line: "- [ ] milk", checked: true }. +// We match the exact line once to keep merges simple; if the line +// isn't found (hyprcrd just rewrote it), the caller reloads and +// retries. Bumps updated_at_server so the next client pull sees +// the change. + +if ($route === '/api/shopping/check' && $method === 'POST') { + if (!$sessionTenant) jsonResponse(['error' => 'unauthorized'], 401); + $input = json_decode(file_get_contents('php://input'), true) ?: []; + $line = (string)($input['line'] ?? ''); + $checked = !empty($input['checked']); + if ($line === '') jsonResponse(['error' => 'line required'], 400); + + $state = shoppingLoad($db, (int)$sessionTenant['id']); + if (!$state) jsonResponse(['error' => 'no active list'], 404); + $body = $state['body']; + $lines = preg_split("/\r\n|\n|\r/", $body); + $hit = false; + foreach ($lines as $i => $l) { + if ($l !== $line) continue; + $toggled = $checked + ? preg_replace('/^(\s*-\s*)\[\s\]/', '$1[x]', $l, 1) + : preg_replace('/^(\s*-\s*)\[[xX]\]/', '$1[ ]', $l, 1); + if ($toggled === $l) continue; // not a checkbox line + $lines[$i] = $toggled; + $hit = true; + break; + } + if (!$hit) jsonResponse(['error' => 'line not found (list may have been edited — reload)'], 409); + + $newBody = implode("\n", $lines); + $now = date('Y-m-d H:i:s'); + $db->prepare('UPDATE shopping_state SET body = :b, updated_at_server = :s WHERE tenant_id = :t') + ->execute(['b' => $newBody, 's' => $now, 't' => $sessionTenant['id']]); + jsonResponse(['ok' => true, 'updated_at_server' => $now]); +} + +// ── GET /shopping ──────────────────────────────────────────── +// Phone UI. Simple static shell; the JS fetches /api/shopping and +// renders only unchecked items. Tapping one POSTs to /api/shopping/check. + +if ($route === '/shopping' && $method === 'GET') { + if (!$sessionTenant) { + // Bounce through the relay's session-auth flow. + header('Location: ' . dirname($_SERVER['SCRIPT_NAME']) . '/'); + exit; + } + header('Content-Type: text/html; charset=utf-8'); + $userLabel = htmlspecialchars($sessionTenant['username'], ENT_QUOTES); + ?> + + + + + + +Einkaufsliste + + + +
+

Einkaufsliste

+ + +
+
+

…

+ + +

+
+ + + + 'not found'], 404); } // ── UI ───────────────────────────────────────────────────────────── +// Shopping UI lives outside /api/ so phones can bookmark a clean URL. +// File has both /api/shopping* and /shopping handlers; they short- +// circuit on match, so loading it here for the non-API path is safe. +require __DIR__ . '/inc/routes/shopping.php'; require __DIR__ . '/ui/layout.php';