From 04fd241679db68c3d71ffdb8ec08e342af23a737 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Wed, 13 May 2026 20:07:36 +0000 Subject: [PATCH] terminal: inline session switching via sidebar + iframe Replace full-page navigation with a two-pane lobby. Sidebar holds the session list as clickable cards; an iframe in the content pane swaps its src on click so switching sessions takes one click instead of two navigations. - #lobby-shell grid (260px sidebar + iframe pane) - Cards become role=button, kill button stops propagation - activateSession/deactivateSession with hash routing (location.hash <-> active session, replaceState so back stack stays clean) - Killed active session deactivates the iframe before re-render - 5s session poll preserves currentActive; deactivates if gone - Mobile media query collapses to one column CSP frame-ancestors already permits same-origin embedding (*.viktorbarzin.me), no infra changes needed. Direct-link ?arg= path is unchanged. --- stacks/terminal/files/index.html | 211 +++++++++++++++++++++++++------ 1 file changed, 169 insertions(+), 42 deletions(-) diff --git a/stacks/terminal/files/index.html b/stacks/terminal/files/index.html index 47084572..388ce373 100644 --- a/stacks/terminal/files/index.html +++ b/stacks/terminal/files/index.html @@ -47,57 +47,120 @@ /* Lobby */ #lobby { - display: none; padding: 32px; height: 100vh; box-sizing: border-box; + display: none; height: 100vh; box-sizing: border-box; font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace; - color: #eee; background: #1a1a2e; overflow-y: auto; + color: #eee; background: #1a1a2e; } #lobby.visible { display: block; } - .lobby-header { font-size: 22px; color: #a29bfe; margin: 0 0 4px 0; } - .lobby-sub { color: #888; font-size: 13px; margin: 0 0 24px 0; } - .new-row { display: flex; gap: 8px; margin-bottom: 24px; max-width: 640px; } + #lobby.access-denied { padding: 32px; overflow-y: auto; } + + #lobby-shell { + display: grid; grid-template-columns: 260px 1fr; height: 100vh; + } + #lobby-sidebar { + padding: 20px 14px; overflow-y: auto; border-right: 1px solid #2a2a3f; + background: #1a1a2e; min-width: 0; + } + #lobby-content { + position: relative; background: #000; min-width: 0; + height: 100vh; overflow: hidden; + } + #session-frame { width: 100%; height: 100%; border: 0; display: block; } + #session-frame.hidden { display: none; } + #lobby-empty { + color: #888; display: flex; align-items: center; justify-content: center; + height: 100%; font-style: italic; padding: 24px; text-align: center; + } + #lobby-empty.hidden { display: none; } + + .lobby-header { font-size: 18px; color: #a29bfe; margin: 0 0 4px 0; } + .lobby-sub { color: #888; font-size: 12px; margin: 0 0 18px 0; line-height: 1.5; } + .new-row { display: flex; flex-direction: column; gap: 6px; margin-bottom: 18px; } .new-row input { - flex: 1; padding: 10px 14px; border-radius: 8px; border: 1px solid #333; - background: #0f0f1f; color: #eee; font-family: inherit; font-size: 14px; + box-sizing: border-box; padding: 8px 12px; border-radius: 8px; + border: 1px solid #333; background: #0f0f1f; color: #eee; + font-family: inherit; font-size: 13px; } .new-row input:focus { outline: none; border-color: #a29bfe; } .new-row button { - padding: 10px 18px; border-radius: 8px; border: 1px solid rgba(162, 155, 254, 0.4); - background: rgba(108, 92, 231, 0.6); color: #eee; font-family: inherit; - font-size: 14px; cursor: pointer; + padding: 8px 12px; border-radius: 8px; + border: 1px solid rgba(162, 155, 254, 0.4); + background: rgba(108, 92, 231, 0.6); color: #eee; + font-family: inherit; font-size: 13px; cursor: pointer; } .new-row button:hover { background: rgba(108, 92, 231, 0.85); } - .session-list { display: grid; gap: 12px; max-width: 640px; } + + .session-list { display: flex; flex-direction: column; gap: 6px; } .session-card { display: flex; align-items: center; justify-content: space-between; - background: #0f0f1f; border: 1px solid #2a2a3f; border-radius: 10px; - padding: 14px 18px; + background: #0f0f1f; border: 1px solid #2a2a3f; + border-left: 3px solid transparent; border-radius: 8px; + padding: 10px 12px; cursor: pointer; + transition: background 0.15s, border-color 0.15s; + font-family: inherit; color: inherit; text-align: left; } - .session-meta { display: flex; flex-direction: column; gap: 4px; min-width: 0; } - .session-name { font-size: 16px; font-weight: 600; color: #eee; } - .session-detail { font-size: 12px; color: #888; } + .session-card:hover { background: #161629; } + .session-card:focus-visible { + outline: 2px solid rgba(162, 155, 254, 0.6); outline-offset: -2px; + } + .session-card.active { + background: #161629; + border-color: rgba(162, 155, 254, 0.5); + border-left-color: rgba(162, 155, 254, 0.95); + } + .session-meta { + display: flex; flex-direction: column; gap: 2px; + min-width: 0; flex: 1; + } + .session-name { + font-size: 14px; font-weight: 600; color: #eee; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .session-detail { font-size: 11px; color: #888; } .session-detail.attached { color: #2ecc71; } - .session-actions { display: flex; gap: 8px; } + .session-actions { display: flex; gap: 6px; flex-shrink: 0; } .session-actions button { - padding: 8px 14px; border-radius: 6px; border: 1px solid #333; + padding: 4px 8px; border-radius: 6px; border: 1px solid #333; background: #1f1f33; color: #eee; font-family: inherit; - font-size: 13px; cursor: pointer; + font-size: 11px; cursor: pointer; + } + .session-actions button.kill { + color: #e74c3c; border-color: rgba(231, 76, 60, 0.4); } - .session-actions button.open { color: #a29bfe; border-color: rgba(162, 155, 254, 0.4); } - .session-actions button.kill { color: #e74c3c; border-color: rgba(231, 76, 60, 0.4); } .session-actions button:hover { filter: brightness(1.3); } - .empty { color: #888; font-style: italic; padding: 16px 0; } + .empty { color: #888; font-style: italic; padding: 12px 0; font-size: 12px; } + + @media (max-width: 720px) { + #lobby-shell { + grid-template-columns: 1fr; + grid-template-rows: auto 60vh; + } + #lobby-sidebar { + border-right: 0; border-bottom: 1px solid #2a2a3f; + max-height: 40vh; + } + #lobby-content { height: 60vh; } + }
-

