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:
thandClaude Opus 4.6 committed 2026-04-17 13:16:23 +02:00
1 parent 8e738ed9d4
commit d50f9fe789
19 files changed
+209 -101

No files matched your search

+20 -25
View File
@@ -68,14 +68,15 @@ 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', 'shopping'].forEach(v => {
['setup', 'login', 'change-password', 'compose', 'inbox', 'account', 'admin', 'chat', 'shopping'].forEach(v => {
$('view-' + v)?.classList.add('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-compose').classList.toggle('active', name === 'compose');
$('nav-inbox')?.classList.toggle('active', name === 'inbox');
$('nav-chat')?.classList.toggle('active', name === 'chat');
$('nav-shopping')?.classList.toggle('active', name === 'shopping');
$('nav-account').classList.toggle('active', name === 'account');
@@ -92,13 +93,19 @@ function showView(name) {
if (name === 'compose') {
loadTenants().then(() => populateRecipientPicker());
$('compose-body')?.focus();
}
if (name === 'inbox') {
loadInbox();
loadOutbox();
loadCollabInbox();
startInboxPoll();
$('compose-body')?.focus();
} else {
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') {
loadChatThreads();
@@ -114,7 +121,7 @@ function showView(name) {
}
// Remember the active view so a page reload lands back here
// 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 {}
}
if (name === 'admin') {
@@ -139,7 +146,7 @@ function startInboxPoll() {
stopInboxPoll();
inboxPollHandle = setInterval(() => {
if (inboxBusy) return;
if (!$('view-compose')?.classList.contains('hidden')) {
if (!$('view-inbox')?.classList.contains('hidden')) {
loadInbox({ quiet: true });
loadOutbox({ quiet: true });
loadCollabInbox({ quiet: true });
@@ -173,21 +180,7 @@ window.toggleNavMenu = toggleNavMenu;
* if at all (the inbox is already on screen). */
function jumpToInbox() {
if (!me) return;
if ($('view-compose')?.classList.contains('hidden')) {
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);
});
showView('inbox');
}
window.jumpToInbox = jumpToInbox;
// Tap outside the menu closes it
@@ -285,7 +278,7 @@ function routePostLogin(status) {
toast('set your client secret to start using the relay');
} else {
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>
</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 class="meta">${escapeHtml(formatRelTime(c.updated_at))}</div>
</div>
@@ -1006,19 +998,22 @@ async function loadUsers() {
</div>`;
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}">
<i class="ph ph-user-circle user-icon"></i>
<div class="info">
<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 class="actions">
<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>` : ''}
</div>
</div>
`).join('');
`;
}).join('');
}
async function createUser() {