feat: group installed-app actions into an overflow menu
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 <noreply@anthropic.com>
This commit is contained in:
parent
0490f49f85
commit
e251eb831a
2 changed files with 106 additions and 25 deletions
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
} else {
|
`<a class="public-link" href="${esc(data.public_url)}" target="_blank" rel="noopener">` +
|
||||||
slot.innerHTML = `<button class="secondary" data-internet="${esc(name)}">Expose to Internet</button>`;
|
`<span class="dot"></span>${esc(host)} ↗</a>`;
|
||||||
}
|
}
|
||||||
slot.querySelector('[data-internet]').addEventListener('click', () => handleInternetToggle(name, !!data.enabled));
|
item.innerHTML = `<button data-internet="${esc(name)}">Stop exposing</button>`;
|
||||||
|
} else {
|
||||||
|
if (status) status.innerHTML = '';
|
||||||
|
item.innerHTML = `<button data-internet="${esc(name)}">Expose to Internet</button>`;
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue