shopping list: phone-synced card via relay
Pick one #einkauf-tagged card as "active"; its body is pushed to the
relay on every sync.tick and rendered on a phone-friendly session-
auth'd page at /shopping. Phone shows only the open "- [ ]" items as
tappable rows — tapping flips the line to "- [x]" on the relay in
place. The next client tick pulls those toggles back and PATCHes the
local card, so hyprcrd stays the source of truth for item text.
- Relay: shopping_state table (one row per tenant), POST/GET
/api/shopping for content push/pull, POST /api/shopping/check
for phone toggles, GET /shopping for the phone UI.
- Client: hamburger menu in the header with an "Einkaufsliste"
entry that opens a picker listing all #einkauf cards with a
radio for the active one. Timestamp cursors in localStorage
keep push/pull idempotent.
- Phone can only toggle, not edit text — edit stays in hyprcrd,
which makes the merge trivial (last-writer wins by timestamp).
Multi-card support is there (any card with #einkauf is a candidate)
but only one is active for the phone at a time, per the spec.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c2485b1307
commit
670f125627
8 files changed
+523
-2
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
define('APP_NAME', 'hyprcrd');
|
||||||
define('APP_VERSION', '1.9.17');
|
define('APP_VERSION', '1.9.18');
|
||||||
|
|
||||||
// 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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hyprcrd",
|
"name": "hyprcrd",
|
||||||
"version": "1.9.17",
|
"version": "1.9.18",
|
||||||
"description": "hyprcrd — self-hosted card manager",
|
"description": "hyprcrd — self-hosted card manager",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1323,6 +1323,14 @@ 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]">Einkaufsliste</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>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ 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) ──
|
||||||
|
|
||||||
@@ -217,6 +218,7 @@ async function checkAuth() {
|
|||||||
loadBuckets();
|
loadBuckets();
|
||||||
loadCards();
|
loadCards();
|
||||||
startBackgroundSync();
|
startBackgroundSync();
|
||||||
|
initShopping();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Keyboard shortcuts (central registry) ──
|
// ── Keyboard shortcuts (central registry) ──
|
||||||
|
|||||||
@@ -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 = `
|
||||||
|
<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">Einkaufsliste</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">
|
||||||
|
Karten mit dem Tag <span class="text-t-accent">#einkauf</span> 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 <span class="text-t-accent">/shopping</span> angezeigt.
|
||||||
|
</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">
|
||||||
|
Keine Karten mit dem Tag <span class="text-t-accent">#einkauf</span>.
|
||||||
|
Lege eine Karte mit Checkbox-Items an und gib ihr den Tag, dann erscheint sie hier.
|
||||||
|
</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} offen · ${items.length} gesamt</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">(keine aktive Liste)</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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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)');
|
$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
|
// Sync-event notifications: replaces the old bot-chat mechanism. When a
|
||||||
// client syncs a card they received, they POST here; the sender polls
|
// 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
|
// GET /api/sync-events and surfaces each as a transient toast. Rows are
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
<?php
|
||||||
|
// Shopping-list routes:
|
||||||
|
// POST /api/shopping (client) upsert active card {uid, title, body, updated_at}
|
||||||
|
// GET /api/shopping (client + session) read current state
|
||||||
|
// POST /api/shopping/check (session) toggle one line {line, checked}
|
||||||
|
// GET /shopping (session) phone-friendly UI
|
||||||
|
//
|
||||||
|
// Plaintext on purpose — shopping-list content is low-stakes and
|
||||||
|
// everything here is session- or client-auth'd. No per-tenant key
|
||||||
|
// dance like chat / collab-buckets. If that needs tightening later,
|
||||||
|
// it's a one-table migration.
|
||||||
|
|
||||||
|
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||||
|
|
||||||
|
$clientTenant = $clientTenant ?? authClient($db);
|
||||||
|
$tenant = $sessionTenant ?: $clientTenant;
|
||||||
|
|
||||||
|
function shoppingLoad(PDO $db, int $tenantId): ?array {
|
||||||
|
$stmt = $db->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);
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<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>Einkaufsliste</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>Einkaufsliste</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">keine aktive Einkaufsliste</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">✓ alles erledigt</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 = 'zuletzt synchronisiert: vor ' + (ago < 60 ? ago + 's' : Math.round(ago/60) + 'min');
|
||||||
|
showErr('');
|
||||||
|
} catch (e) {
|
||||||
|
showErr('laden fehlgeschlagen: ' + 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 || 'fehler');
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -65,10 +65,15 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
require __DIR__ . '/inc/routes/collab-buckets.php';
|
require __DIR__ . '/inc/routes/collab-buckets.php';
|
||||||
require __DIR__ . '/inc/routes/collab-cards.php';
|
require __DIR__ . '/inc/routes/collab-cards.php';
|
||||||
require __DIR__ . '/inc/routes/collab-locks.php';
|
require __DIR__ . '/inc/routes/collab-locks.php';
|
||||||
|
require __DIR__ . '/inc/routes/shopping.php';
|
||||||
|
|
||||||
jsonResponse(['error' => 'not found'], 404);
|
jsonResponse(['error' => 'not found'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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';
|
||||||
Reference in new issue
Block a user