relay UI overhaul, favicon, settings, admin last-seen, fixes
Relay:
- Inbox/shared/sent split into own view-inbox (nav button + poll).
Compose is now single-column, centered.
- Header logo links to compose. Inbox badge stays fresh on all
views via a quiet loadInbox on every view switch.
- Mobile menu: full-height sheet below header, larger buttons
(15px font, 14px padding), full width.
- Login form wrapped in <form> with name attributes + for/id on
labels so password managers can autofill.
- Admin user list shows "last seen <relative time>" per user.
last_seen_at column on tenants, updated on every authenticated
request (session + client API).
- Cache-busting on style.css + app.js via filemtime.
- View persistence in localStorage (survives reload).
- Shared-bucket cards show title only (preview line removed).
Favicon:
- favicon.svg added to project root + copied to public/, relay/,
website/. Linked via <link rel="icon" type="image/svg+xml"> in
all three <head> sections.
Settings:
- New toggle: "Show shopping list items in task collector"
(default off). collectTasks skips #einkauf/#shopping cards when
disabled.
Fixes:
- card-spinner gets display:inline-block so it rotates outside
flex containers (shopping save spinner).
- Tim tab test opens the plugin before checking active state.
- Compose view centered with margin:auto on desktop.
Bumps to 1.9.29.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8e738ed9d4
commit
d50f9fe789
19 files changed
+189
-81
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
define('APP_NAME', 'hyprcrd');
|
||||||
define('APP_VERSION', '1.9.28');
|
define('APP_VERSION', '1.9.29');
|
||||||
|
|
||||||
// Database path — writable directory, outside webroot
|
// Database path — writable directory, outside webroot
|
||||||
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hyprcrd",
|
"name": "hyprcrd",
|
||||||
"version": "1.9.28",
|
"version": "1.9.29",
|
||||||
"description": "hyprcrd — self-hosted card manager",
|
"description": "hyprcrd — self-hosted card manager",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="60.122112mm"
|
||||||
|
height="60.122108mm"
|
||||||
|
viewBox="0 0 60.122112 60.122108"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs1" /><g
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-8.2147047,-118.8156)"><rect
|
||||||
|
style="fill:#06080f;fill-opacity:1;stroke-width:1.5875"
|
||||||
|
id="rect4"
|
||||||
|
width="60.122112"
|
||||||
|
height="60.122112"
|
||||||
|
x="8.2147045"
|
||||||
|
y="118.81558" /><path
|
||||||
|
id="rect1"
|
||||||
|
style="fill:#15bff6;fill-opacity:1;stroke-width:1.5875"
|
||||||
|
d="m 41.162784,130.18519 v 37.02565 h 20.35844 v -37.02565 z m -25.209835,4.34547 v 23.45335 h 5.000729 v -9.22631 h 8.928654 v 9.22631 h 5.000212 v -23.45335 h -5.000212 v 9.22683 h -8.928654 v -9.22683 z" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 926 B |
@@ -395,6 +395,7 @@ body.settings-peeking #settings-overlay > div { pointer-events: auto; }
|
|||||||
.settings-tab.active { color: var(--t-accent); border-left-color: var(--t-accent); background: color-mix(in srgb, var(--t-accent) 8%, transparent); }
|
.settings-tab.active { color: var(--t-accent); border-left-color: var(--t-accent); background: color-mix(in srgb, var(--t-accent) 8%, transparent); }
|
||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
@keyframes pulse-glow { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
|
@keyframes pulse-glow { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
|
||||||
.card-bar-pulse { animation: pulse-glow 2s ease-in-out infinite; }
|
.card-bar-pulse { animation: pulse-glow 2s ease-in-out infinite; }
|
||||||
.board-card:has(.card-bar-pulse) { border-color: var(--t-danger); }
|
.board-card:has(.card-bar-pulse) { border-color: var(--t-danger); }
|
||||||
@@ -402,7 +403,7 @@ body.settings-peeking #settings-overlay > div { pointer-events: auto; }
|
|||||||
.card-done-badge { position: absolute; bottom: 0; right: 0; width: 0; height: 0; border-style: solid; border-width: 0 0 24px 24px; border-color: transparent transparent #4ade80 transparent; z-index: 1; }
|
.card-done-badge { position: absolute; bottom: 0; right: 0; width: 0; height: 0; border-style: solid; border-width: 0 0 24px 24px; border-color: transparent transparent #4ade80 transparent; z-index: 1; }
|
||||||
.card-done-badge i { position: absolute; bottom: -22px; right: 1px; font-size: 10px; color: #000; }
|
.card-done-badge i { position: absolute; bottom: -22px; right: 1px; font-size: 10px; color: #000; }
|
||||||
.board-card:has(.card-bar-pulse) .card-bar-pulse ~ * .text-t-danger { animation: pulse-glow 2s ease-in-out infinite; }
|
.board-card:has(.card-bar-pulse) .card-bar-pulse ~ * .text-t-danger { animation: pulse-glow 2s ease-in-out infinite; }
|
||||||
.card-spinner { animation: spin 0.8s linear infinite; }
|
.card-spinner { animation: spin 0.8s linear infinite; display: inline-block; }
|
||||||
|
|
||||||
/* Cards */
|
/* Cards */
|
||||||
.card-bar { image-rendering: pixelated; height: var(--ui-bar-height, 3px); }
|
.card-bar { image-rendering: pixelated; height: var(--ui-bar-height, 3px); }
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="60.122112mm"
|
||||||
|
height="60.122108mm"
|
||||||
|
viewBox="0 0 60.122112 60.122108"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs1" /><g
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-8.2147047,-118.8156)"><rect
|
||||||
|
style="fill:#06080f;fill-opacity:1;stroke-width:1.5875"
|
||||||
|
id="rect4"
|
||||||
|
width="60.122112"
|
||||||
|
height="60.122112"
|
||||||
|
x="8.2147045"
|
||||||
|
y="118.81558" /><path
|
||||||
|
id="rect1"
|
||||||
|
style="fill:#15bff6;fill-opacity:1;stroke-width:1.5875"
|
||||||
|
d="m 41.162784,130.18519 v 37.02565 h 20.35844 v -37.02565 z m -25.209835,4.34547 v 23.45335 h 5.000729 v -9.22631 h 8.928654 v 9.22631 h 5.000212 v -23.45335 h -5.000212 v 9.22683 h -8.928654 v -9.22683 z" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 926 B |
@@ -1159,6 +1159,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>hyprcrd</title>
|
<title>hyprcrd</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||||
<link rel="stylesheet" href="css/fonts.css">
|
<link rel="stylesheet" href="css/fonts.css">
|
||||||
<link rel="stylesheet" href="css/vendor/phosphor-regular.css">
|
<link rel="stylesheet" href="css/vendor/phosphor-regular.css">
|
||||||
<link rel="stylesheet" href="css/vendor/phosphor-fill.css">
|
<link rel="stylesheet" href="css/vendor/phosphor-fill.css">
|
||||||
@@ -1927,6 +1928,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<div class="toggle-row" data-toggle="setting-confirm-delete"><span class="toggle-label">Confirm before delete</span><div class="toggle-switch" id="setting-confirm-delete"></div></div>
|
<div class="toggle-row" data-toggle="setting-confirm-delete"><span class="toggle-label">Confirm before delete</span><div class="toggle-switch" id="setting-confirm-delete"></div></div>
|
||||||
<div class="toggle-row" data-toggle="setting-auto-save"><span class="toggle-label">Auto-save on close (editor)</span><div class="toggle-switch" id="setting-auto-save"></div></div>
|
<div class="toggle-row" data-toggle="setting-auto-save"><span class="toggle-label">Auto-save on close (editor)</span><div class="toggle-switch" id="setting-auto-save"></div></div>
|
||||||
<div class="toggle-row" data-toggle="setting-open-switches-bucket"><span class="toggle-label">Switch to card's bucket on open (search / link)</span><div class="toggle-switch" id="setting-open-switches-bucket"></div></div>
|
<div class="toggle-row" data-toggle="setting-open-switches-bucket"><span class="toggle-label">Switch to card's bucket on open (search / link)</span><div class="toggle-switch" id="setting-open-switches-bucket"></div></div>
|
||||||
|
<div class="toggle-row" data-toggle="setting-tasks-show-shopping"><span class="toggle-label">Show shopping list items in task collector</span><div class="toggle-switch" id="setting-tasks-show-shopping"></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ const summary = $('tasks-summary');
|
|||||||
const countBadge = $('tasks-open-count');
|
const countBadge = $('tasks-open-count');
|
||||||
|
|
||||||
function collectTasks() {
|
function collectTasks() {
|
||||||
|
const showShopping = localStorage.getItem('hyprcrd-tasks-show-shopping') === '1';
|
||||||
const all = [];
|
const all = [];
|
||||||
for (const card of state.cards) {
|
for (const card of state.cards) {
|
||||||
|
if (!showShopping) {
|
||||||
|
const tags = (card.tags || '').split(',').map(s => s.trim());
|
||||||
|
if (tags.includes('einkauf') || tags.includes('shopping')) continue;
|
||||||
|
}
|
||||||
const re = /- \[([ xX])\][ \t]*([^\n\r]*)/g;
|
const re = /- \[([ xX])\][ \t]*([^\n\r]*)/g;
|
||||||
let m, idx = 0;
|
let m, idx = 0;
|
||||||
while ((m = re.exec(card.body || '')) !== null) {
|
while ((m = re.exec(card.body || '')) !== null) {
|
||||||
|
|||||||
@@ -451,6 +451,7 @@ function initBehaviorSettings() {
|
|||||||
'setting-confirm-delete': { key: 'hyprcrd-confirm-delete', def: '1' },
|
'setting-confirm-delete': { key: 'hyprcrd-confirm-delete', def: '1' },
|
||||||
'setting-auto-save': { key: 'hyprcrd-auto-save', def: '0' },
|
'setting-auto-save': { key: 'hyprcrd-auto-save', def: '0' },
|
||||||
'setting-open-switches-bucket': { key: 'hyprcrd-open-switches-bucket', def: '0' },
|
'setting-open-switches-bucket': { key: 'hyprcrd-open-switches-bucket', def: '0' },
|
||||||
|
'setting-tasks-show-shopping': { key: 'hyprcrd-tasks-show-shopping', def: '0' },
|
||||||
};
|
};
|
||||||
for (const [id, cfg] of Object.entries(selects)) {
|
for (const [id, cfg] of Object.entries(selects)) {
|
||||||
const el = $(id);
|
const el = $(id);
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ function openDialog() {
|
|||||||
const prev = getActiveIds();
|
const prev = getActiveIds();
|
||||||
const next = [...selected];
|
const next = [...selected];
|
||||||
setActiveIds(next);
|
setActiveIds(next);
|
||||||
status.textContent = 'saving…';
|
status.innerHTML = '<i class="ph ph-spinner card-spinner" style="font-size:14px;vertical-align:middle;margin-right:4px;"></i> saving — please wait!';
|
||||||
try {
|
try {
|
||||||
// Remove deactivated lists from relay.
|
// Remove deactivated lists from relay.
|
||||||
for (const id of prev) {
|
for (const id of prev) {
|
||||||
|
|||||||
@@ -127,7 +127,9 @@ describe('Tim Plugin: Open & Close', () => {
|
|||||||
describe('Tim Plugin: Tabs', () => {
|
describe('Tim Plugin: Tabs', () => {
|
||||||
afterEach(async () => { closeTim(); await closeAll(); });
|
afterEach(async () => { closeTim(); await closeAll(); });
|
||||||
|
|
||||||
it('duration tab is active on load', () => {
|
it('duration tab is active on load', async () => {
|
||||||
|
openTim();
|
||||||
|
await sleep(30);
|
||||||
const tab = document.querySelector('.tim-tab[data-tim-tab="duration"]');
|
const tab = document.querySelector('.tim-tab[data-tim-tab="duration"]');
|
||||||
expect(tab.classList.contains('active')).toBe(true);
|
expect(tab.classList.contains('active')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="60.122112mm"
|
||||||
|
height="60.122108mm"
|
||||||
|
viewBox="0 0 60.122112 60.122108"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs1" /><g
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-8.2147047,-118.8156)"><rect
|
||||||
|
style="fill:#06080f;fill-opacity:1;stroke-width:1.5875"
|
||||||
|
id="rect4"
|
||||||
|
width="60.122112"
|
||||||
|
height="60.122112"
|
||||||
|
x="8.2147045"
|
||||||
|
y="118.81558" /><path
|
||||||
|
id="rect1"
|
||||||
|
style="fill:#15bff6;fill-opacity:1;stroke-width:1.5875"
|
||||||
|
d="m 41.162784,130.18519 v 37.02565 h 20.35844 v -37.02565 z m -25.209835,4.34547 v 23.45335 h 5.000729 v -9.22631 h 8.928654 v 9.22631 h 5.000212 v -23.45335 h -5.000212 v 9.22683 h -8.928654 v -9.22683 z" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 926 B |
+9
-1
@@ -24,7 +24,9 @@ function getSessionTenant(PDO $db): ?array {
|
|||||||
if (!$token) return null;
|
if (!$token) return null;
|
||||||
$stmt = $db->prepare("SELECT t.* FROM sessions s JOIN tenants t ON t.id = s.tenant_id WHERE s.id = :tok AND s.expires_at > datetime('now')");
|
$stmt = $db->prepare("SELECT t.* FROM sessions s JOIN tenants t ON t.id = s.tenant_id WHERE s.id = :tok AND s.expires_at > datetime('now')");
|
||||||
$stmt->execute(['tok' => $token]);
|
$stmt->execute(['tok' => $token]);
|
||||||
return $stmt->fetch() ?: null;
|
$tenant = $stmt->fetch() ?: null;
|
||||||
|
if ($tenant) touchLastSeen($db, (int)$tenant['id']);
|
||||||
|
return $tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
function destroySession(PDO $db): void {
|
function destroySession(PDO $db): void {
|
||||||
@@ -62,5 +64,11 @@ function authClient(PDO $db): ?array {
|
|||||||
$tenant = getTenantByUsername($db, $username);
|
$tenant = getTenantByUsername($db, $username);
|
||||||
if (!$tenant) return null;
|
if (!$tenant) return null;
|
||||||
if (!password_verify($key, $tenant['secret_hash'])) return null;
|
if (!password_verify($key, $tenant['secret_hash'])) return null;
|
||||||
|
touchLastSeen($db, (int)$tenant['id']);
|
||||||
return $tenant;
|
return $tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function touchLastSeen(PDO $db, int $tenantId): void {
|
||||||
|
$db->prepare("UPDATE tenants SET last_seen_at = datetime('now') WHERE id = :id")
|
||||||
|
->execute(['id' => $tenantId]);
|
||||||
|
}
|
||||||
@@ -133,6 +133,14 @@ $db->exec('CREATE TABLE IF NOT EXISTS tenants (
|
|||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
)');
|
)');
|
||||||
|
|
||||||
|
// last_seen_at: updated on every authenticated request so the admin
|
||||||
|
// can see when a user was last active.
|
||||||
|
$tenantCols = $db->query("PRAGMA table_info(tenants)")->fetchAll();
|
||||||
|
$tenantColNames = array_column($tenantCols, 'name');
|
||||||
|
if (!in_array('last_seen_at', $tenantColNames)) {
|
||||||
|
$db->exec("ALTER TABLE tenants ADD COLUMN last_seen_at TEXT");
|
||||||
|
}
|
||||||
|
|
||||||
$db->exec('CREATE TABLE IF NOT EXISTS settings (
|
$db->exec('CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT
|
value TEXT
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
|
|
||||||
if ($route === '/api/users' && $method === 'GET') {
|
if ($route === '/api/users' && $method === 'GET') {
|
||||||
if (!$sessionTenant || !$sessionTenant['is_admin']) jsonResponse(['error' => 'admin only'], 403);
|
if (!$sessionTenant || !$sessionTenant['is_admin']) jsonResponse(['error' => 'admin only'], 403);
|
||||||
$rows = $db->query('SELECT id, username, is_admin, created_at FROM tenants ORDER BY id ASC')->fetchAll();
|
$rows = $db->query('SELECT id, username, is_admin, created_at, last_seen_at FROM tenants ORDER BY id ASC')->fetchAll();
|
||||||
jsonResponse(['users' => $rows]);
|
jsonResponse(['users' => $rows]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-25
@@ -68,14 +68,15 @@ function showView(name) {
|
|||||||
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
|
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
|
||||||
name = 'change-password';
|
name = 'change-password';
|
||||||
}
|
}
|
||||||
['setup', 'login', 'change-password', 'compose', 'account', 'admin', 'chat', 'shopping'].forEach(v => {
|
['setup', 'login', 'change-password', 'compose', 'inbox', 'account', 'admin', 'chat', 'shopping'].forEach(v => {
|
||||||
$('view-' + v)?.classList.add('hidden');
|
$('view-' + v)?.classList.add('hidden');
|
||||||
});
|
});
|
||||||
$('view-' + name)?.classList.remove('hidden');
|
$('view-' + name)?.classList.remove('hidden');
|
||||||
|
|
||||||
if (['compose', 'account', 'admin', 'chat', 'shopping'].includes(name)) {
|
if (['compose', 'inbox', 'account', 'admin', 'chat', 'shopping'].includes(name)) {
|
||||||
$('nav-buttons').classList.remove('hidden');
|
$('nav-buttons').classList.remove('hidden');
|
||||||
$('nav-compose').classList.toggle('active', name === 'compose');
|
$('nav-compose').classList.toggle('active', name === 'compose');
|
||||||
|
$('nav-inbox')?.classList.toggle('active', name === 'inbox');
|
||||||
$('nav-chat')?.classList.toggle('active', name === 'chat');
|
$('nav-chat')?.classList.toggle('active', name === 'chat');
|
||||||
$('nav-shopping')?.classList.toggle('active', name === 'shopping');
|
$('nav-shopping')?.classList.toggle('active', name === 'shopping');
|
||||||
$('nav-account').classList.toggle('active', name === 'account');
|
$('nav-account').classList.toggle('active', name === 'account');
|
||||||
@@ -92,13 +93,19 @@ function showView(name) {
|
|||||||
|
|
||||||
if (name === 'compose') {
|
if (name === 'compose') {
|
||||||
loadTenants().then(() => populateRecipientPicker());
|
loadTenants().then(() => populateRecipientPicker());
|
||||||
|
$('compose-body')?.focus();
|
||||||
|
}
|
||||||
|
if (name === 'inbox') {
|
||||||
loadInbox();
|
loadInbox();
|
||||||
loadOutbox();
|
loadOutbox();
|
||||||
loadCollabInbox();
|
loadCollabInbox();
|
||||||
startInboxPoll();
|
startInboxPoll();
|
||||||
$('compose-body')?.focus();
|
|
||||||
} else {
|
} else {
|
||||||
stopInboxPoll();
|
stopInboxPoll();
|
||||||
|
// Keep the header badge count fresh even outside the inbox
|
||||||
|
// view — a quiet load updates the count without rendering the
|
||||||
|
// full list (the DOM elements are in the hidden inbox view).
|
||||||
|
loadInbox({ quiet: true });
|
||||||
}
|
}
|
||||||
if (name === 'chat') {
|
if (name === 'chat') {
|
||||||
loadChatThreads();
|
loadChatThreads();
|
||||||
@@ -114,7 +121,7 @@ function showView(name) {
|
|||||||
}
|
}
|
||||||
// Remember the active view so a page reload lands back here
|
// Remember the active view so a page reload lands back here
|
||||||
// instead of defaulting to compose every time.
|
// instead of defaulting to compose every time.
|
||||||
if (['compose', 'account', 'admin', 'chat', 'shopping'].includes(name)) {
|
if (['compose', 'inbox', 'account', 'admin', 'chat', 'shopping'].includes(name)) {
|
||||||
try { localStorage.setItem('relay-last-view', name); } catch {}
|
try { localStorage.setItem('relay-last-view', name); } catch {}
|
||||||
}
|
}
|
||||||
if (name === 'admin') {
|
if (name === 'admin') {
|
||||||
@@ -139,7 +146,7 @@ function startInboxPoll() {
|
|||||||
stopInboxPoll();
|
stopInboxPoll();
|
||||||
inboxPollHandle = setInterval(() => {
|
inboxPollHandle = setInterval(() => {
|
||||||
if (inboxBusy) return;
|
if (inboxBusy) return;
|
||||||
if (!$('view-compose')?.classList.contains('hidden')) {
|
if (!$('view-inbox')?.classList.contains('hidden')) {
|
||||||
loadInbox({ quiet: true });
|
loadInbox({ quiet: true });
|
||||||
loadOutbox({ quiet: true });
|
loadOutbox({ quiet: true });
|
||||||
loadCollabInbox({ quiet: true });
|
loadCollabInbox({ quiet: true });
|
||||||
@@ -173,21 +180,7 @@ window.toggleNavMenu = toggleNavMenu;
|
|||||||
* if at all (the inbox is already on screen). */
|
* if at all (the inbox is already on screen). */
|
||||||
function jumpToInbox() {
|
function jumpToInbox() {
|
||||||
if (!me) return;
|
if (!me) return;
|
||||||
if ($('view-compose')?.classList.contains('hidden')) {
|
showView('inbox');
|
||||||
showView('compose');
|
|
||||||
}
|
|
||||||
// Wait one frame so the (possibly just-shown) view has measurable
|
|
||||||
// dimensions before scrollIntoView fires
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const target = $('inbox-section');
|
|
||||||
if (!target) return;
|
|
||||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
target.classList.remove('flash-highlight');
|
|
||||||
// Restart the animation by toggling the class
|
|
||||||
void target.offsetWidth;
|
|
||||||
target.classList.add('flash-highlight');
|
|
||||||
setTimeout(() => target.classList.remove('flash-highlight'), 1600);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
window.jumpToInbox = jumpToInbox;
|
window.jumpToInbox = jumpToInbox;
|
||||||
// Tap outside the menu closes it
|
// Tap outside the menu closes it
|
||||||
@@ -285,7 +278,7 @@ function routePostLogin(status) {
|
|||||||
toast('set your client secret to start using the relay');
|
toast('set your client secret to start using the relay');
|
||||||
} else {
|
} else {
|
||||||
const last = localStorage.getItem('relay-last-view') || 'compose';
|
const last = localStorage.getItem('relay-last-view') || 'compose';
|
||||||
showView(['compose', 'account', 'admin', 'chat', 'shopping'].includes(last) ? last : 'compose');
|
showView(['compose', 'inbox', 'account', 'admin', 'chat', 'shopping'].includes(last) ? last : 'compose');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -641,7 +634,6 @@ async function loadCollabInbox({ quiet = false } = {}) {
|
|||||||
<span style="color:var(--dim);margin-left:6px;">${c.mine ? 'you' : escapeHtml(c.author_username || '—')}</span>
|
<span style="color:var(--dim);margin-left:6px;">${c.mine ? 'you' : escapeHtml(c.author_username || '—')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="title">${c.title ? escapeHtml(c.title) : '<span style="color:var(--dim);font-style:italic;font-weight:normal;">(untitled)</span>'}</div>
|
<div class="title">${c.title ? escapeHtml(c.title) : '<span style="color:var(--dim);font-style:italic;font-weight:normal;">(untitled)</span>'}</div>
|
||||||
${c.preview ? `<div style="color:var(--dim);font-size:11px;margin-top:2px;">${escapeHtml(c.preview)}</div>` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">${escapeHtml(formatRelTime(c.updated_at))}</div>
|
<div class="meta">${escapeHtml(formatRelTime(c.updated_at))}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1006,19 +998,22 @@ async function loadUsers() {
|
|||||||
</div>`;
|
</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
list.innerHTML = users.map(u => `
|
list.innerHTML = users.map(u => {
|
||||||
|
const seenText = u.last_seen_at ? 'last seen ' + formatRelTime(u.last_seen_at) : 'never connected';
|
||||||
|
return `
|
||||||
<div class="user-row" data-id="${u.id}">
|
<div class="user-row" data-id="${u.id}">
|
||||||
<i class="ph ph-user-circle user-icon"></i>
|
<i class="ph ph-user-circle user-icon"></i>
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<div class="name">${escapeHtml(u.username)} ${u.is_admin ? '<span class="badge">admin</span>' : ''}</div>
|
<div class="name">${escapeHtml(u.username)} ${u.is_admin ? '<span class="badge">admin</span>' : ''}</div>
|
||||||
<div class="meta">created ${u.created_at}</div>
|
<div class="meta">created ${u.created_at} · ${seenText}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-secondary" onclick="resetUserPassword(${u.id}, '${escapeHtml(u.username)}')"><i class="ph ph-key"></i> reset pw</button>
|
<button class="btn btn-secondary" onclick="resetUserPassword(${u.id}, '${escapeHtml(u.username)}')"><i class="ph ph-key"></i> reset pw</button>
|
||||||
${u.username !== me?.username ? `<button class="btn btn-danger" onclick="deleteUser(${u.id}, '${escapeHtml(u.username)}')"><i class="ph ph-trash"></i> delete</button>` : ''}
|
${u.username !== me?.username ? `<button class="btn btn-danger" onclick="deleteUser(${u.id}, '${escapeHtml(u.username)}')"><i class="ph ph-trash"></i> delete</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createUser() {
|
async function createUser() {
|
||||||
|
|||||||
+15
-7
@@ -7,6 +7,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="theme-color" content="#1e293b">
|
<meta name="theme-color" content="#1e293b">
|
||||||
<title>hyprcrd relay</title>
|
<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="fonts/phosphor.css">
|
||||||
<link rel="stylesheet" href="ui/style.css?v=<?= filemtime(__DIR__ . '/style.css') ?>">
|
<link rel="stylesheet" href="ui/style.css?v=<?= filemtime(__DIR__ . '/style.css') ?>">
|
||||||
</head>
|
</head>
|
||||||
@@ -14,14 +15,17 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
|
|
||||||
<header>
|
<header>
|
||||||
<span class="dot"></span>
|
<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>
|
<i class="ph ph-broadcast brand-icon"></i>
|
||||||
<h1>hyprcrd relay</h1>
|
<h1>hyprcrd relay</h1>
|
||||||
|
</a>
|
||||||
<button id="header-status" class="header-status pixel" onclick="jumpToInbox()" title="jump to inbox"></button>
|
<button id="header-status" class="header-status pixel" onclick="jumpToInbox()" title="jump to inbox"></button>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<button id="nav-burger" class="nav-btn hidden" aria-label="menu" onclick="toggleNavMenu()"><i class="ph ph-list"></i></button>
|
<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">
|
<div id="nav-buttons" class="hidden">
|
||||||
<span id="nav-user" class="user"></span>
|
<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-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-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-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-account" class="nav-btn" onclick="showView('account')"><i class="ph ph-user-circle"></i> account</button>
|
||||||
@@ -58,15 +62,17 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
<div id="view-login" class="auth-form hidden">
|
<div id="view-login" class="auth-form hidden">
|
||||||
<i class="ph ph-key auth-icon"></i>
|
<i class="ph ph-key auth-icon"></i>
|
||||||
<h2>login</h2>
|
<h2>login</h2>
|
||||||
|
<form id="login-form" autocomplete="on" onsubmit="event.preventDefault(); doLogin();">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>username</label>
|
<label for="login-username">username</label>
|
||||||
<input type="text" id="login-username" autocomplete="username">
|
<input type="text" id="login-username" name="username" autocomplete="username">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>password</label>
|
<label for="login-password">password</label>
|
||||||
<input type="password" id="login-password" autocomplete="current-password">
|
<input type="password" id="login-password" name="password" autocomplete="current-password">
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" onclick="doLogin()"><i class="ph ph-sign-in"></i> unlock</button>
|
<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 id="login-err" class="err"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -106,7 +112,10 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
</div>
|
</div>
|
||||||
<div id="compose-status" class="compose-status-bar"></div>
|
<div id="compose-status" class="compose-status-bar"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="compose-sidebar" class="compose-sidebar">
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Inbox view (inbox + shared buckets + sent) ── -->
|
||||||
|
<div id="view-inbox" class="hidden">
|
||||||
<div id="inbox-section" class="section">
|
<div id="inbox-section" class="section">
|
||||||
<h2><i class="ph ph-tray"></i> your inbox <span class="count" id="inbox-count">0</span>
|
<h2><i class="ph ph-tray"></i> your inbox <span class="count" id="inbox-count">0</span>
|
||||||
<span style="flex:1"></span>
|
<span style="flex:1"></span>
|
||||||
@@ -123,7 +132,6 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
<div id="outbox-list"></div>
|
<div id="outbox-list"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Forced password change (first login) ── -->
|
<!-- ── Forced password change (first login) ── -->
|
||||||
<div id="view-change-password" class="auth-form hidden">
|
<div id="view-change-password" class="auth-form hidden">
|
||||||
|
|||||||
+23
-42
@@ -232,13 +232,8 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point
|
|||||||
#view-compose:not(.hidden) {
|
#view-compose:not(.hidden) {
|
||||||
display: flex; flex-direction: column; gap: 16px;
|
display: flex; flex-direction: column; gap: 16px;
|
||||||
}
|
}
|
||||||
/* Same 16 px rhythm between inbox / shared / sent on mobile as
|
#view-inbox:not(.hidden) {
|
||||||
between compose and inbox — desktop re-declares this inside the
|
display: flex; flex-direction: column; gap: 16px;
|
||||||
grid rule further down. */
|
|
||||||
#compose-sidebar {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
}
|
||||||
.compose-attachments {
|
.compose-attachments {
|
||||||
display: flex; flex-wrap: wrap; gap: 6px; min-height: 0;
|
display: flex; flex-wrap: wrap; gap: 6px; min-height: 0;
|
||||||
@@ -677,23 +672,28 @@ header .nav-btn .ph { font-size:11px; vertical-align:-1px; margin-right:2px; }
|
|||||||
display:flex !important;
|
display:flex !important;
|
||||||
flex-direction:column;
|
flex-direction:column;
|
||||||
align-items:stretch;
|
align-items:stretch;
|
||||||
position:absolute;
|
position:fixed;
|
||||||
top:100%;
|
top: 48px;
|
||||||
right:12px;
|
left: 0;
|
||||||
margin-top:6px;
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
margin: 0;
|
||||||
background:var(--surface);
|
background:var(--surface);
|
||||||
border:1px solid var(--border);
|
border-top:1px solid var(--border);
|
||||||
border-radius:var(--radius-lg);
|
border-radius: 0;
|
||||||
box-shadow: var(--shadow-popover);
|
padding: 12px 16px;
|
||||||
padding:6px;
|
gap: 6px;
|
||||||
gap:4px;
|
z-index:100;
|
||||||
min-width:180px;
|
|
||||||
z-index:50;
|
|
||||||
}
|
}
|
||||||
#nav-buttons.menu-open .nav-btn {
|
#nav-buttons.menu-open .nav-btn {
|
||||||
text-align:left;
|
text-align:left;
|
||||||
width:100%;
|
width:100%;
|
||||||
padding: 8px 12px;
|
padding: 14px 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
#nav-buttons.menu-open .nav-btn .ph {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#view-admin .section {
|
#view-admin .section {
|
||||||
@@ -757,18 +757,11 @@ header .nav-btn .ph { font-size:11px; vertical-align:-1px; margin-right:2px; }
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compose view: 2 columns. Left is the compose editor. Right is a
|
/* Compose view: single column, centered. Inbox/shared/sent are in
|
||||||
sidebar with inbox → shared → sent stacked vertically. */
|
their own view now — no sidebar to fill the right-hand space. */
|
||||||
#view-compose:not(.hidden) {
|
#view-compose:not(.hidden) {
|
||||||
display: grid !important;
|
max-width: 720px;
|
||||||
grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr);
|
margin: 0 auto;
|
||||||
gap: 24px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
#compose-sidebar {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Cards: depth via shadow + larger padding */
|
/* Cards: depth via shadow + larger padding */
|
||||||
@@ -810,18 +803,6 @@ header .nav-btn .ph { font-size:11px; vertical-align:-1px; margin-right:2px; }
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inbox card sticks under the header */
|
|
||||||
#inbox-section {
|
|
||||||
position: sticky;
|
|
||||||
top: 24px;
|
|
||||||
transition: box-shadow .15s;
|
|
||||||
}
|
|
||||||
#inbox-section:hover {
|
|
||||||
box-shadow:
|
|
||||||
0 1px 2px rgba(0,0,0,0.3),
|
|
||||||
0 12px 32px -8px rgba(0,0,0,0.5),
|
|
||||||
0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Admin: 2-column grid */
|
/* Admin: 2-column grid */
|
||||||
#view-admin:not(.hidden) .section {
|
#view-admin:not(.hidden) .section {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="60.122112mm"
|
||||||
|
height="60.122108mm"
|
||||||
|
viewBox="0 0 60.122112 60.122108"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs1" /><g
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-8.2147047,-118.8156)"><rect
|
||||||
|
style="fill:#06080f;fill-opacity:1;stroke-width:1.5875"
|
||||||
|
id="rect4"
|
||||||
|
width="60.122112"
|
||||||
|
height="60.122112"
|
||||||
|
x="8.2147045"
|
||||||
|
y="118.81558" /><path
|
||||||
|
id="rect1"
|
||||||
|
style="fill:#15bff6;fill-opacity:1;stroke-width:1.5875"
|
||||||
|
d="m 41.162784,130.18519 v 37.02565 h 20.35844 v -37.02565 z m -25.209835,4.34547 v 23.45335 h 5.000729 v -9.22631 h 8.928654 v 9.22631 h 5.000212 v -23.45335 h -5.000212 v 9.22683 h -8.928654 v -9.22683 z" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 926 B |
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>hyprcrd</title>
|
<title>hyprcrd</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||||
<meta name="description" content="Self-hosted card manager. Markdown, tags, wiki-links, calendar, encrypted relay. Find everything.">
|
<meta name="description" content="Self-hosted card manager. Markdown, tags, wiki-links, calendar, encrypted relay. Find everything.">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Silkscreen&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Silkscreen&display=swap" rel="stylesheet">
|
||||||
|
|||||||
Reference in new issue
Block a user