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:
thandClaude Opus 4.6 committed 2026-04-16 01:24:58 +02:00
1 parent b9d610b612
commit d052fb5f9d
14 files changed
+417 -325

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
<?php <?php
define('APP_NAME', 'hyprcrd'); define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '1.9.20'); define('APP_VERSION', '1.9.21');
// Database path — writable directory, outside webroot // Database path — writable directory, outside webroot
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation // DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hyprcrd", "name": "hyprcrd",
"version": "1.9.20", "version": "1.9.21",
"description": "hyprcrd — self-hosted card manager", "description": "hyprcrd — self-hosted card manager",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
+4 -46
View File
@@ -1148,40 +1148,6 @@ if (str_starts_with($route, '/api/')) {
exit; exit;
} }
// ── Shopping list (proxy to relay) ───────────────────────────
// Thin forwarder. The client (shopping.js) talks to us, we relay
// to /api/shopping with the configured X-Relay-User/-Key headers.
// Phone UI talks to the relay directly via session auth.
if (($route === '/api/shopping' || $route === '/api/shopping/check') && in_array($method, ['GET', 'POST'])) {
$relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn();
$relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn();
$relayUser = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn();
if (!$relayUrl || !$relayKey || !$relayUser) {
echo json_encode(['ok' => false, 'error' => 'relay not configured']); exit;
}
$ch = curl_init(rtrim($relayUrl, '/') . $route);
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Relay-User: ' . $relayUser,
'X-Relay-Key: ' . $relayKey,
],
CURLOPT_TIMEOUT => 10,
];
if ($method === 'POST') {
$opts[CURLOPT_POST] = true;
$opts[CURLOPT_POSTFIELDS] = file_get_contents('php://input') ?: '{}';
}
curl_setopt_array($ch, $opts);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
http_response_code($status ?: 502);
echo is_string($response) ? $response : json_encode(['ok' => false, 'error' => 'relay unreachable']);
exit;
}
http_response_code(404); echo json_encode(['error' => 'not found']); exit; http_response_code(404); echo json_encode(['error' => 'not found']); exit;
} }
@@ -1357,14 +1323,6 @@ if (str_starts_with($route, '/api/')) {
</div> </div>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="relative" id="header-menu-wrap">
<button id="header-menu-btn" class="flex items-center gap-1 px-2 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="menu">
<i class="ph ph-list-dashes text-sm"></i>
</button>
<div id="header-menu" class="hidden absolute right-0 mt-1 min-w-[180px] bg-t-surface border border-t-border shadow-2xl z-50">
<button id="menu-shopping" class="w-full text-left px-3 py-2 text-xs text-t-muted hover:bg-t-raised hover:text-t-accent transition-colors flex items-center gap-2"><i class="ph ph-shopping-cart text-sm"></i><span class="font-pixel text-[10px]">Shopping list</span></button>
</div>
</div>
<button id="calendar-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"> <button id="calendar-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
<i class="ph ph-calendar-dots text-sm"></i><span class="font-pixel text-[10px]">Calendar</span> <i class="ph ph-calendar-dots text-sm"></i><span class="font-pixel text-[10px]">Calendar</span>
</button> </button>
@@ -1693,14 +1651,14 @@ if (str_starts_with($route, '/api/')) {
<section> <section>
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-shopping-cart text-sm"></i>shopping list (phone sync)</h3> <h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-shopping-cart text-sm"></i>shopping list (phone sync)</h3>
<div class="text-xs leading-relaxed space-y-3 text-t-muted mb-3"> <div class="text-xs leading-relaxed space-y-3 text-t-muted mb-3">
<p>Tag a card with <span class="text-t-accent">#einkauf</span> or <span class="text-t-accent">#shopping</span> to mark it as a shopping-list candidate. Open the header's <i class="ph ph-list-dashes text-xs"></i> menu → <span class="text-t-bright font-pixel">Shopping list</span> to pick which one is the currently <span class="text-t-bright">active</span> list — only one at a time is mirrored to the phone.</p> <p>Tag a card with <span class="text-t-accent">#einkauf</span> or <span class="text-t-accent">#shopping</span> to mark it as a shopping-list candidate. Click the header's <span class="text-t-bright font-pixel">Shopping</span> button to pick which one is the currently <span class="text-t-bright">active</span> list — only one at a time is mirrored to the phone.</p>
<p>The active card's body is pushed to the relay on every sync tick. On your phone, open <span class="text-t-accent">&lt;your-relay&gt;/shopping</span> and log in with your relay credentials. The page shows only the <span class="text-t-bright">unchecked</span> <code class="text-t-accent">- [ ]</code> items as tappable rows. Tapping an item flips it to <code class="text-t-accent">- [x]</code> on the relay in-place; the next client tick pulls that back and updates the local card.</p> <p>The active card's body is pushed to the relay on every sync tick. On your phone, log into the relay UI (same credentials as everywhere else) and open <span class="text-t-bright font-pixel">shopping</span> from the menu. You see only the <span class="text-t-bright">unchecked</span> <code class="text-t-accent">- [ ]</code> items as tappable rows. Tapping an item flips it to <code class="text-t-accent">- [x]</code> on the relay in-place; the next client tick pulls that back and updates the local card.</p>
<p>Editing stays in hyprcrd — the phone can only check/uncheck, never change text. That keeps conflict handling trivial (last-writer wins by timestamp).</p> <p>Editing stays in hyprcrd — the phone can only check/uncheck, never change text. That keeps conflict handling trivial (last-writer wins by timestamp).</p>
</div> </div>
<table class="w-full text-xs"> <table class="w-full text-xs">
<tr><td class="py-1 text-t-dim w-40 font-pixel"><i class="ph ph-hash text-xs"></i> trigger tags</td><td><span class="text-t-accent">#einkauf</span> or <span class="text-t-accent">#shopping</span> — either counts</td></tr> <tr><td class="py-1 text-t-dim w-40 font-pixel"><i class="ph ph-hash text-xs"></i> trigger tags</td><td><span class="text-t-accent">#einkauf</span> or <span class="text-t-accent">#shopping</span> — either counts</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-list-dashes text-xs"></i> pick active</td><td>header menu → Shopping list → radio select → Save</td></tr> <tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-shopping-cart text-xs"></i> pick active</td><td>header Shopping button → radio select → Save</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-device-mobile text-xs"></i> phone URL</td><td><code>https://&lt;relay&gt;/shopping</code> — session-auth, mobile layout</td></tr> <tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-device-mobile text-xs"></i> phone access</td><td>relay UI → shopping tab (same login)</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-arrows-clockwise text-xs"></i> sync cadence</td><td>piggybacks on the shared sync tick (see Settings → Relay)</td></tr> <tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-arrows-clockwise text-xs"></i> sync cadence</td><td>piggybacks on the shared sync tick (see Settings → Relay)</td></tr>
</table> </table>
</section> </section>
-2
View File
@@ -21,7 +21,6 @@ import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar
import { loadSettings as loadCaldavSettings } from './modules/caldav.js'; import { loadSettings as loadCaldavSettings } from './modules/caldav.js';
import { startBackgroundSync, triggerSync, loadRelaySettings } from './modules/sync.js'; import { startBackgroundSync, triggerSync, loadRelaySettings } from './modules/sync.js';
import { openPluginSettings } from './modules/plugin-settings.js'; import { openPluginSettings } from './modules/plugin-settings.js';
import { initShopping } from './modules/shopping.js';
// ── Global API (for inline onclick handlers and cross-module calls) ── // ── Global API (for inline onclick handlers and cross-module calls) ──
@@ -218,7 +217,6 @@ async function checkAuth() {
loadBuckets(); loadBuckets();
loadCards(); loadCards();
startBackgroundSync(); startBackgroundSync();
initShopping();
} }
// ── Keyboard shortcuts (central registry) ── // ── Keyboard shortcuts (central registry) ──
+67
View File
@@ -0,0 +1,67 @@
<?php
/**
* Shopping Plugin: backend.
*
* Thin forwarder from the client to the relay's /api/shopping*
* endpoints. The phone-side view is part of the relay UI; this
* plugin just pipes hyprcrd's push/pull through the configured
* relay credentials.
*
* Routes:
* GET /api/shopping — pull active list state from relay
* POST /api/shopping — push active card {uid,title,body}
* POST /api/shopping/check — (mostly phone-only; proxied for
* completeness/tests — relay will
* 401 for client-auth callers)
*/
function shoppingJson($data, int $code = 200): void {
http_response_code($code);
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
function shoppingRelay(): array {
$db = Database::connect();
return [
'url' => $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn() ?: '',
'user' => $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn() ?: '',
'key' => $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn() ?: '',
];
}
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
if ($route !== '/api/shopping' && $route !== '/api/shopping/check') return;
if (!in_array($method, ['GET', 'POST'], true)) return;
$r = shoppingRelay();
if (!$r['url'] || !$r['user'] || !$r['key']) {
shoppingJson(['ok' => false, 'error' => 'relay not configured']);
}
$ch = curl_init(rtrim($r['url'], '/') . $route);
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Relay-User: ' . $r['user'],
'X-Relay-Key: ' . $r['key'],
],
CURLOPT_TIMEOUT => 10,
];
if ($method === 'POST') {
$opts[CURLOPT_POST] = true;
$opts[CURLOPT_POSTFIELDS] = file_get_contents('php://input') ?: '{}';
}
curl_setopt_array($ch, $opts);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
http_response_code($status ?: 502);
header('Content-Type: application/json');
echo is_string($response) ? $response : json_encode(['ok' => false, 'error' => 'relay unreachable']);
exit;
});
+10
View File
@@ -0,0 +1,10 @@
{
"name": "shopping",
"version": "1.0.0",
"description": "Ship one shopping card at a time to the relay so it's checkable from your phone. hyprcrd stays the edit surface; phone only toggles.",
"frontend": {
"js": ["shopping.js"],
"css": ["shopping.css"]
},
"backend": "backend.php"
}
+6
View File
@@ -0,0 +1,6 @@
/* Shopping plugin styles. Most of the picker dialog leans on
existing Tailwind tokens (text-t-accent, border-t-border, etc.)
so there's little needed here — only things that can't be
expressed inline on dynamically-created nodes. */
.shopping-picker-overlay { font-family: var(--t-font-mono, inherit); }
@@ -1,22 +1,27 @@
// ── Shopping list (phone-synced card) ── // ── Shopping-list plugin (hyprcrd side) ──
// //
// One card flagged as #einkauf can be "active" at a time. Its body // Tag a card with #einkauf or #shopping to make it eligible. Pick
// is pushed to the relay on every sync.tick; the relay serves a // one as "active" via the Shopping header button; the active card's
// phone-friendly checklist at /shopping. Phone taps toggle items in // body is pushed to the relay on every sync.tick and on every
// place on the relay; hyprcrd pulls those toggles back on the next // cards.reloaded so edits land on the phone within seconds. Pulls
// tick and patches the local card. // phone-side toggles back and PATCHes the local card.
// //
// Phone never changes item text — only check state. Text edits stay // No timestamp cursors — we diff the (uid, title, body) triple we
// in hyprcrd, which keeps conflict resolution trivial (last-writer // last saw in sync with the relay, which is timezone-agnostic.
// wins by timestamp; near-simultaneous edits are extremely unlikely
// for a shopping list).
import { $, api, esc, getCsrf } from './util.js'; const hyprcrd = window.hyprcrd;
import { state } from './state.js'; if (!hyprcrd) throw new Error('[shopping plugin] window.hyprcrd missing');
const $ = (id) => document.getElementById(id);
const ACTIVE_KEY = 'hyprcrd-shopping-active-id'; const ACTIVE_KEY = 'hyprcrd-shopping-active-id';
const LAST_PUSHED_KEY = 'hyprcrd-shopping-last-pushed-ts'; const SYNCED_KEY = 'hyprcrd-shopping-synced';
const LAST_SERVER_KEY = 'hyprcrd-shopping-last-server-ts';
function esc(s) {
return String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function hasEinkaufTag(card) { function hasEinkaufTag(card) {
const tags = (card.tags || '').split(',').map(s => s.trim()); const tags = (card.tags || '').split(',').map(s => s.trim());
@@ -27,22 +32,48 @@ function getActiveId() {
const v = localStorage.getItem(ACTIVE_KEY); const v = localStorage.getItem(ACTIVE_KEY);
return v ? String(v) : null; return v ? String(v) : null;
} }
function setActiveId(id) { function setActiveId(id) {
if (id === null || id === '') localStorage.removeItem(ACTIVE_KEY); if (id === null || id === '') localStorage.removeItem(ACTIVE_KEY);
else localStorage.setItem(ACTIVE_KEY, String(id)); else localStorage.setItem(ACTIVE_KEY, String(id));
// Reset timestamp cursors so the switch pushes fresh on next tick. localStorage.removeItem(SYNCED_KEY);
localStorage.removeItem(LAST_PUSHED_KEY);
localStorage.removeItem(LAST_SERVER_KEY);
} }
function getActiveCard() { function getActiveCard() {
const id = getActiveId(); const id = getActiveId();
if (!id) return null; if (!id) return null;
return state.cards.find(c => String(c.id) === id) || null; const cards = window.state?.cards || [];
return cards.find(c => String(c.id) === id) || null;
} }
// ── Dialog ──────────────────────────────────────────────────────── function getSynced() {
try { return JSON.parse(localStorage.getItem(SYNCED_KEY) || 'null'); } catch { return null; }
}
function setSynced(uid, title, body) {
localStorage.setItem(SYNCED_KEY, JSON.stringify({ uid: String(uid), title: title || '', body: body || '' }));
}
// ── Header button injection ──────────────────────────────────
function injectHeaderButton() {
if ($('shopping-btn')) return;
// Put it next to Calendar. The header's right-hand button row
// lives in `header > div:last-child > .flex.items-center.gap-3`.
const header = document.querySelector('header#main-header');
if (!header) return;
const rightRow = header.querySelectorAll('.flex.items-center.gap-3');
const group = rightRow[rightRow.length - 1];
if (!group) return;
const btn = document.createElement('button');
btn.id = 'shopping-btn';
btn.className = 'flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors';
btn.innerHTML = '<i class="ph ph-shopping-cart text-sm"></i><span class="font-pixel text-[10px]">Shopping</span>';
btn.addEventListener('click', openDialog);
// Insert as the first item of the right-hand group so the order
// reads Shopping, Calendar, Assets, Tasks, New.
group.insertBefore(btn, group.firstChild);
}
// ── Picker dialog ────────────────────────────────────────────
function openDialog() { function openDialog() {
document.querySelector('.shopping-picker-overlay')?.remove(); document.querySelector('.shopping-picker-overlay')?.remove();
@@ -63,7 +94,7 @@ function openDialog() {
Cards tagged with <span class="text-t-accent">#einkauf</span> or 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 <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 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>. and appears under the <span class="text-t-accent">shopping</span> tab in the relay UI.
</div> </div>
<div class="flex-1 overflow-y-auto px-2 py-2" id="shopping-picker-list"></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"> <div class="flex items-center gap-2 px-5 py-3 border-t border-t-border">
@@ -81,11 +112,12 @@ function openDialog() {
panel.querySelector('.shopping-picker-close').addEventListener('click', close); panel.querySelector('.shopping-picker-close').addEventListener('click', close);
panel.querySelector('.shopping-picker-cancel').addEventListener('click', close); panel.querySelector('.shopping-picker-cancel').addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
document.addEventListener('keydown', function esc(e) { document.addEventListener('keydown', function escClose(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', esc); } if (e.key === 'Escape') { close(); document.removeEventListener('keydown', escClose); }
}); });
const candidates = state.cards.filter(hasEinkaufTag); const cards = window.state?.cards || [];
const candidates = cards.filter(hasEinkaufTag);
const currentActive = getActiveId(); const currentActive = getActiveId();
let selectedId = currentActive; let selectedId = currentActive;
@@ -109,7 +141,6 @@ function openDialog() {
</div> </div>
</label>`; </label>`;
}).join(''); }).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"> 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;"> <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> <span class="text-[11px] text-t-muted italic">(no active list)</span>
@@ -136,93 +167,74 @@ function openDialog() {
}); });
} }
// ── Sync ─────────────────────────────────────────────────────── // ── Sync ─────────────────────────────────────────────────────
async function pushActive() { async function pushActive() {
const card = getActiveCard(); const card = getActiveCard();
if (!card) return; if (!card) return;
const localTs = card.updated_at || new Date().toISOString().replace('T', ' ').slice(0, 19); const uid = String(card.id);
const lastPushed = localStorage.getItem(LAST_PUSHED_KEY); const title = card.title || '';
if (lastPushed && lastPushed >= localTs) return; // nothing newer const body = card.body || '';
const res = await api('api/shopping', { const synced = getSynced();
if (synced && synced.uid === uid && synced.title === title && synced.body === body) return;
const res = await hyprcrd.api('api/shopping', {
method: 'POST', method: 'POST',
body: { card_uid: String(card.id), title: card.title || '', body: card.body || '', updated_at: localTs }, body: { card_uid: uid, title, body, updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) },
}); });
if (res?.ok) { if (res?.ok && res.stored !== false) setSynced(uid, title, body);
localStorage.setItem(LAST_PUSHED_KEY, localTs);
if (res.updated_at_server) localStorage.setItem(LAST_SERVER_KEY, res.updated_at_server);
}
} }
async function pullActive() { async function pullActive() {
const card = getActiveCard(); const card = getActiveCard();
if (!card) return; if (!card) return;
const res = await api('api/shopping', { method: 'GET' }); const res = await hyprcrd.api('api/shopping', { method: 'GET' });
if (!res?.ok || !res.state) return; if (!res?.ok || !res.state) return;
const { card_uid, body, updated_at_server } = res.state; const { card_uid, body, title } = res.state;
if (String(card_uid) !== String(card.id)) return; // relay still has a previous list if (String(card_uid) !== String(card.id)) return;
const seen = localStorage.getItem(LAST_SERVER_KEY); const synced = getSynced();
if (seen && seen >= updated_at_server) return; // no news if (synced && synced.uid === String(card.id) && synced.body === (body || '') && synced.title === (title || '')) return;
if ((body || '') === (card.body || '')) { if ((card.body || '') === (body || '') && (card.title || '') === (title || '')) {
localStorage.setItem(LAST_SERVER_KEY, updated_at_server); setSynced(card.id, card.title || '', card.body || '');
return; return;
} }
// Phone toggled something — patch the local card. // Relay differs from both the sync marker and the local card — phone
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body } }); // toggled. Phone can only flip checkboxes (never edit text), so the
localStorage.setItem(LAST_SERVER_KEY, updated_at_server); // relay body is authoritative for this tick.
localStorage.setItem(LAST_PUSHED_KEY, updated_at_server); // suppress immediate re-push await hyprcrd.api(`api/cards/${card.id}`, { method: 'PATCH', body: { body } });
setSynced(card.id, card.title || '', body || '');
await window._reloadAll?.(); await window._reloadAll?.();
} }
async function clearRemote() { async function clearRemote() {
// No DELETE endpoint — send an empty list that "displaces" the await hyprcrd.api('api/shopping', {
// previous active card on the relay so the phone sees nothing.
await api('api/shopping', {
method: 'POST', method: 'POST',
body: { card_uid: '__inactive__', title: '', body: '', updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) }, body: { card_uid: '__inactive__', title: '', body: '', updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) },
}); });
localStorage.removeItem(LAST_PUSHED_KEY); localStorage.removeItem(SYNCED_KEY);
localStorage.removeItem(LAST_SERVER_KEY);
} }
async function tick() { async function tick() {
if (!getActiveId()) return; if (!getActiveId()) return;
// Pull first so phone toggles land before we overwrite with a push.
try { await pullActive(); } catch {}
try { await pushActive(); } catch {} try { await pushActive(); } catch {}
try { await pullActive(); } catch {}
} }
// ── Hamburger menu wiring ───────────────────────────────────── // ── Init ─────────────────────────────────────────────────────
function initMenu() { function init() {
const btn = $('header-menu-btn'); injectHeaderButton();
const menu = $('header-menu'); hyprcrd.hooks?.on?.('sync.tick', () => { tick().catch(() => {}); });
if (!btn || !menu) return; hyprcrd.hooks?.on?.('cards.reloaded', () => { tick().catch(() => {}); });
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() { if (document.readyState === 'loading') {
initMenu(); document.addEventListener('DOMContentLoaded', init);
// Hook into the shared sync heartbeat so push/pull happens on the } else {
// same cadence as every other poller — one timer rules them all. init();
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);
}
});
} }
hyprcrd.registerPlugin?.({
name: 'shopping',
version: '1.0.0',
description: 'Sync one shopping card to the relay so it\'s checkable from a phone.',
});
+37 -23
View File
@@ -1,10 +1,18 @@
import { describe, it, expect, $, click, sleep, csrf } from './runner.js?v=4'; import { describe, it, expect, $, click, sleep, csrf } from './runner.js?v=4';
// Shopping-list tests. The proxy route is always present; without a // Shopping-plugin tests. Everything ships through the shopping plugin
// configured relay it just returns {ok:false,error:"relay not // (backend.php registers /api/shopping*, shopping.js injects the
// configured"} rather than 404, so we can still assert shape. The UI // header button and the picker dialog on load). Without a configured
// tests create cards with either trigger tag and verify the picker // relay the backend still returns {ok:false,error:"relay not
// finds them + skips non-tagged cards. // configured"} so we can assert shape.
describe('Shopping: plugin registration', () => {
it('registers itself in window.hyprcrd.plugins', () => {
const entries = window.hyprcrd?.plugins || [];
const entry = entries.find(p => p?.name === 'shopping');
expect(entry).toExist();
});
});
describe('Shopping: API proxy', () => { describe('Shopping: API proxy', () => {
it('GET /api/shopping returns an ok field', async () => { it('GET /api/shopping returns an ok field', async () => {
@@ -27,22 +35,25 @@ describe('Shopping: API proxy', () => {
}); });
}); });
describe('Shopping: header menu', () => { describe('Shopping: header button', () => {
it('hamburger button + menu exist; clicking toggles visibility', async () => { it('Shopping button is injected into the header', () => {
const btn = $('#header-menu-btn'); const btn = $('#shopping-btn');
const menu = $('#header-menu');
expect(btn).toExist(); expect(btn).toExist();
expect(menu).toExist(); // Lives inside the main header, not bolted onto the body.
// Make sure we start closed. expect(btn.closest('#main-header')).toExist();
menu.classList.add('hidden'); });
click(btn);
it('clicking opens the picker dialog with save/close controls', async () => {
document.querySelector('.shopping-picker-overlay')?.remove();
click('#shopping-btn');
await sleep(60);
const overlay = document.querySelector('.shopping-picker-overlay');
expect(overlay).toExist();
expect(overlay.querySelector('.shopping-picker-save')).toExist();
expect(overlay.querySelector('.shopping-picker-cancel')).toExist();
overlay.querySelector('.shopping-picker-cancel').click();
await sleep(40); await sleep(40);
expect(menu.classList.contains('hidden')).toBe(false); expect(document.querySelector('.shopping-picker-overlay')).toBe(null);
expect($('#menu-shopping')).toExist();
// Outside-click closer — dispatch a click elsewhere.
document.body.click();
await sleep(40);
expect(menu.classList.contains('hidden')).toBe(true);
}); });
}); });
@@ -63,10 +74,7 @@ describe('Shopping: picker tag matching', () => {
// Close any leftover overlay, then open the picker fresh. // Close any leftover overlay, then open the picker fresh.
document.querySelector('.shopping-picker-overlay')?.remove(); document.querySelector('.shopping-picker-overlay')?.remove();
$('#header-menu')?.classList.add('hidden'); click('#shopping-btn');
click('#header-menu-btn');
await sleep(40);
click('#menu-shopping');
await sleep(80); await sleep(80);
const overlay = document.querySelector('.shopping-picker-overlay'); const overlay = document.querySelector('.shopping-picker-overlay');
@@ -78,6 +86,12 @@ describe('Shopping: picker tag matching', () => {
const controlShown = listText.includes('__test_shop_control__'); const controlShown = listText.includes('__test_shop_control__');
expect(controlShown).toBe(false); expect(controlShown).toBe(false);
// Picker always offers a "no active list" option so the
// user can stop syncing without leaving the dialog.
const radios = overlay.querySelectorAll('input[name="shopping-active"]');
const hasEmpty = [...radios].some(r => r.value === '');
expect(hasEmpty).toBe(true);
overlay.querySelector('.shopping-picker-cancel').click(); overlay.querySelector('.shopping-picker-cancel').click();
await sleep(40); await sleep(40);
} finally { } finally {
+11 -163
View File
@@ -32,24 +32,15 @@ if ($route === '/api/shopping' && $method === 'POST') {
$uid = trim((string)($input['card_uid'] ?? '')); $uid = trim((string)($input['card_uid'] ?? ''));
$title = (string)($input['title'] ?? ''); $title = (string)($input['title'] ?? '');
$body = (string)($input['body'] ?? ''); $body = (string)($input['body'] ?? '');
$clientTs = trim((string)($input['updated_at'] ?? '')) ?: date('Y-m-d H:i:s'); $clientTs = trim((string)($input['updated_at'] ?? '')) ?: gmdate('Y-m-d H:i:s');
if ($uid === '') jsonResponse(['error' => 'card_uid required'], 400); if ($uid === '') jsonResponse(['error' => 'card_uid required'], 400);
$existing = shoppingLoad($db, (int)$clientTenant['id']); // No server-side staleness guard: the client decides whether to
// Skip the write when the client is pushing an older version of // push by diffing its last-synced body against the current card,
// the same card than what's already stored (phone toggled // not by timestamp, so cross-timezone clock comparisons can't
// meanwhile). Still return 200 so the client doesn't retry in a // silently block edits. Last-writer per tick wins — phone-toggle
// loop — they just haven't pulled yet. // races lose at most one tick of flips (rare by design).
if ($existing && $existing['card_uid'] === $uid $now = gmdate('Y-m-d H:i:s');
&& 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(' $db->prepare('
INSERT INTO shopping_state (tenant_id, card_uid, title, body, updated_at_client, updated_at_server) INSERT INTO shopping_state (tenant_id, card_uid, title, body, updated_at_client, updated_at_server)
VALUES (:t, :u, :ti, :b, :c, :s) VALUES (:t, :u, :ti, :b, :c, :s)
@@ -113,155 +104,12 @@ if ($route === '/api/shopping/check' && $method === 'POST') {
if (!$hit) jsonResponse(['error' => 'line not found (list may have been edited — reload)'], 409); if (!$hit) jsonResponse(['error' => 'line not found (list may have been edited — reload)'], 409);
$newBody = implode("\n", $lines); $newBody = implode("\n", $lines);
$now = date('Y-m-d H:i:s'); $now = gmdate('Y-m-d H:i:s');
$db->prepare('UPDATE shopping_state SET body = :b, updated_at_server = :s WHERE tenant_id = :t') $db->prepare('UPDATE shopping_state SET body = :b, updated_at_server = :s WHERE tenant_id = :t')
->execute(['b' => $newBody, 's' => $now, 't' => $sessionTenant['id']]); ->execute(['b' => $newBody, 's' => $now, 't' => $sessionTenant['id']]);
jsonResponse(['ok' => true, 'updated_at_server' => $now]); jsonResponse(['ok' => true, 'updated_at_server' => $now]);
} }
// ── GET /shopping ──────────────────────────────────────────── // Shopping list lives inside the relay UI as a regular view (see
// Phone UI. Simple static shell; the JS fetches /api/shopping and // relay/ui/layout.php + app.js). No separate /shopping page — login
// renders only unchecked items. Tapping one POSTs to /api/shopping/check. // and navigation are the same as for every other relay feature.
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);
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<meta name="theme-color" content="#0f172a">
<title>Shopping list</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: #0f172a; color: #e2e8f0; font-family: system-ui, -apple-system, sans-serif; }
header { position: sticky; top: 0; background: #0f172a; padding: 14px 16px; border-bottom: 1px solid #334155; display: flex; align-items: center; gap: 10px; }
header h1 { margin: 0; font-size: 16px; font-weight: 600; flex: 1; }
header .user { font-size: 12px; color: #64748b; }
header .refresh { background: none; border: 1px solid #334155; color: #94a3b8; padding: 6px 10px; border-radius: 6px; font-size: 12px; cursor: pointer; }
main { padding: 12px; }
.title { font-size: 13px; color: #94a3b8; margin: 0 0 12px; }
.empty { text-align: center; padding: 40px 16px; color: #64748b; }
ul { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; }
li { background: #1e293b; border: 1px solid #334155; border-radius: 8px; 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; }
li:active { transform: scale(0.98); background: #273446; }
li.checked { opacity: 0.35; text-decoration: line-through; }
.stale { font-size: 11px; color: #64748b; text-align: center; margin-top: 16px; }
.err { background: #7f1d1d; color: #fecaca; padding: 8px 12px; border-radius: 6px; margin-bottom: 12px; font-size: 13px; }
</style>
</head>
<body>
<header>
<h1>Shopping list</h1>
<span class="user"><?= $userLabel ?></span>
<button class="refresh" id="refresh">↻</button>
</header>
<main>
<p class="title" id="title">…</p>
<div id="err" class="err" style="display:none"></div>
<ul id="items"></ul>
<p class="stale" id="stale"></p>
</main>
<script>
(function(){
const base = location.pathname.replace(/\/shopping\/?$/, '');
const elTitle = document.getElementById('title');
const elItems = document.getElementById('items');
const elStale = document.getElementById('stale');
const elErr = document.getElementById('err');
const btnRefresh = document.getElementById('refresh');
let lastServerTs = null;
let busy = false;
function showErr(msg) {
if (!msg) { elErr.style.display = 'none'; elErr.textContent = ''; return; }
elErr.textContent = msg; elErr.style.display = 'block';
}
function parseItems(body) {
const lines = String(body || '').split(/\r\n|\n|\r/);
const items = [];
for (const l of lines) {
const m = /^\s*-\s*\[([ xX])\]\s*(.*)$/.exec(l);
if (!m) continue;
const checked = m[1].toLowerCase() === 'x';
if (!checked) items.push({ line: l, text: m[2] });
}
return items;
}
async function load() {
try {
const res = await fetch(base + '/api/shopping', { credentials: 'same-origin' });
const data = await res.json();
if (!data.ok || !data.state) {
elTitle.textContent = '';
elItems.innerHTML = '<div class="empty">no active shopping list</div>';
elStale.textContent = '';
return;
}
const { title, body, updated_at_server } = data.state;
lastServerTs = updated_at_server;
elTitle.textContent = title || '';
const items = parseItems(body);
if (!items.length) {
elItems.innerHTML = '<div class="empty">✓ all done</div>';
} else {
elItems.innerHTML = '';
for (const it of items) {
const li = document.createElement('li');
li.textContent = it.text;
li.dataset.line = it.line;
li.addEventListener('click', () => check(li));
elItems.appendChild(li);
}
}
const ago = Math.round((Date.now() - Date.parse(updated_at_server.replace(' ','T') + 'Z')) / 1000);
elStale.textContent = 'last synced: ' + (ago < 60 ? ago + 's ago' : Math.round(ago/60) + ' min ago');
showErr('');
} catch (e) {
showErr('load failed: ' + e.message);
}
}
async function check(li) {
if (busy) return;
busy = true;
li.classList.add('checked');
try {
const res = await fetch(base + '/api/shopping/check', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ line: li.dataset.line, checked: true }),
});
const data = await res.json();
if (!data.ok) throw new Error(data.error || 'check failed');
setTimeout(() => { busy = false; load(); }, 300);
} catch (e) {
busy = false;
li.classList.remove('checked');
showErr(e.message);
}
}
btnRefresh.addEventListener('click', () => load());
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
load();
setInterval(() => { if (!document.hidden && !busy) load(); }, 15000);
})();
</script>
</body>
</html>
<?php
exit;
}
-4
View File
@@ -72,8 +72,4 @@ if (str_starts_with($route, '/api/')) {
// ── UI ───────────────────────────────────────────────────────────── // ── 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'; require __DIR__ . '/ui/layout.php';
+132 -2
View File
@@ -68,15 +68,16 @@ function showView(name) {
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) { if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
name = 'change-password'; 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-' + v)?.classList.add('hidden');
}); });
$('view-' + name)?.classList.remove('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-buttons').classList.remove('hidden');
$('nav-compose').classList.toggle('active', name === 'compose'); $('nav-compose').classList.toggle('active', name === 'compose');
$('nav-chat')?.classList.toggle('active', name === 'chat'); $('nav-chat')?.classList.toggle('active', name === 'chat');
$('nav-shopping')?.classList.toggle('active', name === 'shopping');
$('nav-account').classList.toggle('active', name === 'account'); $('nav-account').classList.toggle('active', name === 'account');
$('nav-admin').classList.toggle('active', name === 'admin'); $('nav-admin').classList.toggle('active', name === 'admin');
$('nav-burger')?.classList.remove('hidden'); $('nav-burger')?.classList.remove('hidden');
@@ -105,6 +106,12 @@ function showView(name) {
} else { } else {
stopChatPoll(); stopChatPoll();
} }
if (name === 'shopping') {
loadShopping();
startShoppingPoll();
} else {
stopShoppingPoll();
}
if (name === 'admin') { if (name === 'admin') {
loadUsers(); loadUsers();
} else if (name === 'account') { } else if (name === 'account') {
@@ -695,6 +702,129 @@ window.deleteAllMessages = deleteAllMessages;
/** Lightweight relative-time formatter for the inbox. SQLite gives us /** Lightweight relative-time formatter for the inbox. SQLite gives us
* `YYYY-MM-DD HH:MM:SS` (UTC). Returns "2 min ago", "today 14:32", * `YYYY-MM-DD HH:MM:SS` (UTC). Returns "2 min ago", "today 14:32",
* or a full date for older messages. */ * 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 ── // ── Chat ──
let chatActivePeer = null; let chatActivePeer = null;
+16
View File
@@ -23,6 +23,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
<span id="nav-user" class="user"></span> <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-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-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-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 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> <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>
</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 ── --> <!-- ── Chat view ── -->
<div id="view-chat" class="hidden"> <div id="view-chat" class="hidden">
<div class="chat-layout"> <div class="chat-layout">
+37
View File
@@ -254,6 +254,43 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point
display:flex; gap:8px; align-items:center; display:flex; gap:8px; align-items:center;
} }
.compose-card .actions .spacer { flex:1; } .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 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-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); } .chat-sidebar { width: 200px; border-right: 1px solid var(--border); display: flex; flex-direction: column; background: var(--surface); }