tmux sessions

-

Pick an existing session or create a new one. Sessions persist after you close the tab.

-
- - +
+ +
+
Pick a session, or create one above.
+ +
-
@@ -154,7 +217,7 @@ p2.className = 'lobby-sub'; p2.textContent = 'Authentik logs you in; access here requires an OS-user mapping in /etc/ttyd-user-map. Ask Viktor to add one.'; lobby.appendChild(p2); - lobby.classList.add('visible'); + lobby.classList.add('visible', 'access-denied'); document.title = 'access denied'; } @@ -178,19 +241,24 @@ if (!validArg) { // ============================================================ - // LOBBY MODE — no valid ?arg=, show session picker + // LOBBY MODE — no valid ?arg=, render sidebar + iframe shell // ============================================================ document.getElementById('terminal').classList.add('hidden'); document.getElementById('paste-btn').classList.add('hidden'); document.getElementById('img-btn').classList.add('hidden'); document.getElementById('lobby').classList.add('visible'); - document.title = 'tmux sessions (' + whoami.osUser + ')'; + const baseTitle = 'tmux sessions (' + whoami.osUser + ')'; + document.title = baseTitle; document.querySelector('#lobby .lobby-sub').textContent = 'Logged in as ' + whoami.osUser + ' (' + whoami.authentik + '). Sessions are kernel-isolated per Unix user; you only see your own.'; const listEl = document.getElementById('session-list'); const newNameEl = document.getElementById('new-name'); const newBtnEl = document.getElementById('new-btn'); + const frameEl = document.getElementById('session-frame'); + const emptyEl = document.getElementById('lobby-empty'); + + let currentActive = null; function relativeTime(epochSec) { if (!epochSec) return ''; @@ -201,9 +269,38 @@ return Math.floor(diff / 86400) + 'd ago'; } - function openSession(name) { + function updateActiveCard() { + listEl.querySelectorAll('.session-card').forEach(c => { + if (c.dataset.name === currentActive) c.classList.add('active'); + else c.classList.remove('active'); + }); + } + + function activateSession(name) { if (!NAME_RE.test(name)) { showToast('Invalid name', 'error'); return; } - location.search = '?arg=' + encodeURIComponent(name); + if (currentActive === name) { updateActiveCard(); return; } + currentActive = name; + emptyEl.classList.add('hidden'); + frameEl.classList.remove('hidden'); + frameEl.src = '/?arg=' + encodeURIComponent(name); + if (location.hash.slice(1) !== name) { + history.replaceState(null, '', '#' + name); + } + document.title = 'tmux: ' + whoami.osUser + '/' + name; + updateActiveCard(); + } + + function deactivateSession() { + if (!currentActive && frameEl.classList.contains('hidden')) return; + currentActive = null; + frameEl.classList.add('hidden'); + emptyEl.classList.remove('hidden'); + frameEl.src = 'about:blank'; + if (location.hash) { + history.replaceState(null, '', location.pathname + location.search); + } + document.title = baseTitle; + updateActiveCard(); } async function killSession(name) { @@ -220,6 +317,7 @@ } else { showToast('Kill failed: HTTP ' + resp.status, 'error'); } + if (currentActive === name) deactivateSession(); renderLobby(); } catch (err) { showToast('Kill error: ' + err.message, 'error'); @@ -229,6 +327,11 @@ function renderCard(s) { const card = document.createElement('div'); card.className = 'session-card'; + card.dataset.name = s.name; + card.setAttribute('role', 'button'); + card.setAttribute('tabindex', '0'); + card.setAttribute('aria-label', 'Activate session ' + s.name); + if (s.name === currentActive) card.classList.add('active'); const meta = document.createElement('div'); meta.className = 'session-meta'; @@ -239,24 +342,30 @@ const detail = document.createElement('div'); detail.className = 'session-detail' + (s.attached > 0 ? ' attached' : ''); const attachedText = s.attached > 0 ? (s.attached + ' attached') : 'idle'; - detail.textContent = attachedText + ' · last activity ' + relativeTime(s.lastActivity); + detail.textContent = attachedText + ' · ' + relativeTime(s.lastActivity); meta.appendChild(detail); card.appendChild(meta); const actions = document.createElement('div'); actions.className = 'session-actions'; - const openBtn = document.createElement('button'); - openBtn.className = 'open'; - openBtn.textContent = 'Open'; - openBtn.onclick = () => openSession(s.name); - actions.appendChild(openBtn); const killBtn = document.createElement('button'); killBtn.className = 'kill'; killBtn.textContent = 'Kill'; - killBtn.onclick = () => killSession(s.name); + killBtn.addEventListener('click', (e) => { + e.stopPropagation(); + killSession(s.name); + }); actions.appendChild(killBtn); card.appendChild(actions); + card.addEventListener('click', () => activateSession(s.name)); + card.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + activateSession(s.name); + } + }); + return card; } @@ -269,9 +378,12 @@ return; } const sessions = await resp.json(); + if (currentActive && !sessions.some(s => s.name === currentActive)) { + deactivateSession(); + } clearChildren(listEl); if (sessions.length === 0) { - listEl.appendChild(emptyState('No tmux sessions. Create one above.')); + listEl.appendChild(emptyState('No sessions yet.')); return; } sessions.sort((a, b) => b.lastActivity - a.lastActivity); @@ -288,14 +400,29 @@ showToast('Name must match ^[a-zA-Z0-9_-]{1,32}$', 'error', 4000); return; } - openSession(name); + newNameEl.value = ''; + activateSession(name); }); newNameEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') newBtnEl.click(); }); + window.addEventListener('hashchange', () => { + const hash = location.hash.slice(1); + if (hash && NAME_RE.test(hash)) { + if (hash !== currentActive) activateSession(hash); + } else if (currentActive) { + deactivateSession(); + } + }); + renderLobby(); setInterval(renderLobby, 5000); + + const initialHash = location.hash.slice(1); + if (initialHash && NAME_RE.test(initialHash)) { + activateSession(initialHash); + } return; }