Per-card opt-in push to the relay for distraction-free mobile reading.
Not tag-based: a real cards.read_push column so the reading state
stays independent of whatever tags the user organises by.
hyprcrd
- cards.read_push column (0/1, default 0) via Database migration.
- POST /api/cards + PATCH /api/cards/{id} accept read_push; save hook
posts the card (title+body, encrypted) and referenced images
to the relay's /api/read endpoint. First inline image is flagged
as the hero. Toggle-off / DELETE wipes the remote copy.
- Detail-bar toggle button ("Read" → "On relay") with filled book
icon + inverted accent background when active; outline book icon
in the bottom meta row of the board card when read_push=1.
relay
- read_items + read_attachments tables, encrypted under the
inter-tenant key like messages (dual-format envelope).
- Client API: POST /api/read, DELETE /api/read/{uid}, POST
/api/read-attachments. Session API: GET /api/read (list),
GET /api/read/{uid} (single decrypted), GET /api/read-attachments/
{uid}/{filename} (serves decrypted image bytes).
- Payload size caps raised to 15MB (attachments) / 2MB (items).
- New nav tab + view: list with thumbnails, click opens a zen reader
overlay. Reader caps article width at 680px, bumps type to 17px/1.7,
renders a minimal markdown subset (headings, lists, code fences,
blockquotes, links, inline images), rewrites uploads/ references
to the session-authed image endpoint. Escape closes the reader.
- Mobile-first: wider touch targets, tighter padding at ≤640px.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
298 lines
15 KiB
PHP
298 lines
15 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="icon" type="image/svg+xml" href="favicon.svg">
|
|
<link rel="stylesheet" href="fonts/phosphor.css">
|
|
<link rel="stylesheet" href="ui/style.css?v=<?= filemtime(__DIR__ . '/style.css') ?>">
|
|
</head>
|
|
<body>
|
|
|
|
<header>
|
|
<span class="dot"></span>
|
|
<a onclick="showView('compose')" style="display:flex;align-items:center;gap:8px;text-decoration:none;cursor:pointer;">
|
|
<i class="ph ph-broadcast brand-icon"></i>
|
|
<h1>hyprcrd relay</h1>
|
|
</a>
|
|
<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-inbox" class="nav-btn" onclick="showView('inbox')"><i class="ph ph-tray"></i> inbox</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-read" class="nav-btn" onclick="showView('read')"><i class="ph ph-book-open"></i> read</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 10 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>
|
|
<form id="login-form" autocomplete="on" onsubmit="event.preventDefault(); doLogin();">
|
|
<div class="field">
|
|
<label for="login-username">username</label>
|
|
<input type="text" id="login-username" name="username" autocomplete="username">
|
|
</div>
|
|
<div class="field">
|
|
<label for="login-password">password</label>
|
|
<input type="password" id="login-password" name="password" autocomplete="current-password">
|
|
</div>
|
|
<button type="submit" class="btn btn-primary"><i class="ph ph-sign-in"></i> unlock</button>
|
|
</form>
|
|
<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>
|
|
|
|
<!-- ── Inbox view (inbox + shared buckets + sent) ── -->
|
|
<div id="view-inbox" class="hidden">
|
|
<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>
|
|
|
|
<!-- ── 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 10 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 10 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>
|
|
|
|
<!-- ── Read view: list + zen reader ── -->
|
|
<div id="view-read" class="hidden">
|
|
<div id="read-list" class="read-wrap">
|
|
<div id="read-empty" class="read-empty hidden">
|
|
<i class="ph ph-book-open read-empty-icon"></i>
|
|
<p>No items yet. Toggle <strong>Read later</strong> on a card in hyprcrd to send it here.</p>
|
|
</div>
|
|
<ul id="read-items" class="read-items"></ul>
|
|
</div>
|
|
<!-- Zen reader overlay -->
|
|
<div id="read-reader" class="read-reader hidden">
|
|
<div class="read-reader-bar">
|
|
<button id="read-back" class="read-reader-btn" aria-label="back"><i class="ph ph-arrow-left"></i></button>
|
|
<span class="spacer"></span>
|
|
<button id="read-delete" class="read-reader-btn read-reader-btn-danger" aria-label="delete" title="delete from read list"><i class="ph ph-trash"></i></button>
|
|
</div>
|
|
<article id="read-article" class="read-article"></article>
|
|
</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?v=<?= filemtime(__DIR__ . '/app.js') ?>"></script>
|
|
</body>
|
|
</html>
|