Board: new-card unread indicator (public/js/modules/board.js,
public/js/modules/buckets.js, public/js/modules/state.js,
public/css/style.css):
* state.unseenCardIds (Set) tracks card ids that arrived since the
last visit to the "all" view. Diff runs in renderBoard() against a
module-private knownCardIds snapshot.
* Bug fix: the first renderBoard runs with state.cards = [] (before
the API returns). The previous diff treated every card from the
real load as "new" and pulsed the entire board. Now the diff is
skipped when knownCardIds is null or empty — the first non-empty
render is a silent prime.
* When activeBucket === 'all' and unseenCardIds is non-empty, the set
is drained and every matching board card gets a 3-cycle accent glow
(`.card-pulse-new`, 0.8s × 3, pinned for anim-fast/slow). Works
both when navigating TO all and when a poll arrives while already
on all.
* When the user is on a different bucket: unseenCardIds accumulates
across polls and a red dot (`.bar-unread-dot`) appears on the "all"
tab. Position is top:3px right:3px *inside* the tab to survive
the double overflow clip (`#main-header overflow:hidden` +
`#bucket-bar overflow-x:auto`). renderBar() bakes the dot into the
all-tab template on every render so it survives bar re-renders that
aren't followed by a board render.
* decorateAllUnreadDot() in board.js is a fallback called at the end
of every renderBoard for cases where the diff changed unseenCardIds
but renderBar hasn't run yet.
Relay: readable inbox (relay/inc/routes/messages.php,
relay/ui/app.js, relay/ui/layout.php, relay/ui/style.css):
* New session-based GET /api/messages/inbox — returns up to 200
messages for the logged-in user, decrypted server-side with the
inter-tenant key (no extra leakage — the relay already sees
plaintext during the routing hop). Does NOT mark messages as
fetched; the hyprcrd client is still the canonical receiver.
* loadInbox() now fetches from /api/messages/inbox and renders
actual inbox items: sender (with user-circle icon + accent2),
optional title (bold), body (pre-wrap, max-height 120px), relative
timestamp (formatRelTime: "just now", "2 min ago", "today 14:32",
"3 d ago", or YYYY-MM-DD for older). Unfetched messages show a
small "new" accent badge next to the sender name.
* deleteMessage(id, btn) — confirms, calls existing DELETE
/api/messages/{id} (already session-aware), removes the DOM row,
re-loads the list, toasts "message deleted".
* Auto-poll: startInboxPoll / stopInboxPoll refresh the inbox every
15s while the compose view is active. Stops on view switch.
* Header-status badge: mirrors the real message count (not just the
/api/status count) and toggles the has-unread class + dot so the
tap-to-scroll-to-inbox target stays fresh.
Relay UI polish (relay/ui/style.css):
* .header-status is now a clickable button with tray icon, hover
tint, focus-ring, and a red dot (::after pseudo) when has-unread.
jumpToInbox() scrolls smoothly to #inbox-section and flashes it
with a 1.6s accent box-shadow pulse.
* .inbox-item .from is flex-row with icon + badge-new pill.
* .inbox-item .del gets hover-background for better mobile tappability.
* @keyframes flash-highlight for the scroll-to-inbox animation.
chore: bump version to 0.9.9
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
193 lines
8.9 KiB
PHP
193 lines
8.9 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-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>
|
|
<select id="compose-recipient"></select>
|
|
</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 class="actions">
|
|
<button class="btn btn-secondary" onclick="clearCompose()"><i class="ph ph-x"></i> clear</button>
|
|
<span class="spacer"></span>
|
|
<span id="compose-status" class="status"></span>
|
|
<button class="btn btn-primary" onclick="sendCompose()"><i class="ph ph-paper-plane-tilt"></i> send</button>
|
|
</div>
|
|
</div>
|
|
<div id="inbox-section" class="section">
|
|
<h2><i class="ph ph-tray"></i> your inbox <span class="count" id="inbox-count">0</span></h2>
|
|
<div id="inbox-list"></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>
|
|
|
|
</main>
|
|
|
|
<div id="toast-stack"></div>
|
|
|
|
<script src="ui/app.js"></script>
|
|
</body>
|
|
</html>
|