shopping: ship as plugin, integrate phone view into relay UI, fix sync
Restructure the shopping-list feature end to end:
- New public/plugins/shopping/ plugin with backend.php (proxy
routes registered via PluginManager::onApiRoute), shopping.js
(self-contained; injects the header Shopping button, owns the
picker dialog + sync loop via hyprcrd.hooks), plugin.json and a
placeholder css file. Core no longer knows about shopping —
index.php loses the static button and the proxy block, app.js
loses the initShopping wiring, the old js/modules/shopping.js
is gone.
- Phone view moves out of a standalone /shopping page and into
the relay UI as a regular `view-shopping` behind a nav-burger
entry (relay/ui/layout.php + app.js + style.css). Same session
auth as compose/chat/account — no separate login. Auto-refresh
every 4 s while the view is active; no manual refresh button.
Headings "# h1 … ###### h6" render between items for grouping.
Orphan section headings (no open items under them) are hidden.
- Sync: drop timestamp cursors (broke across SQLite-UTC / PHP
timezones and silently blocked pushes after a pull). Both sides
diff the (uid, title, body) triple instead — timezone-agnostic
and authoritative. Client ticks push-before-pull so fresh local
edits land on the relay before a stale pull can overwrite them.
Relay drops its "server newer" guard and stamps server times
with gmdate so anything left comparing to SQLite is UTC.
- Client also ticks on cards.reloaded, not just sync.tick, so
any edit path (editor save, checkbox toggle, context menu,
undo) pushes the update immediately.
- Tests: updated for the plugin (still uses the same selectors
because the plugin injects the same ids), plus a plugin-
registration check and "(no active list)" option assertion.
- Help section copy updated — references the Shopping header
button and the shopping tab in the relay UI rather than the
gone /shopping URL.
Both #einkauf and #shopping work as trigger tags; phone is
check-only, editing stays in hyprcrd. Version bumped to 1.9.21.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
b9d610b612
commit
d052fb5f9d
14 files changed
+417
-325
No files matched your search
+132
-2
@@ -68,15 +68,16 @@ function showView(name) {
|
||||
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
|
||||
name = 'change-password';
|
||||
}
|
||||
['setup', 'login', 'change-password', 'compose', 'account', 'admin', 'chat'].forEach(v => {
|
||||
['setup', 'login', 'change-password', 'compose', 'account', 'admin', 'chat', 'shopping'].forEach(v => {
|
||||
$('view-' + v)?.classList.add('hidden');
|
||||
});
|
||||
$('view-' + name)?.classList.remove('hidden');
|
||||
|
||||
if (['compose', 'account', 'admin', 'chat'].includes(name)) {
|
||||
if (['compose', 'account', 'admin', 'chat', 'shopping'].includes(name)) {
|
||||
$('nav-buttons').classList.remove('hidden');
|
||||
$('nav-compose').classList.toggle('active', name === 'compose');
|
||||
$('nav-chat')?.classList.toggle('active', name === 'chat');
|
||||
$('nav-shopping')?.classList.toggle('active', name === 'shopping');
|
||||
$('nav-account').classList.toggle('active', name === 'account');
|
||||
$('nav-admin').classList.toggle('active', name === 'admin');
|
||||
$('nav-burger')?.classList.remove('hidden');
|
||||
@@ -105,6 +106,12 @@ function showView(name) {
|
||||
} else {
|
||||
stopChatPoll();
|
||||
}
|
||||
if (name === 'shopping') {
|
||||
loadShopping();
|
||||
startShoppingPoll();
|
||||
} else {
|
||||
stopShoppingPoll();
|
||||
}
|
||||
if (name === 'admin') {
|
||||
loadUsers();
|
||||
} else if (name === 'account') {
|
||||
@@ -695,6 +702,129 @@ window.deleteAllMessages = deleteAllMessages;
|
||||
/** Lightweight relative-time formatter for the inbox. SQLite gives us
|
||||
* `YYYY-MM-DD HH:MM:SS` (UTC). Returns "2 min ago", "today 14:32",
|
||||
* or a full date for older messages. */
|
||||
// ── Shopping (phone-side of the hyprcrd → relay list) ──
|
||||
//
|
||||
// We don't own the content — hyprcrd pushes the active card's body
|
||||
// to /api/shopping and we just render the unchecked "- [ ]" lines
|
||||
// as tappable rows. A tap POSTs to /api/shopping/check which flips
|
||||
// that exact line to "- [x]" on disk; hyprcrd's next pull sees it.
|
||||
|
||||
let shoppingPollHandle = null;
|
||||
let shoppingBusy = false;
|
||||
|
||||
function startShoppingPoll() {
|
||||
stopShoppingPoll();
|
||||
// 4 s is responsive enough that edits on the desktop show up
|
||||
// while you're still holding the phone, and cheap enough that
|
||||
// an always-on tab costs basically nothing (single indexed
|
||||
// SELECT per poll).
|
||||
shoppingPollHandle = setInterval(() => {
|
||||
if (!document.hidden && !shoppingBusy) loadShopping(true);
|
||||
}, 4000);
|
||||
}
|
||||
function stopShoppingPoll() {
|
||||
if (shoppingPollHandle) { clearInterval(shoppingPollHandle); shoppingPollHandle = null; }
|
||||
}
|
||||
|
||||
function shoppingErr(msg) {
|
||||
const el = $('shopping-err');
|
||||
if (!el) return;
|
||||
if (!msg) { el.classList.add('hidden'); el.textContent = ''; return; }
|
||||
el.textContent = msg;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function shoppingParseLines(body) {
|
||||
// Two node kinds end up on the phone:
|
||||
// heading — "# h1" … "###### h6" inside the body, used to
|
||||
// section the list (Obst / Backwaren / …)
|
||||
// item — unchecked "- [ ] …" lines (checked ones are hidden)
|
||||
// Then drop orphan headings (no open items before the next
|
||||
// heading or end of list) so empty section labels don't sit
|
||||
// around after everything under them is ticked off.
|
||||
const nodes = [];
|
||||
for (const line of String(body || '').split(/\r\n|\n|\r/)) {
|
||||
const hm = /^\s*(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
|
||||
if (hm) { nodes.push({ kind: 'heading', level: hm[1].length, text: hm[2] }); continue; }
|
||||
const im = /^\s*-\s*\[([ xX])\]\s*(.*)$/.exec(line);
|
||||
if (!im) continue;
|
||||
if (im[1].toLowerCase() === 'x') continue;
|
||||
nodes.push({ kind: 'item', text: im[2], line });
|
||||
}
|
||||
const out = [];
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (nodes[i].kind === 'heading') {
|
||||
let hasItem = false;
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
if (nodes[j].kind === 'heading') break;
|
||||
if (nodes[j].kind === 'item') { hasItem = true; break; }
|
||||
}
|
||||
if (!hasItem) continue;
|
||||
}
|
||||
out.push(nodes[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function loadShopping(quiet = false) {
|
||||
if (!quiet) shoppingErr('');
|
||||
const data = await api('api/shopping');
|
||||
const title = $('shopping-title');
|
||||
const list = $('shopping-items');
|
||||
if (!list) return;
|
||||
if (!data?.ok) { if (!quiet) shoppingErr(data?.error || 'load failed'); return; }
|
||||
if (!data.state) {
|
||||
if (title) title.textContent = '';
|
||||
list.innerHTML = '<li class="empty">no active shopping list</li>';
|
||||
return;
|
||||
}
|
||||
const { title: t, body } = data.state;
|
||||
if (title) title.textContent = t || '';
|
||||
const nodes = shoppingParseLines(body);
|
||||
const hasItems = nodes.some(n => n.kind === 'item');
|
||||
if (!hasItems) {
|
||||
list.innerHTML = '<li class="empty">\u2713 all done</li>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = '';
|
||||
for (const n of nodes) {
|
||||
const li = document.createElement('li');
|
||||
if (n.kind === 'heading') {
|
||||
li.className = 'shopping-heading h' + n.level;
|
||||
li.textContent = n.text;
|
||||
} else {
|
||||
li.textContent = n.text;
|
||||
li.dataset.line = n.line;
|
||||
li.addEventListener('click', () => shoppingCheck(li));
|
||||
}
|
||||
list.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
async function shoppingCheck(li) {
|
||||
if (shoppingBusy) return;
|
||||
shoppingBusy = true;
|
||||
li.classList.add('checked');
|
||||
try {
|
||||
const res = await api('api/shopping/check', { method: 'POST', body: { line: li.dataset.line, checked: true } });
|
||||
if (!res?.ok) throw new Error(res?.error || 'check failed');
|
||||
setTimeout(() => { shoppingBusy = false; loadShopping(true); }, 300);
|
||||
} catch (e) {
|
||||
shoppingBusy = false;
|
||||
li.classList.remove('checked');
|
||||
shoppingErr(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-refresh when the tab comes back into focus — same pattern as
|
||||
// the standalone page used to have, just without a manual button.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) return;
|
||||
if ($('view-shopping')?.classList.contains('hidden')) return;
|
||||
if (shoppingBusy) return;
|
||||
loadShopping(true);
|
||||
});
|
||||
|
||||
// ── Chat ──
|
||||
|
||||
let chatActivePeer = null;
|
||||
|
||||
@@ -23,6 +23,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
<span id="nav-user" class="user"></span>
|
||||
<button id="nav-compose" class="nav-btn active" onclick="showView('compose')"><i class="ph ph-pencil-line"></i> compose</button>
|
||||
<button id="nav-chat" class="nav-btn" onclick="showView('chat')"><i class="ph ph-chat-circle-dots"></i> chat</button>
|
||||
<button id="nav-shopping" class="nav-btn" onclick="showView('shopping')"><i class="ph ph-shopping-cart"></i> shopping</button>
|
||||
<button id="nav-account" class="nav-btn" onclick="showView('account')"><i class="ph ph-user-circle"></i> account</button>
|
||||
<button id="nav-admin" class="nav-btn hidden" onclick="showView('admin')"><i class="ph ph-users"></i> users</button>
|
||||
<button class="nav-btn danger" onclick="doLogout()"><i class="ph ph-sign-out"></i> logout</button>
|
||||
@@ -210,6 +211,21 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Shopping view ── -->
|
||||
<!-- Phone-friendly checklist. Shows only unchecked "- [ ]" items
|
||||
from the currently active shopping card. Tapping an item flips
|
||||
it to "- [x]" on the relay in place; hyprcrd pulls that back on
|
||||
the next sync tick. Edits to item text stay in hyprcrd. -->
|
||||
<div id="view-shopping" class="hidden">
|
||||
<div class="shopping-wrap">
|
||||
<div class="shopping-top">
|
||||
<span id="shopping-title" class="pixel" style="font-size:11px;color:var(--bright);">loading…</span>
|
||||
</div>
|
||||
<div id="shopping-err" class="shopping-err hidden"></div>
|
||||
<ul id="shopping-items" class="shopping-items"></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Chat view ── -->
|
||||
<div id="view-chat" class="hidden">
|
||||
<div class="chat-layout">
|
||||
|
||||
@@ -254,6 +254,43 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point
|
||||
display:flex; gap:8px; align-items:center;
|
||||
}
|
||||
.compose-card .actions .spacer { flex:1; }
|
||||
|
||||
/* Shopping view — phone-friendly checklist. Large tap targets,
|
||||
unchecked items only, tap to flip. */
|
||||
.shopping-wrap { max-width: 520px; margin: 0 auto; display: flex; flex-direction: column; gap: 12px; }
|
||||
.shopping-top { display: flex; align-items: center; gap: 10px; padding: 6px 2px; }
|
||||
.shopping-items { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.shopping-items li {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg);
|
||||
padding: 14px 16px; font-size: 16px; cursor: pointer; user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: transform 0.12s, opacity 0.3s, background 0.12s;
|
||||
}
|
||||
.shopping-items li:active { transform: scale(0.98); background: var(--raised, var(--surface)); }
|
||||
.shopping-items li.checked { opacity: 0.35; text-decoration: line-through; }
|
||||
.shopping-items .empty { text-align: center; padding: 40px 16px; color: var(--dim); background: transparent; border: none; cursor: default; }
|
||||
.shopping-items .empty:active { transform: none; }
|
||||
|
||||
/* Section headings between items — "# Obst", "## Backwaren", etc. */
|
||||
.shopping-items li.shopping-heading {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 18px 4px 4px;
|
||||
cursor: default;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.shopping-items li.shopping-heading:active { transform: none; background: transparent; opacity: 1; }
|
||||
.shopping-items li.shopping-heading:first-child { padding-top: 2px; }
|
||||
.shopping-items li.shopping-heading.h1 { font-size: 18px; color: var(--bright); }
|
||||
.shopping-items li.shopping-heading.h2 { font-size: 15px; color: var(--accent); }
|
||||
.shopping-items li.shopping-heading.h3,
|
||||
.shopping-items li.shopping-heading.h4,
|
||||
.shopping-items li.shopping-heading.h5,
|
||||
.shopping-items li.shopping-heading.h6 { font-size: 13px; color: var(--muted); }
|
||||
.shopping-err { background: var(--danger-bg, #7f1d1d); color: var(--danger-fg, #fecaca); padding: 8px 12px; border-radius: 6px; font-size: 13px; }
|
||||
.shopping-err.hidden { display: none; }
|
||||
|
||||
/* Chat view */
|
||||
.chat-layout { display: flex; height: calc(100vh - 200px); min-height: 300px; border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
|
||||
.chat-sidebar { width: 200px; border-right: 1px solid var(--border); display: flex; flex-direction: column; background: var(--surface); }
|
||||
|
||||
Reference in new issue
Block a user