dev_gateway #22

Merged
robert merged 14 commits from dev_gateway into main 2026-10-07 16:34:47 +02:00
2 changed files with 106 additions and 25 deletions
Showing only changes of commit e251eb831a - Show all commits

View file

@ -222,6 +222,59 @@ button.secondary, .btn.secondary {
border: 1px solid var(--border); border: 1px solid var(--border);
} }
button.danger { background: var(--danger); color: #fff; } 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:disabled { opacity: 0.5; cursor: wait; }
button:focus-visible, .btn:focus-visible { outline: none; box-shadow: var(--ring); } button:focus-visible, .btn:focus-visible { outline: none; box-shadow: var(--ring); }
.empty { color: var(--muted); font-style: italic; padding: 0.5rem 0; } .empty { color: var(--muted); font-style: italic; padding: 0.5rem 0; }

View file

@ -358,12 +358,20 @@ async function refresh() {
const openBtn = openHref const openBtn = openHref
? `<a class="btn" href="${esc(openHref)}" target="_blank" rel="noopener">Open</a>` ? `<a class="btn" href="${esc(openHref)}" target="_blank" rel="noopener">Open</a>`
: ''; : '';
// Filled in async by loadInternetSlots() after this markup lands — // Both filled in async by loadInternetSlots() after this markup
// the toggle's current state needs its own fetch per app // lands — the current publish state needs its own fetch per app
// (/api/apps/<name>/internet), so it can't be known at row-build time. // (/api/apps/<name>/internet), so it can't be known at row-build
const internetSlot = (a.internet && a.internet.viable) // time. The status line sits under the description (public URL,
? `<span class="internet-slot" data-name="${esc(a.name)}"></span>` // when exposed); the toggle lives in the "⋯" menu.
const viable = a.internet && a.internet.viable;
const internetStatus = viable
? `<span class="internet-status" data-name="${esc(a.name)}"></span>`
: ''; : '';
const internetItem = viable
? `<span class="internet-item" data-name="${esc(a.name)}"></span>`
: '';
// Only Open stays visible; everything else goes in a <details>
// menu so the description isn't squeezed by a row of buttons.
return ` return `
<div class="app"> <div class="app">
<div class="left"> <div class="left">
@ -371,16 +379,23 @@ async function refresh() {
<div class="meta"> <div class="meta">
<span class="name">${esc(a.display_name || a.name)} <small>${esc(a.version || '')}</small></span> <span class="name">${esc(a.display_name || a.name)} <small>${esc(a.version || '')}</small></span>
<span class="desc">${esc(a.description || a.error || '')}</span> <span class="desc">${esc(a.description || a.error || '')}</span>
${internetStatus}
</div> </div>
</div> </div>
<div class="buttons"> <div class="buttons">
${openBtn} ${openBtn}
${internetSlot} <details class="menu">
<summary class="btn secondary" aria-label="More actions for ${esc(a.display_name || a.name)}">⋯</summary>
<div class="menu-list">
${hasSettings ? `<button data-op="edit" data-name="${esc(a.name)}">Settings</button>` : ''} ${hasSettings ? `<button data-op="edit" data-name="${esc(a.name)}">Settings</button>` : ''}
<button class="secondary" data-op="update" data-name="${esc(a.name)}">Update</button> ${internetItem}
<button class="secondary" data-op="reinstall" data-name="${esc(a.name)}">Reinstall</button> <button data-op="update" data-name="${esc(a.name)}">Update</button>
<button data-op="reinstall" data-name="${esc(a.name)}">Reinstall</button>
<hr>
<button class="danger" data-op="remove" data-name="${esc(a.name)}">Remove</button> <button class="danger" data-op="remove" data-name="${esc(a.name)}">Remove</button>
</div> </div>
</details>
</div>
</div>`; </div>`;
}).join('') }).join('')
: '<div class="empty">No apps installed yet.</div>'; : '<div class="empty">No apps installed yet.</div>';
@ -403,35 +418,48 @@ async function refresh() {
loadInternetSlots(); loadInternetSlots();
} }
// Populates each installed internet-viable app's "Expose to Internet" / // Populates each installed internet-viable app's public-URL status line
// "Public …" control. Separate from the main refresh() render because the // and its "Expose to Internet" / "Stop exposing" menu item. Separate from
// current publish state needs its own per-app fetch — the /api/apps list // the main refresh() render because the current publish state needs its
// only carries whether an app is *capable* of being exposed, not whether // own per-app fetch — the /api/apps list only carries whether an app is
// it currently is. // *capable* of being exposed, not whether it currently is.
async function loadInternetSlots() { async function loadInternetSlots() {
for (const slot of document.querySelectorAll('.internet-slot')) { for (const item of document.querySelectorAll('.internet-item')) {
const name = slot.dataset.name; const name = item.dataset.name;
const status = document.querySelector(`.internet-status[data-name="${CSS.escape(name)}"]`);
try { try {
const r = await fetch(`/api/apps/${encodeURIComponent(name)}/internet`); const r = await fetch(`/api/apps/${encodeURIComponent(name)}/internet`);
if (!r.ok) continue; if (!r.ok) continue;
renderInternetSlot(slot, name, await r.json()); renderInternetSlot(item, status, name, await r.json());
} catch (e) { } 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) { if (data.enabled) {
slot.innerHTML = const host = data.public_url.replace(/^https?:\\/\\//, '').replace(/\\/$/, '');
`<a class="btn" href="${esc(data.public_url)}" target="_blank" rel="noopener">Public ↗</a>` + if (status) {
`<button class="secondary" data-internet="${esc(name)}">Stop exposing</button>`; status.innerHTML =
`<a class="public-link" href="${esc(data.public_url)}" target="_blank" rel="noopener">` +
`<span class="dot"></span>${esc(host)} ↗</a>`;
}
item.innerHTML = `<button data-internet="${esc(name)}">Stop exposing</button>`;
} else { } else {
slot.innerHTML = `<button class="secondary" data-internet="${esc(name)}">Expose to Internet</button>`; if (status) status.innerHTML = '';
item.innerHTML = `<button data-internet="${esc(name)}">Expose to Internet</button>`;
} }
slot.querySelector('[data-internet]').addEventListener('click', () => handleInternetToggle(name, !!data.enabled)); item.querySelector('[data-internet]').addEventListener('click', () => handleInternetToggle(name, !!data.enabled));
} }
// <details> 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) { async function handleInternetToggle(name, currentlyEnabled) {
const logEl = document.getElementById('log'); const logEl = document.getElementById('log');
if (currentlyEnabled) { if (currentlyEnabled) {