Files
hyprcrd/relay/ui/layout.php
T
thandClaude Opus 4.6 d052fb5f9d shopping: ship as plugin, integrate phone view into relay UI, fix sync
Restructure the shopping-list feature end to end:

  - New public/plugins/shopping/ plugin with backend.php (proxy
    routes registered via PluginManager::onApiRoute), shopping.js
    (self-contained; injects the header Shopping button, owns the
    picker dialog + sync loop via hyprcrd.hooks), plugin.json and a
    placeholder css file. Core no longer knows about shopping —
    index.php loses the static button and the proxy block, app.js
    loses the initShopping wiring, the old js/modules/shopping.js
    is gone.
  - Phone view moves out of a standalone /shopping page and into
    the relay UI as a regular `view-shopping` behind a nav-burger
    entry (relay/ui/layout.php + app.js + style.css). Same session
    auth as compose/chat/account — no separate login. Auto-refresh
    every 4 s while the view is active; no manual refresh button.
    Headings "# h1 … ###### h6" render between items for grouping.
    Orphan section headings (no open items under them) are hidden.
  - Sync: drop timestamp cursors (broke across SQLite-UTC / PHP
    timezones and silently blocked pushes after a pull). Both sides
    diff the (uid, title, body) triple instead — timezone-agnostic
    and authoritative. Client ticks push-before-pull so fresh local
    edits land on the relay before a stale pull can overwrite them.
    Relay drops its "server newer" guard and stamps server times
    with gmdate so anything left comparing to SQLite is UTC.
  - Client also ticks on cards.reloaded, not just sync.tick, so
    any edit path (editor save, checkbox toggle, context menu,
    undo) pushes the update immediately.
  - Tests: updated for the plugin (still uses the same selectors
    because the plugin injects the same ids), plus a plugin-
    registration check and "(no active list)" option assertion.
  - Help section copy updated — references the Shopping header
    button and the shopping tab in the relay UI rather than the
    gone /shopping URL.

Both #einkauf and #shopping work as trigger tags; phone is
check-only, editing stays in hyprcrd. Version bumped to 1.9.21.

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

269 lines
14 KiB
PHP

