From e251eb831a71393827f1f704efab437ffbbac627 Mon Sep 17 00:00:00 2001 From: Robert Syrnicki Date: Wed, 7 Oct 2026 15:13:39 +0200 Subject: [PATCH] feat: group installed-app actions into an overflow menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the internet toggle, a published app showed up to seven buttons and squeezed its description. Only Open stays visible; Settings, the expose/stop-exposing toggle, Update, Reinstall and Remove move into a "⋯" menu, and the public URL shows as a link under the description. Co-Authored-By: Claude Opus 5.5 --- assets/www/style.css | 53 ++++++++++++++++++++++++++++++ furtka/api.py | 78 ++++++++++++++++++++++++++++++-------------- 2 files changed, 106 insertions(+), 25 deletions(-) diff --git a/assets/www/style.css b/assets/www/style.css index 85fa9d2..99d01f5 100644 --- a/assets/www/style.css +++ b/assets/www/style.css @@ -222,6 +222,59 @@ button.secondary, .btn.secondary { border: 1px solid var(--border); } button.danger { background: var(--danger); color: #fff; } + +/* Public-URL line under an app's description, shown while the app is + exposed through furtka-gateway. */ +.public-link { + display: inline-flex; + align-items: center; + gap: 0.4rem; + margin-top: 0.25rem; + font-size: 0.85rem; + color: var(--accent); + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.public-link:hover { text-decoration: underline; } +.public-link .dot { + width: 0.5rem; + height: 0.5rem; + border-radius: var(--r-pill); + background: var(--accent); + flex-shrink: 0; +} + +/* "⋯" overflow menu on an installed app row. */ +details.menu { position: relative; } +details.menu > summary { list-style: none; padding: 0.5rem 0.75rem; } +details.menu > summary::-webkit-details-marker { display: none; } +.menu-list { + position: absolute; + right: 0; + top: calc(100% + 0.25rem); + z-index: 10; + min-width: 11rem; + padding: 0.25rem; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--r-md); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; +} +.menu-list button { + background: none; + color: var(--fg); + font-weight: 500; + width: 100%; + justify-content: flex-start; +} +.menu-list button:hover { background: var(--card-hover); } +.menu-list button.danger { background: none; color: var(--danger); } +.menu-list button.danger:hover { background: var(--card-hover); } +.menu-list hr { border: none; border-top: 1px solid var(--border); margin: 0.25rem 0; } button:disabled { opacity: 0.5; cursor: wait; } button:focus-visible, .btn:focus-visible { outline: none; box-shadow: var(--ring); } .empty { color: var(--muted); font-style: italic; padding: 0.5rem 0; } diff --git a/furtka/api.py b/furtka/api.py index f913574..a6eed87 100644 --- a/furtka/api.py +++ b/furtka/api.py @@ -358,12 +358,20 @@ async function refresh() { const openBtn = openHref ? `Open` : ''; - // Filled in async by loadInternetSlots() after this markup lands — - // the toggle's current state needs its own fetch per app - // (/api/apps//internet), so it can't be known at row-build time. - const internetSlot = (a.internet && a.internet.viable) - ? `` + // Both filled in async by loadInternetSlots() after this markup + // lands — the current publish state needs its own fetch per app + // (/api/apps//internet), so it can't be known at row-build + // time. The status line sits under the description (public URL, + // when exposed); the toggle lives in the "⋯" menu. + const viable = a.internet && a.internet.viable; + const internetStatus = viable + ? `` : ''; + const internetItem = viable + ? `` + : ''; + // Only Open stays visible; everything else goes in a
+ // menu so the description isn't squeezed by a row of buttons. return `
@@ -371,15 +379,22 @@ async function refresh() {
${esc(a.display_name || a.name)} ${esc(a.version || '')} ${esc(a.description || a.error || '')} + ${internetStatus}
${openBtn} - ${internetSlot} - ${hasSettings ? `` : ''} - - - +
`; }).join('') @@ -403,35 +418,48 @@ async function refresh() { loadInternetSlots(); } -// Populates each installed internet-viable app's "Expose to Internet" / -// "Public …" control. Separate from the main refresh() render because the -// current publish state needs its own per-app fetch — the /api/apps list -// only carries whether an app is *capable* of being exposed, not whether -// it currently is. +// Populates each installed internet-viable app's public-URL status line +// and its "Expose to Internet" / "Stop exposing" menu item. Separate from +// the main refresh() render because the current publish state needs its +// own per-app fetch — the /api/apps list only carries whether an app is +// *capable* of being exposed, not whether it currently is. async function loadInternetSlots() { - for (const slot of document.querySelectorAll('.internet-slot')) { - const name = slot.dataset.name; + for (const item of document.querySelectorAll('.internet-item')) { + const name = item.dataset.name; + const status = document.querySelector(`.internet-status[data-name="${CSS.escape(name)}"]`); try { const r = await fetch(`/api/apps/${encodeURIComponent(name)}/internet`); if (!r.ok) continue; - renderInternetSlot(slot, name, await r.json()); + renderInternetSlot(item, status, name, await r.json()); } catch (e) { - // Leave the slot empty — next refresh() retries. + // Leave the slots empty — next refresh() retries. } } } -function renderInternetSlot(slot, name, data) { +function renderInternetSlot(item, status, name, data) { if (data.enabled) { - slot.innerHTML = - `Public ↗` + - ``; + const host = data.public_url.replace(/^https?:\\/\\//, '').replace(/\\/$/, ''); + if (status) { + status.innerHTML = + `` + + `${esc(host)} ↗`; + } + item.innerHTML = ``; } else { - slot.innerHTML = ``; + if (status) status.innerHTML = ''; + item.innerHTML = ``; } - slot.querySelector('[data-internet]').addEventListener('click', () => handleInternetToggle(name, !!data.enabled)); + item.querySelector('[data-internet]').addEventListener('click', () => handleInternetToggle(name, !!data.enabled)); } +//
menus don't close on an outside click by themselves. +document.addEventListener('click', (e) => { + for (const menu of document.querySelectorAll('details.menu[open]')) { + if (!menu.contains(e.target)) menu.removeAttribute('open'); + } +}); + async function handleInternetToggle(name, currentlyEnabled) { const logEl = document.getElementById('log'); if (currentlyEnabled) {