shopping: multi-list, DnD ordering, phone layout; fix flaky tests
Shopping lists:
- Multiple lists per tenant: picker uses checkboxes instead of
radios. Relay schema migrated to composite PK (tenant_id,
card_uid) with a position column for display ordering.
- Picker rows are drag-and-drop reorderable (grip handle +
accent drop indicator). Order persists in localStorage and is
pushed to the relay as position integers — phone renders lists
in that order (ORDER BY position, title).
- Each picker row has a goto-arrow that closes the popup,
switches to the card's bucket if needed, and opens the card.
- Relay DELETE /api/shopping removes deactivated lists. Client
proxy forwards DELETE. Deactivating a list in the picker
immediately removes it from the relay.
- Phone UI renders multiple lists as stacked boxes with headers.
Section headings (# h1–h6) get 14px top padding for breathing
room. CSS selectors fixed to match div-based headings inside
the new .shopping-list-box containers.
- Help text updated for multi-list wording.
- pullOne preserves the synced position so a pull doesn't cause
a spurious re-push of the same order.
Test fixes:
- Line-numbers gutter height tolerance bumped 50%→60% — OS text
shaping can push the divergence past 50%.
- Find/replace flaky: replaced sleep(200) with waitFor on the
confirm dialog so slow renders don't miss the OK click.
- Shopping picker test updated: checkboxes instead of radios.
Bumps to 1.9.25.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
4c3954d989
commit
be7eb1523e
12 files changed
+370
-192
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
define('APP_NAME', 'hyprcrd');
|
||||
define('APP_VERSION', '1.9.24');
|
||||
define('APP_VERSION', '1.9.25');
|
||||
|
||||
// Database path — writable directory, outside webroot
|
||||
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hyprcrd",
|
||||
"version": "1.9.24",
|
||||
"version": "1.9.25",
|
||||
"description": "hyprcrd — self-hosted card manager",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
+1
-1
@@ -1651,7 +1651,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
<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>
|
||||
<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. 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>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 and check any number of lists to activate them — all active lists are pushed to the relay and shown stacked on the phone.</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>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@ function shoppingRelay(): array {
|
||||
|
||||
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;
|
||||
if (!in_array($method, ['GET', 'POST', 'DELETE'], true)) return;
|
||||
|
||||
$r = shoppingRelay();
|
||||
if (!$r['url'] || !$r['user'] || !$r['key']) {
|
||||
@@ -54,6 +54,9 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||
if ($method === 'POST') {
|
||||
$opts[CURLOPT_POST] = true;
|
||||
$opts[CURLOPT_POSTFIELDS] = file_get_contents('php://input') ?: '{}';
|
||||
} elseif ($method === 'DELETE') {
|
||||
$opts[CURLOPT_CUSTOMREQUEST] = 'DELETE';
|
||||
$opts[CURLOPT_POSTFIELDS] = file_get_contents('php://input') ?: '{}';
|
||||
}
|
||||
curl_setopt_array($ch, $opts);
|
||||
$response = curl_exec($ch);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// ── Shopping-list plugin (hyprcrd side) ──
|
||||
//
|
||||
// Tag a card with #einkauf or #shopping to make it eligible. Pick
|
||||
// one as "active" via the Shopping header button; the active card's
|
||||
// body is pushed to the relay on every sync.tick and on every
|
||||
// any number as "active" via the Shopping header button; each active
|
||||
// card's body is pushed to the relay on every sync.tick and on every
|
||||
// cards.reloaded so edits land on the phone within seconds. Pulls
|
||||
// phone-side toggles back and PATCHes the local card.
|
||||
// phone-side toggles back and PATCHes the local cards.
|
||||
//
|
||||
// No timestamp cursors — we diff the (uid, title, body) triple we
|
||||
// last saw in sync with the relay, which is timezone-agnostic.
|
||||
@@ -14,8 +14,9 @@ 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-ids';
|
||||
const SYNCED_KEY = 'hyprcrd-shopping-synced';
|
||||
const ORDER_KEY = 'hyprcrd-shopping-order';
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '')
|
||||
@@ -28,48 +29,71 @@ function hasEinkaufTag(card) {
|
||||
return tags.includes('einkauf') || tags.includes('shopping');
|
||||
}
|
||||
|
||||
function getActiveId() {
|
||||
const v = localStorage.getItem(ACTIVE_KEY);
|
||||
return v ? String(v) : null;
|
||||
// ── Active IDs (multi-select) ────────────────────────────────
|
||||
|
||||
function getActiveIds() {
|
||||
try { return JSON.parse(localStorage.getItem(ACTIVE_KEY) || '[]'); } catch { return []; }
|
||||
}
|
||||
function setActiveId(id) {
|
||||
if (id === null || id === '') localStorage.removeItem(ACTIVE_KEY);
|
||||
else localStorage.setItem(ACTIVE_KEY, String(id));
|
||||
localStorage.removeItem(SYNCED_KEY);
|
||||
function setActiveIds(ids) {
|
||||
localStorage.setItem(ACTIVE_KEY, JSON.stringify(ids.map(String)));
|
||||
}
|
||||
function isActive(id) {
|
||||
return getActiveIds().includes(String(id));
|
||||
}
|
||||
|
||||
function getActiveCard() {
|
||||
const id = getActiveId();
|
||||
if (!id) return null;
|
||||
function getActiveCards() {
|
||||
const ids = getActiveIds();
|
||||
if (!ids.length) return [];
|
||||
const cards = window.state?.cards || [];
|
||||
return cards.find(c => String(c.id) === id) || null;
|
||||
return ids.map(id => cards.find(c => String(c.id) === id)).filter(Boolean);
|
||||
}
|
||||
|
||||
function getSynced() {
|
||||
try { return JSON.parse(localStorage.getItem(SYNCED_KEY) || 'null'); } catch { return null; }
|
||||
function getStoredOrder() {
|
||||
try { return JSON.parse(localStorage.getItem(ORDER_KEY) || '[]'); } catch { return []; }
|
||||
}
|
||||
function setSynced(uid, title, body) {
|
||||
localStorage.setItem(SYNCED_KEY, JSON.stringify({ uid: String(uid), title: title || '', body: body || '' }));
|
||||
function setStoredOrder(ids) {
|
||||
localStorage.setItem(ORDER_KEY, JSON.stringify(ids.map(String)));
|
||||
}
|
||||
function sortByOrder(cards) {
|
||||
const order = getStoredOrder();
|
||||
const idx = new Map(order.map((id, i) => [id, i]));
|
||||
return [...cards].sort((a, b) => {
|
||||
const ai = idx.has(String(a.id)) ? idx.get(String(a.id)) : 9999;
|
||||
const bi = idx.has(String(b.id)) ? idx.get(String(b.id)) : 9999;
|
||||
return ai - bi;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Synced state (per card_uid) ──────────────────────────────
|
||||
|
||||
function getSyncedMap() {
|
||||
try { return JSON.parse(localStorage.getItem(SYNCED_KEY) || '{}'); } catch { return {}; }
|
||||
}
|
||||
function setSyncedFor(uid, title, body, pos) {
|
||||
const m = getSyncedMap();
|
||||
m[String(uid)] = { title: title || '', body: body || '', pos: pos ?? -1 };
|
||||
localStorage.setItem(SYNCED_KEY, JSON.stringify(m));
|
||||
}
|
||||
function removeSyncedFor(uid) {
|
||||
const m = getSyncedMap();
|
||||
delete m[String(uid)];
|
||||
localStorage.setItem(SYNCED_KEY, JSON.stringify(m));
|
||||
}
|
||||
|
||||
// ── 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];
|
||||
const rows = header.querySelectorAll('.flex.items-center.gap-3');
|
||||
const group = rows[rows.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);
|
||||
}
|
||||
|
||||
@@ -87,14 +111,14 @@ function openDialog() {
|
||||
panel.innerHTML = `
|
||||
<div class="flex items-center gap-3 px-5 py-3 border-b border-t-border">
|
||||
<i class="ph ph-shopping-cart text-t-accent text-base"></i>
|
||||
<h2 class="font-pixel text-sm text-t-accent flex-1">Shopping list</h2>
|
||||
<h2 class="font-pixel text-sm text-t-accent flex-1">Shopping lists</h2>
|
||||
<button class="shopping-picker-close text-t-muted hover:text-t-danger"><i class="ph ph-x text-sm"></i></button>
|
||||
</div>
|
||||
<div class="px-5 py-3 text-[11px] text-t-muted leading-relaxed">
|
||||
Cards tagged with <span class="text-t-accent">#einkauf</span> or
|
||||
<span class="text-t-accent">#shopping</span> can be set as the active list for the
|
||||
phone. Only one is active at a time; its body is pushed to the relay on every sync
|
||||
and appears under the <span class="text-t-accent">shopping</span> tab in the relay UI.
|
||||
<span class="text-t-accent">#shopping</span> can be activated for the phone.
|
||||
Check any number of lists; their items appear stacked under the
|
||||
<span class="text-t-accent">shopping</span> tab in the relay UI.
|
||||
</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">
|
||||
@@ -117,9 +141,9 @@ function openDialog() {
|
||||
});
|
||||
|
||||
const cards = window.state?.cards || [];
|
||||
const candidates = cards.filter(hasEinkaufTag);
|
||||
const currentActive = getActiveId();
|
||||
let selectedId = currentActive;
|
||||
const candidates = sortByOrder(cards.filter(hasEinkaufTag));
|
||||
const currentIds = new Set(getActiveIds());
|
||||
const selected = new Set(currentIds);
|
||||
|
||||
if (!candidates.length) {
|
||||
list.innerHTML = `<div class="px-4 py-6 text-center text-[11px] text-t-dim">
|
||||
@@ -130,93 +154,165 @@ function openDialog() {
|
||||
} else {
|
||||
list.innerHTML = candidates.map(c => {
|
||||
const id = String(c.id);
|
||||
const checked = id === selectedId ? 'checked' : '';
|
||||
const checked = selected.has(id) ? 'checked' : '';
|
||||
const items = (c.body || '').split('\n').filter(l => /^\s*-\s*\[[ xX]\]/.test(l));
|
||||
const open = items.filter(l => /^\s*-\s*\[\s\]/.test(l)).length;
|
||||
return `<label class="flex items-start gap-3 px-3 py-2 hover:bg-t-raised cursor-pointer">
|
||||
<input type="radio" name="shopping-active" value="${id}" ${checked} style="margin-top:3px;">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs text-t-bright font-pixel">${esc(c.title || '(untitled)')}</div>
|
||||
<div class="text-[10px] text-t-dim font-pixel mt-0.5">${open} open · ${items.length} total</div>
|
||||
</div>
|
||||
</label>`;
|
||||
return `<div class="shopping-picker-row flex items-start gap-3 px-3 py-2 hover:bg-t-raised" draggable="true" data-card-id="${id}">
|
||||
<i class="ph ph-dots-six-vertical text-t-dim" style="margin-top:4px;cursor:grab;flex-shrink:0;"></i>
|
||||
<label class="flex items-start gap-3 flex-1 min-w-0 cursor-pointer">
|
||||
<input type="checkbox" data-card-id="${id}" ${checked} style="margin-top:3px;">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs text-t-bright font-pixel">${esc(c.title || '(untitled)')}</div>
|
||||
<div class="text-[10px] text-t-dim font-pixel mt-0.5">${open} open · ${items.length} total</div>
|
||||
</div>
|
||||
</label>
|
||||
<button class="shopping-goto text-t-dim hover:text-t-accent transition-colors" data-card-id="${id}" data-bucket="${c.bucket_id || ''}" title="go to card" style="padding:4px;margin-top:1px;"><i class="ph ph-arrow-square-out text-sm"></i></button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
list.insertAdjacentHTML('beforeend', `<label class="flex items-center gap-3 px-3 py-2 border-t border-t-border hover:bg-t-raised cursor-pointer mt-2">
|
||||
<input type="radio" name="shopping-active" value="" ${currentActive ? '' : 'checked'} style="margin-top:0;">
|
||||
<span class="text-[11px] text-t-muted italic">(no active list)</span>
|
||||
</label>`);
|
||||
list.querySelectorAll('input[name="shopping-active"]').forEach(r => {
|
||||
r.addEventListener('change', () => { selectedId = r.value || null; });
|
||||
list.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
||||
cb.addEventListener('change', () => {
|
||||
if (cb.checked) selected.add(cb.dataset.cardId);
|
||||
else selected.delete(cb.dataset.cardId);
|
||||
});
|
||||
});
|
||||
// Drag-and-drop reordering of picker rows.
|
||||
let dragRow = null;
|
||||
list.querySelectorAll('.shopping-picker-row').forEach(row => {
|
||||
row.addEventListener('dragstart', (e) => {
|
||||
dragRow = row;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
row.style.opacity = '0.4';
|
||||
});
|
||||
row.addEventListener('dragend', () => {
|
||||
row.style.opacity = '';
|
||||
dragRow = null;
|
||||
list.querySelectorAll('.shopping-picker-row').forEach(r => r.style.borderTop = '');
|
||||
});
|
||||
row.addEventListener('dragover', (e) => {
|
||||
if (!dragRow || dragRow === row) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
list.querySelectorAll('.shopping-picker-row').forEach(r => r.style.borderTop = '');
|
||||
const rect = row.getBoundingClientRect();
|
||||
const mid = rect.top + rect.height / 2;
|
||||
if (e.clientY < mid) {
|
||||
row.style.borderTop = '2px solid var(--t-accent)';
|
||||
} else {
|
||||
const next = row.nextElementSibling;
|
||||
if (next) next.style.borderTop = '2px solid var(--t-accent)';
|
||||
}
|
||||
});
|
||||
row.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
if (!dragRow || dragRow === row) return;
|
||||
const rect = row.getBoundingClientRect();
|
||||
const mid = rect.top + rect.height / 2;
|
||||
if (e.clientY < mid) {
|
||||
list.insertBefore(dragRow, row);
|
||||
} else {
|
||||
list.insertBefore(dragRow, row.nextSibling);
|
||||
}
|
||||
// Persist new order from DOM.
|
||||
const newOrder = [...list.querySelectorAll('.shopping-picker-row')].map(r => r.dataset.cardId);
|
||||
setStoredOrder(newOrder);
|
||||
list.querySelectorAll('.shopping-picker-row').forEach(r => r.style.borderTop = '');
|
||||
});
|
||||
});
|
||||
|
||||
list.querySelectorAll('.shopping-goto').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const cardId = btn.dataset.cardId;
|
||||
const bucketId = btn.dataset.bucket;
|
||||
close();
|
||||
// Switch bucket if needed so the card is visible.
|
||||
if (bucketId && window.state?.activeBucket !== bucketId
|
||||
&& window.state?.activeBucket !== 'all') {
|
||||
window.state.activeBucket = bucketId;
|
||||
localStorage.setItem('hyprcrd-active-bucket', bucketId);
|
||||
window._renderBoard?.();
|
||||
}
|
||||
setTimeout(() => window._openDetail?.(parseInt(cardId)), 100);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
panel.querySelector('.shopping-picker-save').addEventListener('click', async () => {
|
||||
const prev = getActiveId();
|
||||
setActiveId(selectedId);
|
||||
const prev = getActiveIds();
|
||||
const next = [...selected];
|
||||
setActiveIds(next);
|
||||
status.textContent = 'saving…';
|
||||
try {
|
||||
if (selectedId) await pushActive();
|
||||
else await clearRemote();
|
||||
// Remove deactivated lists from relay.
|
||||
for (const id of prev) {
|
||||
if (!selected.has(id)) {
|
||||
removeSyncedFor(id);
|
||||
await hyprcrd.api('api/shopping', { method: 'DELETE', body: { card_uid: id } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
// Push all active lists.
|
||||
await tick();
|
||||
status.textContent = 'saved';
|
||||
setTimeout(close, 400);
|
||||
} catch (e) {
|
||||
status.textContent = 'error: ' + (e?.message || e);
|
||||
status.style.color = 'var(--t-danger)';
|
||||
setActiveId(prev);
|
||||
setActiveIds(prev);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sync ─────────────────────────────────────────────────────
|
||||
|
||||
async function pushActive() {
|
||||
const card = getActiveCard();
|
||||
if (!card) return;
|
||||
async function pushOne(card) {
|
||||
const uid = String(card.id);
|
||||
const title = card.title || '';
|
||||
const body = card.body || '';
|
||||
const synced = getSynced();
|
||||
if (synced && synced.uid === uid && synced.title === title && synced.body === body) return;
|
||||
const order = getStoredOrder();
|
||||
const pos = order.indexOf(uid);
|
||||
const synced = getSyncedMap()[uid];
|
||||
if (synced && synced.title === title && synced.body === body && synced.pos === pos) return;
|
||||
const res = await hyprcrd.api('api/shopping', {
|
||||
method: 'POST',
|
||||
body: { card_uid: uid, title, body, updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) },
|
||||
body: { card_uid: uid, title, body, position: pos >= 0 ? pos : 999, updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) },
|
||||
});
|
||||
if (res?.ok && res.stored !== false) setSynced(uid, title, body);
|
||||
if (res?.ok && res.stored !== false) setSyncedFor(uid, title, body, pos);
|
||||
}
|
||||
|
||||
async function pullActive() {
|
||||
const card = getActiveCard();
|
||||
if (!card) return;
|
||||
const res = await hyprcrd.api('api/shopping', { method: 'GET' });
|
||||
if (!res?.ok || !res.state) return;
|
||||
const { card_uid, body, title } = res.state;
|
||||
if (String(card_uid) !== String(card.id)) return;
|
||||
const synced = getSynced();
|
||||
if (synced && synced.uid === String(card.id) && synced.body === (body || '') && synced.title === (title || '')) return;
|
||||
async function pullOne(card, relayState) {
|
||||
const uid = String(card.id);
|
||||
const { body, title } = relayState;
|
||||
const synced = getSyncedMap()[uid];
|
||||
// Preserve the position we last pushed so a pull doesn't reset it
|
||||
// and cause an unnecessary re-push on the next tick.
|
||||
const curPos = synced?.pos ?? -1;
|
||||
if (synced && synced.body === (body || '') && synced.title === (title || '')) return;
|
||||
if ((card.body || '') === (body || '') && (card.title || '') === (title || '')) {
|
||||
setSynced(card.id, card.title || '', card.body || '');
|
||||
setSyncedFor(card.id, card.title || '', card.body || '', curPos);
|
||||
return;
|
||||
}
|
||||
// Relay differs from both the sync marker and the local card — phone
|
||||
// toggled. Phone can only flip checkboxes (never edit text), so the
|
||||
// relay body is authoritative for this tick.
|
||||
await hyprcrd.api(`api/cards/${card.id}`, { method: 'PATCH', body: { body } });
|
||||
setSynced(card.id, card.title || '', body || '');
|
||||
setSyncedFor(card.id, card.title || '', body || '', curPos);
|
||||
await window._reloadAll?.();
|
||||
}
|
||||
|
||||
async function clearRemote() {
|
||||
await hyprcrd.api('api/shopping', {
|
||||
method: 'POST',
|
||||
body: { card_uid: '__inactive__', title: '', body: '', updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) },
|
||||
});
|
||||
localStorage.removeItem(SYNCED_KEY);
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
if (!getActiveId()) return;
|
||||
try { await pushActive(); } catch {}
|
||||
try { await pullActive(); } catch {}
|
||||
const activeCards = getActiveCards();
|
||||
if (!activeCards.length) return;
|
||||
// Push all active cards first.
|
||||
for (const card of activeCards) {
|
||||
try { await pushOne(card); } catch {}
|
||||
}
|
||||
// Pull: fetch all lists from relay, match by card_uid.
|
||||
try {
|
||||
const res = await hyprcrd.api('api/shopping', { method: 'GET' });
|
||||
if (!res?.ok || !Array.isArray(res.lists)) return;
|
||||
const byUid = new Map(res.lists.map(l => [l.card_uid, l]));
|
||||
for (const card of activeCards) {
|
||||
const relay = byUid.get(String(card.id));
|
||||
if (relay) await pullOne(card, relay);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────
|
||||
@@ -235,6 +331,6 @@ if (document.readyState === 'loading') {
|
||||
|
||||
hyprcrd.registerPlugin?.({
|
||||
name: 'shopping',
|
||||
version: '1.0.0',
|
||||
description: 'Sync one shopping card to the relay so it\'s checkable from a phone.',
|
||||
version: '1.1.0',
|
||||
description: 'Sync shopping cards to the relay so they\'re checkable from a phone.',
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||
import { describe, it, afterEach, expect, $, $$, click, sleep, waitFor, closeAll, csrf } from './runner.js?v=4';
|
||||
|
||||
async function makeCard(title, body) {
|
||||
return fetch('api/cards', {
|
||||
@@ -70,13 +70,13 @@ describe('Search: Find/Replace', () => {
|
||||
const applyBtn = document.getElementById('search-replace-all');
|
||||
expect(applyBtn.disabled).toBe(false);
|
||||
click(applyBtn);
|
||||
await sleep(200);
|
||||
|
||||
const dialog = document.getElementById('dialog');
|
||||
if (dialog?.classList.contains('open')) {
|
||||
const okBtn = document.getElementById('dialog-ok');
|
||||
if (okBtn) click(okBtn);
|
||||
}
|
||||
// The replace-all button opens a confirm dialog — wait for it
|
||||
// to appear rather than relying on a fixed sleep, since the
|
||||
// dialog creation is async and can lag on slow runs.
|
||||
await waitFor(() => document.getElementById('dialog')?.classList.contains('open'), 2000);
|
||||
const okBtn = document.getElementById('dialog-ok');
|
||||
if (okBtn) click(okBtn);
|
||||
|
||||
// Poll until the replace has actually persisted
|
||||
let updated1, updated2;
|
||||
|
||||
@@ -143,9 +143,10 @@ describe('Line Numbers: Alignment with Textarea', () => {
|
||||
const cs = getComputedStyle(body);
|
||||
const pad = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
|
||||
const textTotal = body.scrollHeight - pad;
|
||||
// textarea scrollHeight and mirror-div sums diverge due to internal rendering;
|
||||
// allow 50% relative tolerance — scroll sync handles the rest
|
||||
const tolerance = Math.max(textTotal, gutterTotal) * 0.5;
|
||||
// textarea scrollHeight and mirror-div sums diverge due to internal rendering,
|
||||
// font metrics, and OS-level text shaping — allow 60% relative tolerance.
|
||||
// Scroll sync (proportional mapping) handles the real alignment at runtime.
|
||||
const tolerance = Math.max(textTotal, gutterTotal) * 0.6;
|
||||
expect(Math.abs(gutterTotal - textTotal)).toBeLessThan(tolerance);
|
||||
});
|
||||
|
||||
|
||||
@@ -86,11 +86,9 @@ describe('Shopping: picker tag matching', () => {
|
||||
const controlShown = listText.includes('__test_shop_control__');
|
||||
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);
|
||||
// Picker uses checkboxes so multiple lists can be active.
|
||||
const checkboxes = overlay.querySelectorAll('input[type="checkbox"]');
|
||||
expect(checkboxes.length).toBeGreaterThan(0);
|
||||
|
||||
overlay.querySelector('.shopping-picker-cancel').click();
|
||||
await sleep(40);
|
||||
|
||||
+30
-5
@@ -233,19 +233,44 @@ $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)');
|
||||
|
||||
// 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.
|
||||
// Shopping lists: multiple active cards per tenant, each mirrored from
|
||||
// the client. Phone toggles checkboxes in-place via the session UI.
|
||||
// hyprcrd remains the only surface that edits item text.
|
||||
$db->exec('CREATE TABLE IF NOT EXISTS shopping_state (
|
||||
tenant_id INTEGER PRIMARY KEY,
|
||||
tenant_id INTEGER NOT NULL,
|
||||
card_uid TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT "",
|
||||
body TEXT NOT NULL DEFAULT "",
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at_client TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at_server TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (tenant_id, card_uid),
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||
)');
|
||||
// Migrate from old single-PK schema (tenant_id only) to composite PK
|
||||
// + ensure position column exists (may be missing on tables created
|
||||
// before the multi-list update).
|
||||
$shoppingInfo = $db->query("PRAGMA table_info(shopping_state)")->fetchAll();
|
||||
$shoppingPk = array_filter($shoppingInfo, fn($c) => $c['pk'] > 0);
|
||||
$shoppingColNames = array_column($shoppingInfo, 'name');
|
||||
if (count($shoppingPk) === 1) {
|
||||
$db->exec('ALTER TABLE shopping_state RENAME TO _shopping_old');
|
||||
$db->exec('CREATE TABLE shopping_state (
|
||||
tenant_id INTEGER NOT NULL,
|
||||
card_uid TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT "",
|
||||
body TEXT NOT NULL DEFAULT "",
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at_client TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at_server TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (tenant_id, card_uid),
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||
)');
|
||||
$db->exec('INSERT OR IGNORE INTO shopping_state (tenant_id, card_uid, title, body, updated_at_client, updated_at_server) SELECT tenant_id, card_uid, title, body, updated_at_client, updated_at_server FROM _shopping_old');
|
||||
$db->exec('DROP TABLE _shopping_old');
|
||||
} elseif (!in_array('position', $shoppingColNames)) {
|
||||
$db->exec('ALTER TABLE shopping_state ADD COLUMN position INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
|
||||
// Sync-event notifications: replaces the old bot-chat mechanism. When a
|
||||
// client syncs a card they received, they POST here; the sender polls
|
||||
|
||||
@@ -1,53 +1,50 @@
|
||||
<?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
|
||||
// POST /api/shopping (client) upsert one list {card_uid, title, body}
|
||||
// GET /api/shopping (client + session) all active lists for tenant
|
||||
// POST /api/shopping/check (session) toggle one line {card_uid, line, checked}
|
||||
// DELETE /api/shopping (client) remove a deactivated list {card_uid}
|
||||
//
|
||||
// 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.
|
||||
// Multiple lists per tenant (composite PK: tenant_id + card_uid).
|
||||
// Plaintext on purpose — shopping-list content is low-stakes.
|
||||
|
||||
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');
|
||||
function shoppingLoadAll(PDO $db, int $tenantId): array {
|
||||
$stmt = $db->prepare('SELECT card_uid, title, body, position, updated_at_client, updated_at_server FROM shopping_state WHERE tenant_id = :t ORDER BY position, title');
|
||||
$stmt->execute(['t' => $tenantId]);
|
||||
return $stmt->fetchAll() ?: [];
|
||||
}
|
||||
|
||||
function shoppingLoadOne(PDO $db, int $tenantId, string $cardUid): ?array {
|
||||
$stmt = $db->prepare('SELECT card_uid, title, body, updated_at_client, updated_at_server FROM shopping_state WHERE tenant_id = :t AND card_uid = :u');
|
||||
$stmt->execute(['t' => $tenantId, 'u' => $cardUid]);
|
||||
$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'] ?? '');
|
||||
$pos = (int)($input['position'] ?? 0);
|
||||
$clientTs = trim((string)($input['updated_at'] ?? '')) ?: gmdate('Y-m-d H:i:s');
|
||||
if ($uid === '') jsonResponse(['error' => 'card_uid required'], 400);
|
||||
|
||||
// No server-side staleness guard: the client decides whether to
|
||||
// push by diffing its last-synced body against the current card,
|
||||
// not by timestamp, so cross-timezone clock comparisons can't
|
||||
// silently block edits. Last-writer per tick wins — phone-toggle
|
||||
// races lose at most one tick of flips (rare by design).
|
||||
$now = gmdate('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,
|
||||
INSERT INTO shopping_state (tenant_id, card_uid, title, body, position, updated_at_client, updated_at_server)
|
||||
VALUES (:t, :u, :ti, :b, :p, :c, :s)
|
||||
ON CONFLICT(tenant_id, card_uid) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
body = excluded.body,
|
||||
position = excluded.position,
|
||||
updated_at_client = excluded.updated_at_client,
|
||||
updated_at_server = excluded.updated_at_server
|
||||
')->execute([
|
||||
@@ -55,39 +52,42 @@ if ($route === '/api/shopping' && $method === 'POST') {
|
||||
'u' => $uid,
|
||||
'ti' => $title,
|
||||
'b' => $body,
|
||||
'p' => $pos,
|
||||
'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.
|
||||
// ── DELETE /api/shopping ─────────────────────────────────────
|
||||
if ($route === '/api/shopping' && $method === 'DELETE') {
|
||||
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$uid = trim((string)($input['card_uid'] ?? ''));
|
||||
if ($uid === '') jsonResponse(['error' => 'card_uid required'], 400);
|
||||
$db->prepare('DELETE FROM shopping_state WHERE tenant_id = :t AND card_uid = :u')
|
||||
->execute(['t' => $clientTenant['id'], 'u' => $uid]);
|
||||
jsonResponse(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── GET /api/shopping ────────────────────────────────────────
|
||||
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]);
|
||||
$lists = shoppingLoadAll($db, (int)$tenant['id']);
|
||||
jsonResponse(['ok' => true, 'lists' => $lists]);
|
||||
}
|
||||
|
||||
// ── 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) ?: [];
|
||||
$uid = trim((string)($input['card_uid'] ?? ''));
|
||||
$line = (string)($input['line'] ?? '');
|
||||
$checked = !empty($input['checked']);
|
||||
if ($line === '') jsonResponse(['error' => 'line required'], 400);
|
||||
if ($uid === '' || $line === '') jsonResponse(['error' => 'card_uid and line required'], 400);
|
||||
|
||||
$state = shoppingLoad($db, (int)$sessionTenant['id']);
|
||||
if (!$state) jsonResponse(['error' => 'no active list'], 404);
|
||||
$state = shoppingLoadOne($db, (int)$sessionTenant['id'], $uid);
|
||||
if (!$state) jsonResponse(['error' => 'list not found'], 404);
|
||||
$body = $state['body'];
|
||||
$lines = preg_split("/\r\n|\n|\r/", $body);
|
||||
$hit = false;
|
||||
@@ -96,7 +96,7 @@ if ($route === '/api/shopping/check' && $method === 'POST') {
|
||||
$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
|
||||
if ($toggled === $l) continue;
|
||||
$lines[$i] = $toggled;
|
||||
$hit = true;
|
||||
break;
|
||||
@@ -105,11 +105,7 @@ if ($route === '/api/shopping/check' && $method === 'POST') {
|
||||
|
||||
$newBody = implode("\n", $lines);
|
||||
$now = gmdate('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']]);
|
||||
$db->prepare('UPDATE shopping_state SET body = :b, updated_at_server = :s WHERE tenant_id = :t AND card_uid = :u')
|
||||
->execute(['b' => $newBody, 's' => $now, 't' => $sessionTenant['id'], 'u' => $uid]);
|
||||
jsonResponse(['ok' => true, 'updated_at_server' => $now]);
|
||||
}
|
||||
|
||||
// Shopping list lives inside the relay UI as a regular view (see
|
||||
// relay/ui/layout.php + app.js). No separate /shopping page — login
|
||||
// and navigation are the same as for every other relay feature.
|
||||
+47
-25
@@ -773,45 +773,67 @@ async function loadShopping(quiet = false) {
|
||||
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>';
|
||||
const lists = data.lists || [];
|
||||
if (!lists.length) {
|
||||
if (title) title.textContent = 'Shopping';
|
||||
list.innerHTML = '<li class="empty">no active shopping lists</li>';
|
||||
return;
|
||||
}
|
||||
if (title) title.textContent = lists.length === 1 ? (lists[0].title || 'Shopping') : 'Shopping';
|
||||
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));
|
||||
for (const entry of lists) {
|
||||
const nodes = shoppingParseLines(entry.body);
|
||||
const hasItems = nodes.some(n => n.kind === 'item');
|
||||
// List box wrapper
|
||||
const box = document.createElement('li');
|
||||
box.className = 'shopping-list-box';
|
||||
if (lists.length > 1 || entry.title) {
|
||||
const hdr = document.createElement('div');
|
||||
hdr.className = 'shopping-list-header';
|
||||
hdr.textContent = entry.title || '(untitled)';
|
||||
box.appendChild(hdr);
|
||||
}
|
||||
list.appendChild(li);
|
||||
if (!hasItems) {
|
||||
const done = document.createElement('div');
|
||||
done.className = 'shopping-list-done';
|
||||
done.textContent = '\u2713 all done';
|
||||
box.appendChild(done);
|
||||
} else {
|
||||
for (const n of nodes) {
|
||||
if (n.kind === 'heading') {
|
||||
const h = document.createElement('div');
|
||||
h.className = 'shopping-heading h' + n.level;
|
||||
h.textContent = n.text;
|
||||
box.appendChild(h);
|
||||
} else {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'shopping-item';
|
||||
row.textContent = n.text;
|
||||
row.dataset.line = n.line;
|
||||
row.dataset.uid = entry.card_uid;
|
||||
row.addEventListener('click', () => shoppingCheck(row));
|
||||
box.appendChild(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
list.appendChild(box);
|
||||
}
|
||||
}
|
||||
|
||||
async function shoppingCheck(li) {
|
||||
async function shoppingCheck(el) {
|
||||
if (shoppingBusy) return;
|
||||
shoppingBusy = true;
|
||||
li.classList.add('checked');
|
||||
el.classList.add('checked');
|
||||
try {
|
||||
const res = await api('api/shopping/check', { method: 'POST', body: { line: li.dataset.line, checked: true } });
|
||||
const res = await api('api/shopping/check', {
|
||||
method: 'POST',
|
||||
body: { card_uid: el.dataset.uid, line: el.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');
|
||||
el.classList.remove('checked');
|
||||
shoppingErr(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
+51
-14
@@ -276,26 +276,63 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point
|
||||
}
|
||||
.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 { text-align: center; padding: 40px 16px; color: var(--dim); background: transparent; border: none; cursor: default; list-style: none; }
|
||||
.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;
|
||||
/* Multi-list stacked boxes */
|
||||
.shopping-list-box {
|
||||
list-style: none;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.shopping-list-box:active { transform: none; }
|
||||
.shopping-list-header {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--bright);
|
||||
padding: 4px 6px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.shopping-list-done {
|
||||
text-align: center;
|
||||
padding: 16px 8px;
|
||||
color: var(--dim);
|
||||
font-size: 14px;
|
||||
}
|
||||
.shopping-item {
|
||||
background: var(--raised, var(--surface));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 12px 14px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: transform 0.12s, opacity 0.3s, background 0.12s;
|
||||
}
|
||||
.shopping-item:active { transform: scale(0.98); }
|
||||
.shopping-item.checked { opacity: 0.35; text-decoration: line-through; }
|
||||
|
||||
/* Section headings between items — "# Obst", "## Backwaren", etc.
|
||||
Adds breathing room above so grouped items feel like sections. */
|
||||
.shopping-heading {
|
||||
padding: 14px 6px 2px;
|
||||
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-heading:first-child { padding-top: 2px; }
|
||||
.shopping-heading.h1 { font-size: 17px; color: var(--bright); }
|
||||
.shopping-heading.h2 { font-size: 14px; color: var(--accent); }
|
||||
.shopping-heading.h3,
|
||||
.shopping-heading.h4,
|
||||
.shopping-heading.h5,
|
||||
.shopping-heading.h6 { font-size: 12px; 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; }
|
||||
|
||||
|
||||
Reference in new issue
Block a user