<?php
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#1e293b">
<title>hyprcrd relay</title>
<link rel="stylesheet" href="fonts/phosphor.css">
<link rel="stylesheet" href="ui/style.css">
</head>
<body>
<header>
<span class="dot"></span>
<i class="ph ph-broadcast brand-icon"></i>
<h1>hyprcrd relay</h1>
<button id="header-status" class="header-status pixel" onclick="jumpToInbox()" title="jump to inbox"></button>
<span class="spacer"></span>
<button id="nav-burger" class="nav-btn hidden" aria-label="menu" onclick="toggleNavMenu()"><i class="ph ph-list"></i></button>
<div id="nav-buttons" class="hidden">
<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>
</div>
</header>
<main>
<!-- ── Setup view (first install) ── -->
<div id="view-setup" class="auth-form hidden">
<i class="ph ph-broadcast auth-icon"></i>
<h2>welcome to relay</h2>
<p>This is the first time the relay is being set up. The user you create here will become the <strong>admin</strong>. You'll also set the <strong>super-shared secret</strong> — a relay-wide key used to route messages between tenants.</p>
<div class="field">
<label>admin username</label>
<input type="text" id="setup-username" placeholder="alice" autocomplete="username">
</div>
<div class="field">
<label>admin password</label>
<input type="password" id="setup-password" placeholder="min 4 chars" autocomplete="new-password">
</div>
<div class="field">
<label>super-shared secret</label>
<input type="password" id="setup-super-secret" placeholder="min 12 chars" autocomplete="new-password">
<p class="field-hint">Used by the relay to encrypt messages at rest and route them between tenants. Set once, can't be changed without re-setup.</p>
</div>
<button class="btn btn-primary" onclick="doSetup()"><i class="ph ph-user-circle-plus"></i> create admin + activate relay</button>
<div id="setup-err" class="err"></div>
</div>
<!-- ── Login view ── -->
<div id="view-login" class="auth-form hidden">
<i class="ph ph-key auth-icon"></i>
<h2>login</h2>
<div class="field">
<label>username</label>
<input type="text" id="login-username" autocomplete="username">
</div>
<div class="field">
<label>password</label>
<input type="password" id="login-password" autocomplete="current-password">
</div>
<button class="btn btn-primary" onclick="doLogin()"><i class="ph ph-sign-in"></i> unlock</button>
<div id="login-err" class="err"></div>
</div>
<!-- ── Compose view ── -->
<div id="view-compose" class="hidden">
<div class="compose-card">
<h2><i class="ph ph-paper-plane-tilt"></i> compose</h2>
<div class="field" id="compose-recipient-field">
<label>recipient</label>
<div id="compose-recipient-wrap" style="position:relative;">
<button id="compose-recipient-btn" type="button" class="input" style="text-align:left;cursor:pointer;display:flex;align-items:center;justify-content:space-between;">
<span id="compose-recipient-label">select…</span>
<i class="ph ph-caret-down" style="font-size:10px;color:var(--dim);"></i>
</button>
<input type="hidden" id="compose-recipient" value="">
<div id="compose-recipient-dropdown" class="hidden" style="position:absolute;top:100%;left:0;right:0;margin-top:4px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:0 8px 24px rgba(0,0,0,0.4);z-index:50;max-height:200px;overflow-y:auto;padding:4px 0;"></div>
</div>
</div>
<div class="field">
<label>title (optional)</label>
<input type="text" id="compose-title" autocomplete="off">
</div>
<div class="field">
<label>body</label>
<textarea id="compose-body" placeholder="write something..."></textarea>
</div>
<div id="compose-attachments" class="compose-attachments"></div>
<label class="checkbox" id="compose-cc-self-wrap" style="margin-top:4px;">
<input type="checkbox" id="compose-cc-self"> keep a copy (tagged with recipient name)
</label>
<div class="actions">
<button class="btn btn-secondary" onclick="clearCompose()"><i class="ph ph-x"></i> clear</button>
<button id="compose-attach-btn" class="btn btn-secondary"><i class="ph ph-paperclip"></i> attach</button>
<input type="file" id="compose-file" multiple class="hidden">
<span class="spacer"></span>
<button class="btn btn-primary" onclick="sendCompose()"><i class="ph ph-paper-plane-tilt"></i> send</button>
</div>
<div id="compose-status" class="compose-status-bar"></div>
</div>
<div id="compose-sidebar" class="compose-sidebar">
<div id="inbox-section" class="section">
<h2><i class="ph ph-tray"></i> your inbox <span class="count" id="inbox-count">0</span>
<span style="flex:1"></span>
<button id="inbox-delete-all" class="btn btn-danger hidden" style="padding:3px 10px;font-size:9px;" onclick="deleteAllMessages()"><i class="ph ph-trash"></i> delete all</button>
</h2>
<div id="inbox-list"></div>
</div>
<div id="shared-section" class="section">
<h2><i class="ph ph-users-three"></i> shared buckets <span class="count" id="shared-count">0</span></h2>
<div id="shared-list"></div>
</div>
<div id="outbox-section" class="section">
<h2><i class="ph ph-paper-plane-tilt"></i> sent <span class="count" id="outbox-count">0</span></h2>
<div id="outbox-list"></div>
</div>
</div>
</div>
<!-- ── Forced password change (first login) ── -->
<div id="view-change-password" class="auth-form hidden">
<i class="ph ph-key auth-icon"></i>
<h2>change your password</h2>
<p>This is your first login. Please choose a new password before you can use the relay.</p>
<div class="field">
<label>current password</label>
<input type="password" id="cp-current" placeholder="the one the admin gave you" autocomplete="current-password">
</div>
<div class="field">
<label>new password</label>
<input type="password" id="cp-new" placeholder="min 4 chars" autocomplete="new-password">
</div>
<div class="field">
<label>confirm new password</label>
<input type="password" id="cp-new2" placeholder="repeat" autocomplete="new-password">
</div>
<button class="btn btn-primary" onclick="doChangePassword()"><i class="ph ph-key"></i> change password</button>
<div id="cp-err" class="err"></div>
</div>
<!-- ── Account view ── -->
<div id="view-account" class="hidden">
<div class="section">
<h2><i class="ph ph-user-circle"></i> your client secret</h2>
<p class="field-hint" style="line-height:1.6;font-size:11px;color:var(--muted);">
Your hyprcrd clients use this secret to encrypt messages before sending them to the relay.
All your devices share the same secret. Other tenants never see it. The relay stores it
encrypted with the master key so it can route your messages.
</p>
<div id="account-no-secret" class="hidden notice warn">
<i class="ph ph-warning"></i>
<span>no secret set yet — your hyprcrd clients can't connect until you generate or set one below</span>
</div>
<div id="account-current" class="hidden account-secret-block">
<label class="field-label">current secret</label>
<div class="account-secret-row">
<input id="account-secret-display" type="password" readonly class="input">
<button class="btn btn-secondary" onclick="toggleAccountSecret()" id="account-secret-toggle"><i class="ph ph-eye"></i> show</button>
<button class="btn btn-secondary" onclick="copyAccountSecret()"><i class="ph ph-copy"></i> copy</button>
</div>
</div>
<div class="account-secret-grid">
<div class="field">
<label>set your own secret</label>
<div class="account-secret-row">
<input id="account-secret-input" type="text" placeholder="min 8 chars" class="input">
<button class="btn btn-primary" onclick="saveAccountSecret()"><i class="ph ph-floppy-disk"></i> save</button>
</div>
</div>
<div class="account-divider">— or —</div>
<button class="btn btn-secondary" onclick="generateAccountSecret()"><i class="ph ph-dice-five"></i> generate random secret</button>
</div>
<div id="account-status" class="status" style="margin-top:12px;"></div>
<div class="account-secret-actions">
<button class="btn btn-danger" onclick="clearAccountSecret()"><i class="ph ph-x-circle"></i> clear secret (disconnect all clients)</button>
</div>
</div>
</div>
<!-- ── Admin view ── -->
<div id="view-admin" class="hidden">
<div class="section">
<h2><i class="ph ph-users-three"></i> users <span class="count" id="users-count">0</span></h2>
<div id="users-list"></div>
<div class="create-user-form">
<div class="field">
<label for="new-username">username</label>
<input type="text" id="new-username" placeholder="alice" autocomplete="off">
</div>
<div class="field">
<label for="new-password">password</label>
<input type="password" id="new-password" placeholder="min 4 chars" autocomplete="new-password">
</div>
<label class="checkbox">
<input type="checkbox" id="new-is-admin"> grant admin privileges
</label>
<p class="field-hint">The new user sets their own client secret on first login.</p>
<div class="actions">
<button class="btn btn-primary" onclick="createUser()"><i class="ph ph-user-plus"></i> create user</button>
<span id="create-user-status" class="status"></span>
</div>
</div>
</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">
<div class="chat-sidebar">
<div class="chat-sidebar-header">
<span class="pixel" style="font-size:10px;color:var(--accent);">conversations</span>
</div>
<div id="chat-thread-list"></div>
</div>
<div class="chat-main">
<div id="chat-header" class="chat-header">
<span id="chat-peer-name" class="pixel" style="font-size:11px;color:var(--bright);">select a conversation</span>
</div>
<div id="chat-messages" class="chat-messages"></div>
<div id="chat-input-bar" class="chat-input-bar hidden">
<input type="text" id="chat-input" placeholder="type a message..." autocomplete="off">
<button id="chat-send-btn" class="btn btn-primary" style="padding:6px 14px;"><i class="ph ph-paper-plane-tilt"></i></button>
</div>
</div>
</div>
</div>
</main>
<!-- Confirm dialog (replaces native confirm()) -->
<div id="relay-confirm" class="hidden" style="position:fixed;inset:0;z-index:300;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;padding:24px;">
<div style="background:var(--surface);border:1px solid var(--border);border-radius:6px;box-shadow:0 16px 48px rgba(0,0,0,0.5);padding:24px;max-width:380px;width:100%;text-align:center;">
<p id="relay-confirm-msg" style="font-size:12px;color:var(--text);line-height:1.5;margin-bottom:18px;"></p>
<div style="display:flex;gap:8px;justify-content:center;">
<button id="relay-confirm-no" class="btn btn-secondary">Cancel</button>
<button id="relay-confirm-yes" class="btn btn-danger">Confirm</button>
</div>
</div>
</div>
<div id="toast-stack"></div>
<script src="ui/app.js"></script>
</body>
</html>