Picker dialog, hamburger menu entry, phone page and help section
still had German labels from the original request ("Einkaufsliste",
"keine aktive Liste", etc.). The rest of the app's UI is English,
so align everything here: menu entry is "Shopping list", dialog +
phone page copy translated, help references match. Trigger tags
stay as-is — both #einkauf and #shopping keep working so users with
either convention aren't disrupted.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
229 lines
11 KiB
JavaScript
229 lines
11 KiB
JavaScript
// ── 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) {
|
|
const tags = (card.tags || '').split(',').map(s => s.trim());
|
|
return tags.includes('einkauf') || tags.includes('shopping');
|
|
}
|
|
|
|
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 = `
|
|
<div class="flex items-center gap-3 px-5 py-3 border-b border-t-border">
|
|
<i class="ph ph-shopping-cart text-t-accent text-base"></i>
|
|
<h2 class="font-pixel text-sm text-t-accent flex-1">Shopping list</h2>
|
|
<button class="shopping-picker-close text-t-muted hover:text-t-danger"><i class="ph ph-x text-sm"></i></button>
|
|
</div>
|
|
<div class="px-5 py-3 text-[11px] text-t-muted leading-relaxed">
|
|
Cards tagged with <span class="text-t-accent">#einkauf</span> or
|
|
<span class="text-t-accent">#shopping</span> can be set as the active list for the
|
|
phone. Only one is active at a time; its body is pushed to the relay on every sync
|
|
and shown at <span class="text-t-accent">/shopping</span>.
|
|
</div>
|
|
<div class="flex-1 overflow-y-auto px-2 py-2" id="shopping-picker-list"></div>
|
|
<div class="flex items-center gap-2 px-5 py-3 border-t border-t-border">
|
|
<span id="shopping-picker-status" class="font-pixel text-[10px] text-t-dim flex-1"></span>
|
|
<button class="shopping-picker-cancel px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger"><span class="font-pixel text-[10px]">Close</span></button>
|
|
<button class="shopping-picker-save px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10"><span class="font-pixel text-[10px]">Save</span></button>
|
|
</div>
|
|
`;
|
|
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 = `<div class="px-4 py-6 text-center text-[11px] text-t-dim">
|
|
No cards tagged <span class="text-t-accent">#einkauf</span> or
|
|
<span class="text-t-accent">#shopping</span> yet. Create a card with checkbox items
|
|
and add one of those tags — it'll show up here.
|
|
</div>`;
|
|
} 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 `<label class="flex items-start gap-3 px-3 py-2 hover:bg-t-raised cursor-pointer">
|
|
<input type="radio" name="shopping-active" value="${id}" ${checked} style="margin-top:3px;">
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-xs text-t-bright font-pixel">${esc(c.title || '(untitled)')}</div>
|
|
<div class="text-[10px] text-t-dim font-pixel mt-0.5">${open} open · ${items.length} total</div>
|
|
</div>
|
|
</label>`;
|
|
}).join('');
|
|
// "No active" option — explicit way to stop syncing.
|
|
list.insertAdjacentHTML('beforeend', `<label class="flex items-center gap-3 px-3 py-2 border-t border-t-border hover:bg-t-raised cursor-pointer mt-2">
|
|
<input type="radio" name="shopping-active" value="" ${currentActive ? '' : 'checked'} style="margin-top:0;">
|
|
<span class="text-[11px] text-t-muted italic">(no active list)</span>
|
|
</label>`);
|
|
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);
|
|
}
|
|
});
|
|
}
|