Compare commits
7
Commits
c2485b1307
...
be28080439
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be28080439 | ||
|
|
c07340a3ec | ||
|
|
d052fb5f9d | ||
|
|
b9d610b612 | ||
|
|
d925070a36 | ||
|
|
e086db0c2c | ||
|
|
670f125627 |
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
define('APP_NAME', 'hyprcrd');
|
||||
define('APP_VERSION', '1.9.17');
|
||||
define('APP_VERSION', '1.9.21');
|
||||
|
||||
// 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.17",
|
||||
"version": "1.9.21",
|
||||
"description": "hyprcrd — self-hosted card manager",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -1648,6 +1648,21 @@ if (str_starts_with($route, '/api/')) {
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-shopping-cart text-sm"></i>shopping list (phone sync)</h3>
|
||||
<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>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>
|
||||
<table class="w-full text-xs">
|
||||
<tr><td class="py-1 text-t-dim w-40 font-pixel"><i class="ph ph-hash text-xs"></i> trigger tags</td><td><span class="text-t-accent">#einkauf</span> or <span class="text-t-accent">#shopping</span> — either counts</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-shopping-cart text-xs"></i> pick active</td><td>header Shopping button → radio select → Save</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-device-mobile text-xs"></i> phone access</td><td>relay UI → shopping tab (same login)</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-arrows-clockwise text-xs"></i> sync cadence</td><td>piggybacks on the shared sync tick (see Settings → Relay)</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-download-simple text-sm"></i>import / export</h3>
|
||||
<table class="w-full text-xs">
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Shopping Plugin: backend.
|
||||
*
|
||||
* Thin forwarder from the client to the relay's /api/shopping*
|
||||
* endpoints. The phone-side view is part of the relay UI; this
|
||||
* plugin just pipes hyprcrd's push/pull through the configured
|
||||
* relay credentials.
|
||||
*
|
||||
* Routes:
|
||||
* GET /api/shopping — pull active list state from relay
|
||||
* POST /api/shopping — push active card {uid,title,body}
|
||||
* POST /api/shopping/check — (mostly phone-only; proxied for
|
||||
* completeness/tests — relay will
|
||||
* 401 for client-auth callers)
|
||||
*/
|
||||
|
||||
function shoppingJson($data, int $code = 200): void {
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
function shoppingRelay(): array {
|
||||
$db = Database::connect();
|
||||
return [
|
||||
'url' => $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn() ?: '',
|
||||
'user' => $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn() ?: '',
|
||||
'key' => $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn() ?: '',
|
||||
];
|
||||
}
|
||||
|
||||
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||
if ($route !== '/api/shopping' && $route !== '/api/shopping/check') return;
|
||||
if (!in_array($method, ['GET', 'POST'], true)) return;
|
||||
|
||||
$r = shoppingRelay();
|
||||
if (!$r['url'] || !$r['user'] || !$r['key']) {
|
||||
shoppingJson(['ok' => false, 'error' => 'relay not configured']);
|
||||
}
|
||||
|
||||
$ch = curl_init(rtrim($r['url'], '/') . $route);
|
||||
$opts = [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'X-Relay-User: ' . $r['user'],
|
||||
'X-Relay-Key: ' . $r['key'],
|
||||
],
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
];
|
||||
if ($method === 'POST') {
|
||||
$opts[CURLOPT_POST] = true;
|
||||
$opts[CURLOPT_POSTFIELDS] = file_get_contents('php://input') ?: '{}';
|
||||
}
|
||||
curl_setopt_array($ch, $opts);
|
||||
$response = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
http_response_code($status ?: 502);
|
||||
header('Content-Type: application/json');
|
||||
echo is_string($response) ? $response : json_encode(['ok' => false, 'error' => 'relay unreachable']);
|
||||
exit;
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -0,0 +1,240 @@
|
||||
// ── 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
|
||||
// cards.reloaded so edits land on the phone within seconds. Pulls
|
||||
// phone-side toggles back and PATCHes the local card.
|
||||
//
|
||||
// No timestamp cursors — we diff the (uid, title, body) triple we
|
||||
// last saw in sync with the relay, which is timezone-agnostic.
|
||||
|
||||
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 SYNCED_KEY = 'hyprcrd-shopping-synced';
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function hasEinkaufTag(card) {
|
||||
const tags = (card.tags || '').split(',').map(s => s.trim());
|
||||
return tags.includes('einkauf') || tags.includes('shopping');
|
||||
}
|
||||
|
||||
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));
|
||||
localStorage.removeItem(SYNCED_KEY);
|
||||
}
|
||||
|
||||
function getActiveCard() {
|
||||
const id = getActiveId();
|
||||
if (!id) return null;
|
||||
const cards = window.state?.cards || [];
|
||||
return cards.find(c => String(c.id) === id) || null;
|
||||
}
|
||||
|
||||
function getSynced() {
|
||||
try { return JSON.parse(localStorage.getItem(SYNCED_KEY) || 'null'); } catch { return null; }
|
||||
}
|
||||
function setSynced(uid, title, body) {
|
||||
localStorage.setItem(SYNCED_KEY, JSON.stringify({ uid: String(uid), title: title || '', body: body || '' }));
|
||||
}
|
||||
|
||||
// ── Header button injection ──────────────────────────────────
|
||||
|
||||
function injectHeaderButton() {
|
||||
if ($('shopping-btn')) return;
|
||||
// Put it next to Calendar. The header's right-hand button row
|
||||
// lives in `header > div:last-child > .flex.items-center.gap-3`.
|
||||
const header = document.querySelector('header#main-header');
|
||||
if (!header) return;
|
||||
const rightRow = header.querySelectorAll('.flex.items-center.gap-3');
|
||||
const group = rightRow[rightRow.length - 1];
|
||||
if (!group) return;
|
||||
const btn = document.createElement('button');
|
||||
btn.id = 'shopping-btn';
|
||||
btn.className = 'flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors';
|
||||
btn.innerHTML = '<i class="ph ph-shopping-cart text-sm"></i><span class="font-pixel text-[10px]">Shopping</span>';
|
||||
btn.addEventListener('click', openDialog);
|
||||
// Insert as the first item of the right-hand group so the order
|
||||
// reads Shopping, Calendar, Assets, Tasks, New.
|
||||
group.insertBefore(btn, group.firstChild);
|
||||
}
|
||||
|
||||
// ── Picker dialog ────────────────────────────────────────────
|
||||
|
||||
function openDialog() {
|
||||
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">Shopping list</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.
|
||||
</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 escClose(e) {
|
||||
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', escClose); }
|
||||
});
|
||||
|
||||
const cards = window.state?.cards || [];
|
||||
const candidates = 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">
|
||||
No cards tagged <span class="text-t-accent">#einkauf</span> or
|
||||
<span class="text-t-accent">#shopping</span> yet. Create a card with checkbox items
|
||||
and add one of those tags — it'll show up here.
|
||||
</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} open · ${items.length} total</div>
|
||||
</div>
|
||||
</label>`;
|
||||
}).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; });
|
||||
});
|
||||
}
|
||||
|
||||
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 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 res = await hyprcrd.api('api/shopping', {
|
||||
method: 'POST',
|
||||
body: { card_uid: uid, title, body, updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) },
|
||||
});
|
||||
if (res?.ok && res.stored !== false) setSynced(uid, title, body);
|
||||
}
|
||||
|
||||
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;
|
||||
if ((card.body || '') === (body || '') && (card.title || '') === (title || '')) {
|
||||
setSynced(card.id, card.title || '', card.body || '');
|
||||
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 || '');
|
||||
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 {}
|
||||
}
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────
|
||||
|
||||
function init() {
|
||||
injectHeaderButton();
|
||||
hyprcrd.hooks?.on?.('sync.tick', () => { tick().catch(() => {}); });
|
||||
hyprcrd.hooks?.on?.('cards.reloaded', () => { tick().catch(() => {}); });
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
hyprcrd.registerPlugin?.({
|
||||
name: 'shopping',
|
||||
version: '1.0.0',
|
||||
description: 'Sync one shopping card to the relay so it\'s checkable from a phone.',
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect, $, click, sleep, csrf } from './runner.js?v=4';
|
||||
|
||||
// Shopping-plugin tests. Everything ships through the shopping plugin
|
||||
// (backend.php registers /api/shopping*, shopping.js injects the
|
||||
// header button and the picker dialog on load). Without a configured
|
||||
// relay the backend still returns {ok:false,error:"relay not
|
||||
// configured"} so we can assert shape.
|
||||
|
||||
describe('Shopping: plugin registration', () => {
|
||||
it('registers itself in window.hyprcrd.plugins', () => {
|
||||
const entries = window.hyprcrd?.plugins || [];
|
||||
const entry = entries.find(p => p?.name === 'shopping');
|
||||
expect(entry).toExist();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shopping: API proxy', () => {
|
||||
it('GET /api/shopping returns an ok field', async () => {
|
||||
const r = await fetch('api/shopping').then(x => x.json());
|
||||
expect(typeof r.ok).toBe('boolean');
|
||||
});
|
||||
|
||||
it('POST /api/shopping/check returns a JSON object', async () => {
|
||||
const r = await fetch('api/shopping/check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ line: '- [ ] nope', checked: true }),
|
||||
}).then(x => x.json());
|
||||
expect(r).toExist();
|
||||
// The check endpoint is session-auth on the relay (phone-only),
|
||||
// so hitting it via the client proxy returns an `error` field
|
||||
// from the relay's unauthorized branch. Without a configured
|
||||
// relay, it's `ok:false`. Either way it's a JSON object.
|
||||
expect(typeof r.ok === 'boolean' || typeof r.error === 'string').toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shopping: header button', () => {
|
||||
it('Shopping button is injected into the header', () => {
|
||||
const btn = $('#shopping-btn');
|
||||
expect(btn).toExist();
|
||||
// Lives inside the main header, not bolted onto the body.
|
||||
expect(btn.closest('#main-header')).toExist();
|
||||
});
|
||||
|
||||
it('clicking opens the picker dialog with save/close controls', async () => {
|
||||
document.querySelector('.shopping-picker-overlay')?.remove();
|
||||
click('#shopping-btn');
|
||||
await sleep(60);
|
||||
const overlay = document.querySelector('.shopping-picker-overlay');
|
||||
expect(overlay).toExist();
|
||||
expect(overlay.querySelector('.shopping-picker-save')).toExist();
|
||||
expect(overlay.querySelector('.shopping-picker-cancel')).toExist();
|
||||
overlay.querySelector('.shopping-picker-cancel').click();
|
||||
await sleep(40);
|
||||
expect(document.querySelector('.shopping-picker-overlay')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shopping: picker tag matching', () => {
|
||||
// Creates one card per trigger tag + one untagged control, then
|
||||
// opens the picker and asserts the list. Both #einkauf and
|
||||
// #shopping must qualify; untagged card must not.
|
||||
it('lists both #einkauf and #shopping cards, excludes others', async () => {
|
||||
const token = csrf();
|
||||
const h = { 'Content-Type': 'application/json', 'X-CSRF-Token': token };
|
||||
const hDel = { 'X-CSRF-Token': token };
|
||||
let eink, shop, other;
|
||||
try {
|
||||
eink = await fetch('api/cards', { method: 'POST', headers: h, body: JSON.stringify({ title: '__test_shop_einkauf__', body: '- [ ] milk #einkauf' }) }).then(r => r.json());
|
||||
shop = await fetch('api/cards', { method: 'POST', headers: h, body: JSON.stringify({ title: '__test_shop_shopping__', body: '- [ ] bread #shopping' }) }).then(r => r.json());
|
||||
other = await fetch('api/cards', { method: 'POST', headers: h, body: JSON.stringify({ title: '__test_shop_control__', body: 'plain card' }) }).then(r => r.json());
|
||||
await window._reloadAll();
|
||||
|
||||
// Close any leftover overlay, then open the picker fresh.
|
||||
document.querySelector('.shopping-picker-overlay')?.remove();
|
||||
click('#shopping-btn');
|
||||
await sleep(80);
|
||||
|
||||
const overlay = document.querySelector('.shopping-picker-overlay');
|
||||
expect(overlay).toExist();
|
||||
const listText = overlay.querySelector('#shopping-picker-list').textContent;
|
||||
expect(listText).toContain('__test_shop_einkauf__');
|
||||
expect(listText).toContain('__test_shop_shopping__');
|
||||
// Non-tagged control must be absent.
|
||||
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);
|
||||
|
||||
overlay.querySelector('.shopping-picker-cancel').click();
|
||||
await sleep(40);
|
||||
} finally {
|
||||
for (const c of [eink, shop, other]) {
|
||||
if (c?.id) await fetch(`api/cards/${c.id}`, { method: 'DELETE', headers: hDel });
|
||||
}
|
||||
await window._reloadAll();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,7 @@ const FILES = [
|
||||
'./terminal.test.js',
|
||||
'./theme.test.js',
|
||||
'./chat.test.js',
|
||||
'./shopping.test.js',
|
||||
];
|
||||
|
||||
// Await all imports so the runner can start in a deterministic state.
|
||||
|
||||
@@ -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)');
|
||||
|
||||
// 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
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<?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'] ?? '')) ?: 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,
|
||||
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 = 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']]);
|
||||
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.
|
||||
@@ -65,6 +65,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
require __DIR__ . '/inc/routes/collab-buckets.php';
|
||||
require __DIR__ . '/inc/routes/collab-cards.php';
|
||||
require __DIR__ . '/inc/routes/collab-locks.php';
|
||||
require __DIR__ . '/inc/routes/shopping.php';
|
||||
|
||||
jsonResponse(['error' => 'not found'], 404);
|
||||
}
|
||||
|
||||
+132
-2
@@ -68,15 +68,16 @@ function showView(name) {
|
||||
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
|
||||
name = 'change-password';
|
||||
}
|
||||
['setup', 'login', 'change-password', 'compose', 'account', 'admin', 'chat'].forEach(v => {
|
||||
['setup', 'login', 'change-password', 'compose', 'account', 'admin', 'chat', 'shopping'].forEach(v => {
|
||||
$('view-' + v)?.classList.add('hidden');
|
||||
});
|
||||
$('view-' + name)?.classList.remove('hidden');
|
||||
|
||||
if (['compose', 'account', 'admin', 'chat'].includes(name)) {
|
||||
if (['compose', 'account', 'admin', 'chat', 'shopping'].includes(name)) {
|
||||
$('nav-buttons').classList.remove('hidden');
|
||||
$('nav-compose').classList.toggle('active', name === 'compose');
|
||||
$('nav-chat')?.classList.toggle('active', name === 'chat');
|
||||
$('nav-shopping')?.classList.toggle('active', name === 'shopping');
|
||||
$('nav-account').classList.toggle('active', name === 'account');
|
||||
$('nav-admin').classList.toggle('active', name === 'admin');
|
||||
$('nav-burger')?.classList.remove('hidden');
|
||||
@@ -105,6 +106,12 @@ function showView(name) {
|
||||
} else {
|
||||
stopChatPoll();
|
||||
}
|
||||
if (name === 'shopping') {
|
||||
loadShopping();
|
||||
startShoppingPoll();
|
||||
} else {
|
||||
stopShoppingPoll();
|
||||
}
|
||||
if (name === 'admin') {
|
||||
loadUsers();
|
||||
} else if (name === 'account') {
|
||||
@@ -695,6 +702,129 @@ window.deleteAllMessages = deleteAllMessages;
|
||||
/** Lightweight relative-time formatter for the inbox. SQLite gives us
|
||||
* `YYYY-MM-DD HH:MM:SS` (UTC). Returns "2 min ago", "today 14:32",
|
||||
* or a full date for older messages. */
|
||||
// ── Shopping (phone-side of the hyprcrd → relay list) ──
|
||||
//
|
||||
// We don't own the content — hyprcrd pushes the active card's body
|
||||
// to /api/shopping and we just render the unchecked "- [ ]" lines
|
||||
// as tappable rows. A tap POSTs to /api/shopping/check which flips
|
||||
// that exact line to "- [x]" on disk; hyprcrd's next pull sees it.
|
||||
|
||||
let shoppingPollHandle = null;
|
||||
let shoppingBusy = false;
|
||||
|
||||
function startShoppingPoll() {
|
||||
stopShoppingPoll();
|
||||
// 4 s is responsive enough that edits on the desktop show up
|
||||
// while you're still holding the phone, and cheap enough that
|
||||
// an always-on tab costs basically nothing (single indexed
|
||||
// SELECT per poll).
|
||||
shoppingPollHandle = setInterval(() => {
|
||||
if (!document.hidden && !shoppingBusy) loadShopping(true);
|
||||
}, 4000);
|
||||
}
|
||||
function stopShoppingPoll() {
|
||||
if (shoppingPollHandle) { clearInterval(shoppingPollHandle); shoppingPollHandle = null; }
|
||||
}
|
||||
|
||||
function shoppingErr(msg) {
|
||||
const el = $('shopping-err');
|
||||
if (!el) return;
|
||||
if (!msg) { el.classList.add('hidden'); el.textContent = ''; return; }
|
||||
el.textContent = msg;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function shoppingParseLines(body) {
|
||||
// Two node kinds end up on the phone:
|
||||
// heading — "# h1" … "###### h6" inside the body, used to
|
||||
// section the list (Obst / Backwaren / …)
|
||||
// item — unchecked "- [ ] …" lines (checked ones are hidden)
|
||||
// Then drop orphan headings (no open items before the next
|
||||
// heading or end of list) so empty section labels don't sit
|
||||
// around after everything under them is ticked off.
|
||||
const nodes = [];
|
||||
for (const line of String(body || '').split(/\r\n|\n|\r/)) {
|
||||
const hm = /^\s*(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
|
||||
if (hm) { nodes.push({ kind: 'heading', level: hm[1].length, text: hm[2] }); continue; }
|
||||
const im = /^\s*-\s*\[([ xX])\]\s*(.*)$/.exec(line);
|
||||
if (!im) continue;
|
||||
if (im[1].toLowerCase() === 'x') continue;
|
||||
nodes.push({ kind: 'item', text: im[2], line });
|
||||
}
|
||||
const out = [];
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (nodes[i].kind === 'heading') {
|
||||
let hasItem = false;
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
if (nodes[j].kind === 'heading') break;
|
||||
if (nodes[j].kind === 'item') { hasItem = true; break; }
|
||||
}
|
||||
if (!hasItem) continue;
|
||||
}
|
||||
out.push(nodes[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function loadShopping(quiet = false) {
|
||||
if (!quiet) shoppingErr('');
|
||||
const data = await api('api/shopping');
|
||||
const title = $('shopping-title');
|
||||
const list = $('shopping-items');
|
||||
if (!list) return;
|
||||
if (!data?.ok) { if (!quiet) shoppingErr(data?.error || 'load failed'); return; }
|
||||
if (!data.state) {
|
||||
if (title) title.textContent = '';
|
||||
list.innerHTML = '<li class="empty">no active shopping list</li>';
|
||||
return;
|
||||
}
|
||||
const { title: t, body } = data.state;
|
||||
if (title) title.textContent = t || '';
|
||||
const nodes = shoppingParseLines(body);
|
||||
const hasItems = nodes.some(n => n.kind === 'item');
|
||||
if (!hasItems) {
|
||||
list.innerHTML = '<li class="empty">\u2713 all done</li>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = '';
|
||||
for (const n of nodes) {
|
||||
const li = document.createElement('li');
|
||||
if (n.kind === 'heading') {
|
||||
li.className = 'shopping-heading h' + n.level;
|
||||
li.textContent = n.text;
|
||||
} else {
|
||||
li.textContent = n.text;
|
||||
li.dataset.line = n.line;
|
||||
li.addEventListener('click', () => shoppingCheck(li));
|
||||
}
|
||||
list.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
async function shoppingCheck(li) {
|
||||
if (shoppingBusy) return;
|
||||
shoppingBusy = true;
|
||||
li.classList.add('checked');
|
||||
try {
|
||||
const res = await api('api/shopping/check', { method: 'POST', body: { line: li.dataset.line, checked: true } });
|
||||
if (!res?.ok) throw new Error(res?.error || 'check failed');
|
||||
setTimeout(() => { shoppingBusy = false; loadShopping(true); }, 300);
|
||||
} catch (e) {
|
||||
shoppingBusy = false;
|
||||
li.classList.remove('checked');
|
||||
shoppingErr(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-refresh when the tab comes back into focus — same pattern as
|
||||
// the standalone page used to have, just without a manual button.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) return;
|
||||
if ($('view-shopping')?.classList.contains('hidden')) return;
|
||||
if (shoppingBusy) return;
|
||||
loadShopping(true);
|
||||
});
|
||||
|
||||
// ── Chat ──
|
||||
|
||||
let chatActivePeer = null;
|
||||
|
||||
@@ -23,6 +23,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
<span id="nav-user" class="user"></span>
|
||||
<button id="nav-compose" class="nav-btn active" onclick="showView('compose')"><i class="ph ph-pencil-line"></i> compose</button>
|
||||
<button id="nav-chat" class="nav-btn" onclick="showView('chat')"><i class="ph ph-chat-circle-dots"></i> chat</button>
|
||||
<button id="nav-shopping" class="nav-btn" onclick="showView('shopping')"><i class="ph ph-shopping-cart"></i> shopping</button>
|
||||
<button id="nav-account" class="nav-btn" onclick="showView('account')"><i class="ph ph-user-circle"></i> account</button>
|
||||
<button id="nav-admin" class="nav-btn hidden" onclick="showView('admin')"><i class="ph ph-users"></i> users</button>
|
||||
<button class="nav-btn danger" onclick="doLogout()"><i class="ph ph-sign-out"></i> logout</button>
|
||||
@@ -210,6 +211,21 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Shopping view ── -->
|
||||
<!-- Phone-friendly checklist. Shows only unchecked "- [ ]" items
|
||||
from the currently active shopping card. Tapping an item flips
|
||||
it to "- [x]" on the relay in place; hyprcrd pulls that back on
|
||||
the next sync tick. Edits to item text stay in hyprcrd. -->
|
||||
<div id="view-shopping" class="hidden">
|
||||
<div class="shopping-wrap">
|
||||
<div class="shopping-top">
|
||||
<span id="shopping-title" class="pixel" style="font-size:11px;color:var(--bright);">loading…</span>
|
||||
</div>
|
||||
<div id="shopping-err" class="shopping-err hidden"></div>
|
||||
<ul id="shopping-items" class="shopping-items"></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Chat view ── -->
|
||||
<div id="view-chat" class="hidden">
|
||||
<div class="chat-layout">
|
||||
|
||||
@@ -232,6 +232,14 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point
|
||||
#view-compose:not(.hidden) {
|
||||
display: flex; flex-direction: column; gap: 16px;
|
||||
}
|
||||
/* Same 16 px rhythm between inbox / shared / sent on mobile as
|
||||
between compose and inbox — desktop re-declares this inside the
|
||||
grid rule further down. */
|
||||
#compose-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.compose-attachments {
|
||||
display: flex; flex-wrap: wrap; gap: 6px; min-height: 0;
|
||||
}
|
||||
@@ -254,6 +262,43 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point
|
||||
display:flex; gap:8px; align-items:center;
|
||||
}
|
||||
.compose-card .actions .spacer { flex:1; }
|
||||
|
||||
/* Shopping view — phone-friendly checklist. Large tap targets,
|
||||
unchecked items only, tap to flip. */
|
||||
.shopping-wrap { max-width: 520px; margin: 0 auto; display: flex; flex-direction: column; gap: 12px; }
|
||||
.shopping-top { display: flex; align-items: center; gap: 10px; padding: 6px 2px; }
|
||||
.shopping-items { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.shopping-items li {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg);
|
||||
padding: 14px 16px; font-size: 16px; cursor: pointer; user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: transform 0.12s, opacity 0.3s, background 0.12s;
|
||||
}
|
||||
.shopping-items li:active { transform: scale(0.98); background: var(--raised, var(--surface)); }
|
||||
.shopping-items li.checked { opacity: 0.35; text-decoration: line-through; }
|
||||
.shopping-items .empty { text-align: center; padding: 40px 16px; color: var(--dim); background: transparent; border: none; cursor: default; }
|
||||
.shopping-items .empty:active { transform: none; }
|
||||
|
||||
/* Section headings between items — "# Obst", "## Backwaren", etc. */
|
||||
.shopping-items li.shopping-heading {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 18px 4px 4px;
|
||||
cursor: default;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.shopping-items li.shopping-heading:active { transform: none; background: transparent; opacity: 1; }
|
||||
.shopping-items li.shopping-heading:first-child { padding-top: 2px; }
|
||||
.shopping-items li.shopping-heading.h1 { font-size: 18px; color: var(--bright); }
|
||||
.shopping-items li.shopping-heading.h2 { font-size: 15px; color: var(--accent); }
|
||||
.shopping-items li.shopping-heading.h3,
|
||||
.shopping-items li.shopping-heading.h4,
|
||||
.shopping-items li.shopping-heading.h5,
|
||||
.shopping-items li.shopping-heading.h6 { font-size: 13px; color: var(--muted); }
|
||||
.shopping-err { background: var(--danger-bg, #7f1d1d); color: var(--danger-fg, #fecaca); padding: 8px 12px; border-radius: 6px; font-size: 13px; }
|
||||
.shopping-err.hidden { display: none; }
|
||||
|
||||
/* Chat view */
|
||||
.chat-layout { display: flex; height: calc(100vh - 200px); min-height: 300px; border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
|
||||
.chat-sidebar { width: 200px; border-right: 1px solid var(--border); display: flex; flex-direction: column; background: var(--surface); }
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Website TODO
|
||||
|
||||
Audit (app 1.9.17 vs. current site 1.3.3) found the site is ~14 releases
|
||||
behind on content and missing entire feature categories. Items grouped
|
||||
by priority so it's obvious what's must-have vs. polish.
|
||||
|
||||
## P0 — critical freshness gap
|
||||
|
||||
- [ ] **Bump footer version** to the actual app version (pull from
|
||||
`config.php` APP_VERSION or `electron/package.json`). Today shows
|
||||
`v1.3.3`, app is `1.9.17`.
|
||||
- [ ] **Refresh hero screenshot** (`screenshot.png`). Probably 1+ year
|
||||
stale; current board has new bucket bar with fade edges, loading
|
||||
spinner, peer strip in chat, etc.
|
||||
- [ ] **New section: Calendar + CalDAV.** Completely absent today.
|
||||
Cover: month grid, journal entries (`YYYY-MM-DD` titled cards),
|
||||
appointments via `#termin` template (Zeit/Bis/Ort/Teilnehmer),
|
||||
bidirectional ownCloud/Nextcloud sync, day-popup quick-add. This
|
||||
is a big differentiator vs. Obsidian/Notion and currently hidden.
|
||||
- [ ] **Rewrite Relay section.** Today only mentions 1:1 chat + collab
|
||||
buckets. Add: group chat (named rooms, member mgmt, persistent
|
||||
history, backfill on new device), disposable vs. persistent 1:1
|
||||
threads, shared-bucket inbox in relay UI, sync-event toasts.
|
||||
- [ ] **Flesh out bundled plugins.** Currently lists names only. Add
|
||||
a short paragraph + tiny screenshot each for: TIM (time calc),
|
||||
Terminal, Slideshow, Logseq-Import, Canvas (ASCII art).
|
||||
|
||||
## P1 — substance
|
||||
|
||||
- [ ] **Screenshots gallery section.** Only one screenshot (hero) exists.
|
||||
Shoot + embed: calendar month view, day-popup with templates,
|
||||
chat flyout with peer strip, group chat + member list, detail
|
||||
split view, superview, gallery plugin, Gitea issue browser.
|
||||
Thumbnails with click-to-zoom.
|
||||
- [ ] **"Daily use" section** with three concrete workflows:
|
||||
1. Journaling + calendar sync (date cards → ownCloud)
|
||||
2. Team collab (shared bucket + group chat + shared-bucket inbox)
|
||||
3. Gitea ticket-to-card flow
|
||||
- [ ] **CTA buttons point to real landing targets**, not raw
|
||||
`README.md`. Minimum: a `/install.html` with Docker, Electron
|
||||
and PHP-hosting tabs.
|
||||
- [ ] **Mention Superview + Split View + Find-and-Replace** explicitly.
|
||||
Today buried or absent. Same for Undo/Redo across all mutations
|
||||
and the editor draft auto-backup.
|
||||
|
||||
## P2 — nice-to-have
|
||||
|
||||
- [ ] Animated hero demo (GIF or ≤30s mp4): create card, tag-click,
|
||||
detail open, calendar jump. No voiceover, just the keystrokes.
|
||||
- [ ] Keyboard shortcut reference expanded (app has ~40+; site shows
|
||||
~20). Maybe collapsible per-category.
|
||||
- [ ] Theming preview: 3–4 design-token presets side by side so the
|
||||
"fully themed" claim has a picture behind it.
|
||||
- [ ] Visible changelog / "recently added" box so the site doesn't
|
||||
drift 14 releases behind again.
|
||||
- [ ] Comparison table vs. Obsidian / Notion / Joplin — privacy +
|
||||
self-hosted + collab + calendar all-in-one is the pitch.
|
||||
- [ ] Vault-key explainer (multi-instance CalDAV sharing, separate
|
||||
from login password). Niche but unique to hyprcrd.
|
||||
|
||||
## Not doing (deliberate)
|
||||
|
||||
- Pricing page — open-source/MIT, no revenue model to surface.
|
||||
- User testimonials / case studies — no real users to quote.
|
||||
- Community links (Discord/Matrix) — none exist yet.
|
||||
- Mobile app shots — there isn't a mobile app, only mobile-friendly
|
||||
relay UI.
|
||||
Reference in new issue
Block a user