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
+209
-101
No files matched your search
+20
-25
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user