diff --git a/config.php b/config.php index 29909fb..8b10e1b 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ 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; } @@ -1357,14 +1323,6 @@ if (str_starts_with($route, '/api/')) {
-
- - -
@@ -1693,14 +1651,14 @@ if (str_starts_with($route, '/api/')) {

shopping list (phone sync)

-

Tag a card with #einkauf or #shopping to mark it as a shopping-list candidate. Open the header's menu → Shopping list to pick which one is the currently active list — only one at a time is mirrored to the phone.

-

The active card's body is pushed to the relay on every sync tick. On your phone, open <your-relay>/shopping and log in with your relay credentials. The page shows only the unchecked - [ ] items as tappable rows. Tapping an item flips it to - [x] on the relay in-place; the next client tick pulls that back and updates the local card.

+

Tag a card with #einkauf or #shopping to mark it as a shopping-list candidate. Click the header's Shopping button to pick which one is the currently active list — only one at a time is mirrored to the phone.

+

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 shopping from the menu. You see only the unchecked - [ ] items as tappable rows. Tapping an item flips it to - [x] on the relay in-place; the next client tick pulls that back and updates the local card.

Editing stays in hyprcrd — the phone can only check/uncheck, never change text. That keeps conflict handling trivial (last-writer wins by timestamp).

- - + +
trigger tags#einkauf or #shopping — either counts
pick activeheader menu → Shopping list → radio select → Save
phone URLhttps://<relay>/shopping — session-auth, mobile layout
pick activeheader Shopping button → radio select → Save
phone accessrelay UI → shopping tab (same login)
sync cadencepiggybacks on the shared sync tick (see Settings → Relay)
diff --git a/public/js/app.js b/public/js/app.js index 15ed114..094620b 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -21,7 +21,6 @@ import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar import { loadSettings as loadCaldavSettings } from './modules/caldav.js'; import { startBackgroundSync, triggerSync, loadRelaySettings } from './modules/sync.js'; import { openPluginSettings } from './modules/plugin-settings.js'; -import { initShopping } from './modules/shopping.js'; // ── Global API (for inline onclick handlers and cross-module calls) ── @@ -218,7 +217,6 @@ async function checkAuth() { loadBuckets(); loadCards(); startBackgroundSync(); - initShopping(); } // ── Keyboard shortcuts (central registry) ── diff --git a/public/plugins/shopping/backend.php b/public/plugins/shopping/backend.php new file mode 100644 index 0000000..825d054 --- /dev/null +++ b/public/plugins/shopping/backend.php @@ -0,0 +1,67 @@ + $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; +}); diff --git a/public/plugins/shopping/plugin.json b/public/plugins/shopping/plugin.json new file mode 100644 index 0000000..e02f4da --- /dev/null +++ b/public/plugins/shopping/plugin.json @@ -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" +} diff --git a/public/plugins/shopping/shopping.css b/public/plugins/shopping/shopping.css new file mode 100644 index 0000000..4c3613c --- /dev/null +++ b/public/plugins/shopping/shopping.css @@ -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); } diff --git a/public/js/modules/shopping.js b/public/plugins/shopping/shopping.js similarity index 56% rename from public/js/modules/shopping.js rename to public/plugins/shopping/shopping.js index 57c81ce..301e10f 100644 --- a/public/js/modules/shopping.js +++ b/public/plugins/shopping/shopping.js @@ -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 -// 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. +// 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 +// cards.reloaded so edits land on the phone within seconds. Pulls +// phone-side toggles back 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). +// No timestamp cursors — we diff the (uid, title, body) triple we +// last saw in sync with the relay, which is timezone-agnostic. -import { $, api, esc, getCsrf } from './util.js'; -import { state } from './state.js'; +const hyprcrd = window.hyprcrd; +if (!hyprcrd) throw new Error('[shopping plugin] window.hyprcrd missing'); + +const $ = (id) => document.getElementById(id); 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'; +const SYNCED_KEY = 'hyprcrd-shopping-synced'; + +function esc(s) { + return String(s ?? '') + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} function hasEinkaufTag(card) { const tags = (card.tags || '').split(',').map(s => s.trim()); @@ -27,22 +32,48 @@ 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); + localStorage.removeItem(SYNCED_KEY); } function getActiveCard() { const id = getActiveId(); 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 = 'Shopping'; + 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() { document.querySelector('.shopping-picker-overlay')?.remove(); @@ -63,7 +94,7 @@ function openDialog() { Cards tagged with #einkauf or #shopping 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 shown at /shopping. + and appears under the shopping tab in the relay UI.
@@ -81,11 +112,12 @@ function openDialog() { 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); } + document.addEventListener('keydown', function escClose(e) { + 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(); let selectedId = currentActive; @@ -109,7 +141,6 @@ function openDialog() {
`; }).join(''); - // "No active" option — explicit way to stop syncing. list.insertAdjacentHTML('beforeend', `