diff --git a/config.php b/config.php index a0c64f3..9bdefd4 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ + + + diff --git a/public/css/style.css b/public/css/style.css index d782e29..440fab2 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -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); } @keyframes spin { to { transform: rotate(360deg); } } + @keyframes pulse-glow { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } .card-bar-pulse { animation: pulse-glow 2s ease-in-out infinite; } .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 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; } -.card-spinner { animation: spin 0.8s linear infinite; } +.card-spinner { animation: spin 0.8s linear infinite; display: inline-block; } /* Cards */ .card-bar { image-rendering: pixelated; height: var(--ui-bar-height, 3px); } diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..cfa8719 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,24 @@ + + + + diff --git a/public/index.php b/public/index.php index 0e40b1f..bdf1955 100644 --- a/public/index.php +++ b/public/index.php @@ -1159,6 +1159,7 @@ if (str_starts_with($route, '/api/')) { hyprcrd + @@ -1927,6 +1928,7 @@ if (str_starts_with($route, '/api/')) {
Confirm before delete
Auto-save on close (editor)
Switch to card's bucket on open (search / link)
+
Show shopping list items in task collector
diff --git a/public/js/modules/tasks.js b/public/js/modules/tasks.js index 3f69d2f..999a12e 100644 --- a/public/js/modules/tasks.js +++ b/public/js/modules/tasks.js @@ -13,8 +13,13 @@ const summary = $('tasks-summary'); const countBadge = $('tasks-open-count'); function collectTasks() { + const showShopping = localStorage.getItem('hyprcrd-tasks-show-shopping') === '1'; const all = []; 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; let m, idx = 0; while ((m = re.exec(card.body || '')) !== null) { diff --git a/public/js/modules/theme.js b/public/js/modules/theme.js index c383677..9834a0b 100644 --- a/public/js/modules/theme.js +++ b/public/js/modules/theme.js @@ -451,6 +451,7 @@ function initBehaviorSettings() { 'setting-confirm-delete': { key: 'hyprcrd-confirm-delete', def: '1' }, 'setting-auto-save': { key: 'hyprcrd-auto-save', 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)) { const el = $(id); diff --git a/public/plugins/shopping/shopping.js b/public/plugins/shopping/shopping.js index 0b3eecb..e22734a 100644 --- a/public/plugins/shopping/shopping.js +++ b/public/plugins/shopping/shopping.js @@ -241,7 +241,7 @@ function openDialog() { const prev = getActiveIds(); const next = [...selected]; setActiveIds(next); - status.textContent = 'saving…'; + status.innerHTML = ' saving — please wait!'; try { // Remove deactivated lists from relay. for (const id of prev) { diff --git a/public/plugins/tests/tim.test.js b/public/plugins/tests/tim.test.js index fddf77d..87ee666 100644 --- a/public/plugins/tests/tim.test.js +++ b/public/plugins/tests/tim.test.js @@ -127,7 +127,9 @@ describe('Tim Plugin: Open & Close', () => { describe('Tim Plugin: Tabs', () => { 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"]'); expect(tab.classList.contains('active')).toBe(true); }); diff --git a/relay/favicon.svg b/relay/favicon.svg new file mode 100644 index 0000000..cfa8719 --- /dev/null +++ b/relay/favicon.svg @@ -0,0 +1,24 @@ + + + + diff --git a/relay/inc/auth.php b/relay/inc/auth.php index 94e55ae..2c67275 100644 --- a/relay/inc/auth.php +++ b/relay/inc/auth.php @@ -24,7 +24,9 @@ function getSessionTenant(PDO $db): ?array { 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->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 { @@ -62,5 +64,11 @@ function authClient(PDO $db): ?array { $tenant = getTenantByUsername($db, $username); if (!$tenant) return null; if (!password_verify($key, $tenant['secret_hash'])) return null; + touchLastSeen($db, (int)$tenant['id']); 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]); +} diff --git a/relay/inc/bootstrap.php b/relay/inc/bootstrap.php index c552a5c..2f3edd4 100644 --- a/relay/inc/bootstrap.php +++ b/relay/inc/bootstrap.php @@ -133,6 +133,14 @@ $db->exec('CREATE TABLE IF NOT EXISTS tenants ( 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 ( key TEXT PRIMARY KEY, value TEXT diff --git a/relay/inc/routes/admin.php b/relay/inc/routes/admin.php index ddfe6e2..f53da58 100644 --- a/relay/inc/routes/admin.php +++ b/relay/inc/routes/admin.php @@ -14,7 +14,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; } if ($route === '/api/users' && $method === 'GET') { 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]); } diff --git a/relay/ui/app.js b/relay/ui/app.js index 1c1e3fd..f3d745e 100644 --- a/relay/ui/app.js +++ b/relay/ui/app.js @@ -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 } = {}) { ${c.mine ? 'you' : escapeHtml(c.author_username || '—')}
${c.title ? escapeHtml(c.title) : '(untitled)'}
- ${c.preview ? `
${escapeHtml(c.preview)}
` : ''}
${escapeHtml(formatRelTime(c.updated_at))}
@@ -1006,19 +998,22 @@ async function loadUsers() { `; 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 `
${escapeHtml(u.username)} ${u.is_admin ? 'admin' : ''}
-
created ${u.created_at}
+
created ${u.created_at} · ${seenText}
${u.username !== me?.username ? `` : ''}
- `).join(''); + `; + }).join(''); } async function createUser() { diff --git a/relay/ui/layout.php b/relay/ui/layout.php index 418779a..9cf75d1 100644 --- a/relay/ui/layout.php +++ b/relay/ui/layout.php @@ -7,6 +7,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; } hyprcrd relay + @@ -14,14 +15,17 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
- -

hyprcrd relay

+ + +

hyprcrd relay

+
-
-
-

your inbox 0 - - -

-
-
-
-

shared buckets 0

-
-
-
-

sent 0

-
-
+
+ + + diff --git a/relay/ui/style.css b/relay/ui/style.css index 5f6fbb6..cab2c70 100644 --- a/relay/ui/style.css +++ b/relay/ui/style.css @@ -232,13 +232,8 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point #view-compose:not(.hidden) { display: flex; flex-direction: column; gap: 16px; } -/* Same 16 px rhythm between inbox / shared / sent on mobile as - between compose and inbox — desktop re-declares this inside the - grid rule further down. */ -#compose-sidebar { - display: flex; - flex-direction: column; - gap: 16px; +#view-inbox:not(.hidden) { + display: flex; flex-direction: column; gap: 16px; } .compose-attachments { 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; flex-direction:column; align-items:stretch; - position:absolute; - top:100%; - right:12px; - margin-top:6px; + position:fixed; + top: 48px; + left: 0; + right: 0; + bottom: 0; + margin: 0; background:var(--surface); - border:1px solid var(--border); - border-radius:var(--radius-lg); - box-shadow: var(--shadow-popover); - padding:6px; - gap:4px; - min-width:180px; - z-index:50; + border-top:1px solid var(--border); + border-radius: 0; + padding: 12px 16px; + gap: 6px; + z-index:100; } #nav-buttons.menu-open .nav-btn { text-align:left; 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 { @@ -757,18 +757,11 @@ header .nav-btn .ph { font-size:11px; vertical-align:-1px; margin-right:2px; } border-radius: var(--radius); } - /* Compose view: 2 columns. Left is the compose editor. Right is a - sidebar with inbox → shared → sent stacked vertically. */ + /* Compose view: single column, centered. Inbox/shared/sent are in + their own view now — no sidebar to fill the right-hand space. */ #view-compose:not(.hidden) { - display: grid !important; - grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr); - gap: 24px; - align-items: start; - } - #compose-sidebar { - display: flex; - flex-direction: column; - gap: 16px; + max-width: 720px; + margin: 0 auto; } /* 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; } - /* 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 */ #view-admin:not(.hidden) .section { diff --git a/website/favicon.svg b/website/favicon.svg new file mode 100644 index 0000000..cfa8719 --- /dev/null +++ b/website/favicon.svg @@ -0,0 +1,24 @@ + + + + diff --git a/website/index.html b/website/index.html index be54033..49b22fc 100644 --- a/website/index.html +++ b/website/index.html @@ -4,6 +4,7 @@ hyprcrd +