Files
hyprcrd/relay/inc/routes/shopping.php
T
thandClaude Opus 4.6 b9d610b612 shopping: UI strings all English
Picker dialog, hamburger menu entry, phone page and help section
still had German labels from the original request ("Einkaufsliste",
"keine aktive Liste", etc.). The rest of the app's UI is English,
so align everything here: menu entry is "Shopping list", dialog +
phone page copy translated, help references match. Trigger tags
stay as-is — both #einkauf and #shopping keep working so users with
either convention aren't disrupted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 00:48:05 +02:00

268 lines
11 KiB
PHP

<?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="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;
}