Add the Bot settings group with limit, usage bar, and clean modal
This commit is contained in:
+1
-1
@@ -31,7 +31,7 @@ mobile draft at 390px.
|
||||
- [x] C5 daemon wiring: `scrape media` command, MediaService, `/api/media/*`
|
||||
- [x] C6 Media and Bot tabs in the Web UI
|
||||
- [x] C7a storage limits, retention, warning/confirm flow, storage and clean API
|
||||
- [ ] C7b Settings group: limit field with info hover, usage bar, clean modal
|
||||
- [x] C7b Settings group: limit field with info hover, usage bar, clean modal
|
||||
- [ ] C8 MCP service hooks
|
||||
- [ ] C9 live verification, tuning, docs
|
||||
- Flextalk voice profile, end to end. The profile window is a movable panel that recenters on each
|
||||
|
||||
+4
-3
@@ -1,8 +1,8 @@
|
||||
# Media archive
|
||||
|
||||
Status: design approved 2026-09-16. The engine, the slopbot backend, the `scrape media` command,
|
||||
the `/api/media/*` routes, and storage limits with retention are implemented (C3-C7a). The Media
|
||||
and Bot tabs are C6; the Settings group is C7b. Checkpoints live in [CURRENT.md](../CURRENT.md).
|
||||
the `/api/media/*` routes, storage limits with retention, and the Settings group are implemented
|
||||
(C3-C7b). The Media and Bot tabs are C6. Checkpoints live in [CURRENT.md](../CURRENT.md).
|
||||
|
||||
The media archive saves the media history of a channel. A trusted user runs `scrape media` in the
|
||||
channel. The daemon walks the history through a scrape backend and indexes every image, GIF,
|
||||
@@ -125,7 +125,8 @@ that deletion is irreversible. The delete button is disabled for a 5 second coun
|
||||
database overhead.
|
||||
- Settings gains one collapsible group. Its name is **Bot** while the bot path is active, and
|
||||
**Media** if the bot path is removed. It holds the limit field with the info tooltip, the usage
|
||||
bar, and the clean button.
|
||||
bar (segmented by image, GIF, video, audio, and other), and the clean button with its 5 second
|
||||
countdown.
|
||||
- Mobile is out of scope for now.
|
||||
|
||||
## MCP
|
||||
|
||||
@@ -171,6 +171,9 @@ The Settings button opens a slide-over:
|
||||
the bot's devices. Clicking **Client audio** in the header does the same: it asks for the
|
||||
permission, unlocks the audio context in the same click, and then starts listening. A second
|
||||
click stops it.
|
||||
- **Bot** — slopbot connection state, the media storage limit in GiB with an info tooltip (5% overrun
|
||||
and the warning rule), the usage bar segmented by image, GIF, video, audio, and other, and
|
||||
**Clean media…** with a 5 second confirmation countdown. See [media.md](media.md).
|
||||
- **Server** — a collapsible group with:
|
||||
- **Core process** — state, last exit, output, Start / Stop / Restart.
|
||||
- **Account login** — token file state, QR login, captcha.
|
||||
|
||||
@@ -294,6 +294,49 @@
|
||||
<div id="guild-visibility" class="vk-settings-card"></div>
|
||||
</section>
|
||||
|
||||
<details class="vk-settings-group" open>
|
||||
<summary class="vk-settings-summary">
|
||||
<span class="vk-icon vk-caret" data-icon="caretDown"></span>
|
||||
<span>Bot</span>
|
||||
</summary>
|
||||
|
||||
<div class="vk-settings-group-body">
|
||||
<section class="vk-settings-section">
|
||||
<div class="vk-section-head">
|
||||
<span class="vk-rail-label vk-rail-label-flat">Media storage</span>
|
||||
<span class="vk-settings-actions">
|
||||
<button id="media-save" class="btn btn-primary vk-small-btn" type="button" disabled>Save</button>
|
||||
<button id="media-clean" class="btn btn-secondary vk-small-btn" type="button">Clean media…</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="vk-settings-card">
|
||||
<div class="vk-status-row">
|
||||
<span class="vk-status-label">Slopbot</span>
|
||||
<span class="vk-status-value" id="media-slopbot">checking…</span>
|
||||
</div>
|
||||
<div class="vk-status-row">
|
||||
<span class="vk-status-label">
|
||||
Limit (GiB)
|
||||
<span
|
||||
class="vk-info"
|
||||
title="Combined size of the scrape databases, downloads, and previews. Below the warn threshold scrapes run freely. At the threshold a new scrape asks for a re-run. Up to 5% over the limit nothing is deleted; past that the oldest marked scrapes are removed."
|
||||
>i</span
|
||||
>
|
||||
</span>
|
||||
<span class="vk-status-value">
|
||||
<input id="media-limit" class="vk-media-input" type="number" min="0.25" step="0.25" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="vk-status-row">
|
||||
<span class="vk-status-label">Used</span>
|
||||
<span class="vk-status-value" id="media-used">—</span>
|
||||
</div>
|
||||
<div id="media-usage-bar" class="vk-media-usage"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="vk-settings-group" open>
|
||||
<summary class="vk-settings-summary">
|
||||
<span class="vk-icon vk-caret" data-icon="caretDown"></span>
|
||||
|
||||
@@ -113,3 +113,94 @@
|
||||
.vk-media-scrape button {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.vk-info {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
margin-left: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-neutral-800);
|
||||
color: var(--color-neutral-300);
|
||||
font: 600 9px/1 ui-monospace, Menlo, monospace;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.vk-media-input {
|
||||
width: 90px;
|
||||
padding: 3px 6px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
background: var(--color-neutral-900);
|
||||
color: var(--color-neutral-200);
|
||||
box-shadow: inset 0 0 0 1px var(--color-neutral-800);
|
||||
font: 400 11px/1 ui-monospace, Menlo, monospace;
|
||||
}
|
||||
|
||||
.vk-media-usage {
|
||||
display: flex;
|
||||
height: 10px;
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
border-radius: 5px;
|
||||
background: var(--color-neutral-900);
|
||||
box-shadow: inset 0 0 0 1px var(--color-neutral-800);
|
||||
}
|
||||
|
||||
.vk-media-usage-seg {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.vk-seg-image {
|
||||
background: #4f8cff;
|
||||
}
|
||||
|
||||
.vk-seg-gif {
|
||||
background: #8a6cff;
|
||||
}
|
||||
|
||||
.vk-seg-video {
|
||||
background: #ff7a45;
|
||||
}
|
||||
|
||||
.vk-seg-audio {
|
||||
background: #2fbf71;
|
||||
}
|
||||
|
||||
.vk-seg-other {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
.vk-media-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 95;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--color-neutral-900) 70%, transparent);
|
||||
}
|
||||
|
||||
.vk-media-modal[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vk-media-modal-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: min(520px, 92vw);
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
background: var(--color-bg);
|
||||
box-shadow: inset 0 0 0 1px var(--color-neutral-800);
|
||||
}
|
||||
|
||||
.vk-media-modal-list {
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
font: 400 11px/1.5 ui-monospace, Menlo, monospace;
|
||||
color: var(--color-neutral-400);
|
||||
}
|
||||
|
||||
+172
-1
@@ -252,5 +252,176 @@
|
||||
document.querySelector("#scope-guild")?.addEventListener("click", hide);
|
||||
document.querySelector("#scope-dm")?.addEventListener("click", hide);
|
||||
|
||||
window.VK.media = { show, hide };
|
||||
// ---- Settings: media storage ----
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GiB`;
|
||||
if (bytes >= 1024 ** 2) return `${Math.round(bytes / 1024 ** 2)} MiB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
function renderUsageBar(status) {
|
||||
const bar = document.querySelector("#media-usage-bar");
|
||||
if (!bar || !status) return;
|
||||
|
||||
bar.replaceChildren();
|
||||
const limit = Math.max(status.limitBytes, 1);
|
||||
const measured =
|
||||
(status.byKind?.image ?? 0) +
|
||||
(status.byKind?.gif ?? 0) +
|
||||
(status.byKind?.video ?? 0) +
|
||||
(status.byKind?.audio ?? 0);
|
||||
const other = Math.max(0, status.usedBytes - measured);
|
||||
|
||||
for (const [kind, bytes] of [
|
||||
["image", status.byKind?.image ?? 0],
|
||||
["gif", status.byKind?.gif ?? 0],
|
||||
["video", status.byKind?.video ?? 0],
|
||||
["audio", status.byKind?.audio ?? 0],
|
||||
["other", other]
|
||||
]) {
|
||||
if (bytes <= 0) continue;
|
||||
const segment = el("div", `vk-media-usage-seg vk-seg-${kind}`);
|
||||
segment.style.width = `${Math.min(100, (bytes / limit) * 100)}%`;
|
||||
segment.title = `${kind}: ${formatBytes(bytes)}`;
|
||||
bar.append(segment);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMediaSettings() {
|
||||
try {
|
||||
const [storage, status] = await Promise.all([
|
||||
window.VK.api.get("/api/media/storage"),
|
||||
window.VK.api.get("/api/media/status")
|
||||
]);
|
||||
state.storage = storage;
|
||||
|
||||
const bot = status?.slopbot;
|
||||
const slopbotRow = document.querySelector("#media-slopbot");
|
||||
if (slopbotRow) slopbotRow.textContent = bot?.connected ? `connected as ${bot.user}` : "disabled";
|
||||
|
||||
const limit = document.querySelector("#media-limit");
|
||||
if (limit && document.activeElement !== limit) {
|
||||
limit.value = (storage.limitBytes / 1024 ** 3).toFixed(2);
|
||||
}
|
||||
|
||||
const used = document.querySelector("#media-used");
|
||||
if (used) {
|
||||
used.textContent = `${formatBytes(storage.usedBytes)} of ${formatBytes(storage.limitBytes)}${
|
||||
storage.nearLimit ? " (near limit)" : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
renderUsageBar(storage);
|
||||
} catch (error) {
|
||||
const used = document.querySelector("#media-used");
|
||||
if (used) used.textContent = `unavailable: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelector("#media-save")?.addEventListener("click", async () => {
|
||||
const input = document.querySelector("#media-limit");
|
||||
const gib = Number(input?.value);
|
||||
if (!Number.isFinite(gib) || gib <= 0) return;
|
||||
|
||||
try {
|
||||
await window.VK.api.post("/api/preferences", { media: { limitBytes: Math.round(gib * 1024 ** 3) } });
|
||||
setStatus("media limit saved");
|
||||
const save = document.querySelector("#media-save");
|
||||
if (save) save.disabled = true;
|
||||
} catch (error) {
|
||||
setStatus(`save failed: ${error.message}`);
|
||||
}
|
||||
void loadMediaSettings();
|
||||
});
|
||||
|
||||
document.querySelector("#media-limit")?.addEventListener("input", () => {
|
||||
const save = document.querySelector("#media-save");
|
||||
if (save) save.disabled = false;
|
||||
});
|
||||
|
||||
const cleanModal = document.createElement("div");
|
||||
cleanModal.className = "vk-media-modal";
|
||||
cleanModal.hidden = true;
|
||||
cleanModal.innerHTML = `
|
||||
<div class="vk-media-modal-box">
|
||||
<strong>Delete media scrapes?</strong>
|
||||
<div id="clean-note" class="vk-media-status"></div>
|
||||
<div id="clean-list" class="vk-media-modal-list"></div>
|
||||
<div class="vk-media-toolbar">
|
||||
<button id="clean-cancel" class="btn btn-secondary vk-small-btn" type="button">Cancel</button>
|
||||
<button id="clean-confirm" class="btn btn-primary vk-small-btn" type="button" disabled>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.append(cleanModal);
|
||||
|
||||
let cleanTimer = null;
|
||||
|
||||
function openCleanModal() {
|
||||
const scrapes = state.storage?.scrapes ?? [];
|
||||
const list = cleanModal.querySelector("#clean-list");
|
||||
const note = cleanModal.querySelector("#clean-note");
|
||||
const confirm = cleanModal.querySelector("#clean-confirm");
|
||||
|
||||
list.replaceChildren();
|
||||
for (const scrape of scrapes) {
|
||||
list.append(el("div", null, `${scrape.id} · ${scrape.state} · ${formatBytes(scrape.bytes)}`));
|
||||
}
|
||||
|
||||
const bytes = scrapes.reduce((sum, scrape) => sum + scrape.bytes, 0);
|
||||
note.textContent = scrapes.length
|
||||
? `Everything above will be deleted permanently, including files and databases (${formatBytes(bytes)}). This cannot be undone.`
|
||||
: "No scrapes to delete.";
|
||||
|
||||
confirm.disabled = true;
|
||||
let left = 5;
|
||||
confirm.textContent = `Delete (${left})`;
|
||||
cleanModal.hidden = false;
|
||||
|
||||
clearInterval(cleanTimer);
|
||||
if (!scrapes.length) {
|
||||
confirm.textContent = "Delete";
|
||||
return;
|
||||
}
|
||||
|
||||
cleanTimer = setInterval(() => {
|
||||
left--;
|
||||
if (left <= 0) {
|
||||
clearInterval(cleanTimer);
|
||||
confirm.disabled = false;
|
||||
confirm.textContent = "Delete";
|
||||
return;
|
||||
}
|
||||
confirm.textContent = `Delete (${left})`;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
cleanModal.querySelector("#clean-cancel").addEventListener("click", () => {
|
||||
clearInterval(cleanTimer);
|
||||
cleanModal.hidden = true;
|
||||
});
|
||||
|
||||
cleanModal.querySelector("#clean-confirm").addEventListener("click", async () => {
|
||||
const ids = (state.storage?.scrapes ?? []).map(scrape => scrape.id);
|
||||
try {
|
||||
await window.VK.api.post("/api/media/clean", { ids });
|
||||
} catch (error) {
|
||||
setStatus(`clean failed: ${error.message}`);
|
||||
}
|
||||
cleanModal.hidden = true;
|
||||
void loadMediaSettings();
|
||||
void loadScrapes();
|
||||
});
|
||||
|
||||
document.querySelector("#media-clean")?.addEventListener("click", openCleanModal);
|
||||
|
||||
const settingsOverlay = document.querySelector("#settings-overlay");
|
||||
if (settingsOverlay) {
|
||||
new MutationObserver(() => {
|
||||
if (!settingsOverlay.hidden) void loadMediaSettings();
|
||||
}).observe(settingsOverlay, { attributes: true, attributeFilter: ["hidden"] });
|
||||
}
|
||||
|
||||
window.VK.media = { show, hide, loadMediaSettings };
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user