Polish resource library controls

This commit is contained in:
Franz Rolfsvaag 2026-07-20 23:23:39 +02:00
parent 7ec23e5709
commit a95e0789cc
16 changed files with 686 additions and 104 deletions

View File

@ -1,5 +1,13 @@
# Lumi changelog
## 0.2.24
- Fixed resource locking and deletion by sending the shared timed-confirmation token directly with AJAX actions, and made deletion reliable on Windows and network-backed storage.
- Added dedicated attachment downloads instead of relying on browser-player behavior.
- Reworked resource cards with contained filenames and actions, compact accurate empty/filter states, inline image previews, and fading play/stop timelines over audio and video previews.
- Moved rename and delete actions into a predictable split layout and only reveals the save-name button when the name has actually changed.
- Made saved storage and upload limits immediately visible and enforced in the current page without requiring a refresh or restart.
## 0.2.23
- Added an admin-only content library for verified image, audio, video, caption, document, data, and font uploads with previews, searching, storage quotas, disk reserves, and upload progress.

View File

@ -69,6 +69,12 @@ media seeking. Plugins use the shared `global.lumiFrameworks.resources` API.
OKF's generated core reference now discovers shared route modules so the AI can
also find and explain the Resources and overlay endpoints.
Follow-up completed on 2026-07-20: repaired shared timed confirmation for lock
and delete actions, added reliable attachment downloads and network-drive-safe
deletion, redesigned cards for responsive filenames/actions and embedded media
controls, corrected empty/filter messages, and made storage/upload limit changes
take effect and update the open page immediately.
## Current Local State / Source of Truth
### P0 OBS Overlay System

View File

@ -10,7 +10,7 @@ Files are stored under:
data/content-library/files/
```
Temporary uploads and deletion staging remain under the same data directory so normal Lumi backup and update boundaries preserve them.
Temporary uploads remain under the same data directory so normal Lumi backup and update boundaries preserve them. Deletion removes the stored file directly, which also works on Windows network-backed storage where directory renames may be restricted.
The Resources page displays:
@ -22,6 +22,8 @@ The Resources page displays:
A quota of `0` means that Lumi does not impose an additional library limit. Physical disk space and the configured reserve still apply.
Saved quotas, reserves, per-file limits, and per-upload file counts apply to the next upload immediately. The storage cards, settings fields, upload guidance, and client-side selection checks update without a page refresh.
## Access levels
### Locked
@ -86,6 +88,8 @@ Raw delivery includes:
- permissive CORS/Cross-Origin-Resource-Policy headers for exposed and signed media;
- read-only routes with no mutation capability.
Admin downloads use a dedicated attachment route so browsers download the original file instead of opening their built-in audio or video player.
## Verification
Run:

View File

@ -14,7 +14,7 @@ editable: false
Lumi is the core web UI and bot runtime.
## Runtime
Package: lumi-bot
Version: 0.2.23
Version: 0.2.24
## Routes
- POST /api/diagnostics/v1/run
- GET /api/events
@ -163,6 +163,7 @@ Version: 0.2.23
- GET /admin/resources
- GET /api/admin/resources
- GET /admin/resources/:id/raw/:filename
- GET /admin/resources/:id/download/:filename
- POST /admin/resources/upload
- POST /admin/resources/settings
- POST /admin/resources/:id/rename
@ -1535,6 +1536,15 @@ Version: 0.2.23
- Side effects: Usually read-only.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
### GET /admin/resources/:id/download/:filename
- Purpose: Handles admin resources id download filename.
- Inputs: path params: `filename`, `id`
- Response format: HTML or data response; exact format was not detected statically.
- Access: admin access expected
- Side effects: Usually read-only.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
### POST /admin/resources/upload
- Purpose: Processes the admin resources upload action and stores or applies submitted form data.

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "lumi-bot",
"version": "0.2.23",
"version": "0.2.24",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lumi-bot",
"version": "0.2.23",
"version": "0.2.24",
"dependencies": {
"adm-zip": "^0.5.12",
"better-sqlite3": "^11.5.0",

View File

@ -1,6 +1,6 @@
{
"name": "lumi-bot",
"version": "0.2.23",
"version": "0.2.24",
"private": true,
"type": "commonjs",
"scripts": {

View File

@ -2,6 +2,36 @@
"schema_version": 1,
"channel": "stable",
"releases": [
{
"version": "0.2.24",
"ref": "refs/tags/v0.2.24",
"released_at": "2026-07-20",
"installable": true,
"rollback_safe": true,
"replaces_versions": [
"1.2.0"
],
"data_policy": "preserve",
"dependency_policy": "sync_on_restart",
"migration_notes": "Repairs resource locking, deletion, attachment downloads, responsive cards, embedded media previews, empty states, and live storage-limit reflection. Existing content files, metadata, settings, and other local data are preserved.",
"plugins": {
"auto-vc": "0.1.6",
"birthday": "0.1.3",
"economy-framework": "0.2.10",
"economy-games": "0.1.7",
"expression-interaction": "0.2.1",
"lumi_ai": "0.8.5",
"moderation": "0.1.5",
"okf": "0.1.2",
"quotes": "0.1.2",
"sample-plugin": "0.1.0",
"throne_wishlist": "0.1.2",
"welcome_messages": "0.1.1"
},
"tools": {
"lumi_ai_web_search": "0.1.1"
}
},
{
"version": "0.2.23",
"ref": "refs/tags/v0.2.23",

View File

@ -59,6 +59,7 @@ let database = null;
assert.strictEqual(created.preview_kind, "image");
assert.strictEqual(library.publicUrl(created, "https://lumi.example"), null);
assert(fs.existsSync(library.resourceFilePath(created)));
assert.match(library.serializeResource(created, { base_url: "https://lumi.example" }).download_url, /\/admin\/resources\/[^/]+\/download\/Alert%20Graphic\.png$/);
const signed = new URL(library.createSignedUrl(created.id, {
base_url: "https://lumi.example",
@ -91,12 +92,25 @@ let database = null;
assert(stats.used_bytes > 0);
assert(stats.max_file_bytes > 0);
const configuredStorage = library.updateStorageSettings({
limit_gib: "2.5",
reserve_gib: "0.25",
max_file_mib: "32",
max_files: "7"
});
assert.strictEqual(configuredStorage.limit_bytes, Math.floor(2.5 * 1024 ** 3));
assert.strictEqual(configuredStorage.reserve_bytes, Math.floor(0.25 * 1024 ** 3));
assert.strictEqual(configuredStorage.max_file_bytes, 32 * 1024 ** 2);
assert.strictEqual(configuredStorage.max_files, 7);
assert.deepStrictEqual(library.uploadLimits(), { max_file_bytes: 32 * 1024 ** 2, max_files: 7 });
const lockedAgain = library.setResourceAccess(created.id, "locked");
assert.strictEqual(library.getResourceByExposedToken(publicToken), null);
assert.strictEqual(lockedAgain.public_token, "");
fs.rmSync(library.resourceFilePath(created), { force: true });
const storedPath = library.resourceFilePath(created);
library.deleteResource(created.id);
assert.strictEqual(fs.existsSync(storedPath), false);
assert.strictEqual(library.getResource(created.id), null);
assert.strictEqual(library.getStorageStats().file_count, 0);
@ -112,14 +126,22 @@ let database = null;
const viewSource = fs.readFileSync(path.join(root, "src", "web", "views", "admin-resources.ejs"), "utf8");
assert(viewSource.includes("data-resource-dropzone"));
assert(viewSource.includes("data-resource-preview-modal"));
assert(viewSource.includes("data-resource-media-toggle") && viewSource.includes("data-resource-media-seek"));
assert(viewSource.includes("data-resource-rename-submit") && viewSource.includes("resource-more-actions"));
assert(viewSource.includes("Available to Lumi"));
assert(viewSource.includes("data-resource-access-form") && viewSource.includes("data-confirm-mode=\"modal\""));
assert(viewSource.includes("data-resource-access-form") && viewSource.includes("data-confirm-mode=\"modal\"") && viewSource.includes("data-no-destructive-confirm"));
const contentRouteSource = fs.readFileSync(path.join(root, "src", "services", "content-routes.js"), "utf8");
assert(contentRouteSource.includes('/admin/resources/:id/revoke'));
assert(contentRouteSource.includes('/admin/resources/:id/download/:filename'));
const clientSource = fs.readFileSync(path.join(root, "src", "web", "public", "content-library.js"), "utf8");
assert(clientSource.includes("if (event.defaultPrevented) return;"), "AJAX actions must wait for shared timed confirmation");
assert(clientSource.includes('body.has("confirmation_token")') && clientSource.includes('[data-destructive-cancel]'), "confirmed AJAX actions must close the shared modal without losing the issued token");
assert(clientSource.includes("window.LumiConfirm?.destructiveToken"), "Resource actions must use the shared timed confirmation API");
assert(clientSource.includes('headers["X-Confirmation-Token"] = token'), "Resource actions must send the issued confirmation token directly");
assert(clientSource.includes("data-resource-upload-limit-copy") && clientSource.includes("selectedFilesError"), "Saved upload limits must be reflected and enforced in the UI");
const cssSource = fs.readFileSync(path.join(root, "src", "web", "public", "content-library.css"), "utf8");
assert(cssSource.includes(".resource-empty-state[hidden]") && cssSource.includes("display: none !important"));
assert(cssSource.includes(".resource-preview-tile:focus-within .resource-media-controls"));
const coreKnowledge = fs.readFileSync(path.join(root, "knowledge", "core", "lumi-core.md"), "utf8");
assert(coreKnowledge.includes("GET /admin/resources") && coreKnowledge.includes("GET /media/:token/:filename"));

View File

@ -4,8 +4,8 @@ const path = require("path");
const { findSafeTarget } = require("../src/services/versioning");
const root = path.join(__dirname, "..");
const releaseVersion = "0.2.23";
const previousCoreVersion = "0.2.22";
const releaseVersion = "0.2.24";
const previousCoreVersion = "0.2.23";
const earliestCompatibleCoreVersion = "0.1.9";
const changedPlugins = {
"auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" },
@ -87,4 +87,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0");
assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2");
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
console.log("Release metadata verification passed: core 0.2.23, Lumi AI 0.8.5, and synchronized package metadata.");
console.log("Release metadata verification passed: core 0.2.24, Lumi AI 0.8.5, and synchronized package metadata.");

View File

@ -16,7 +16,7 @@ function readJson(relativePath) {
const releaseIndex = readJson("release-index.json");
const releaseVersions = releaseIndex.releases.map((release) => release.version);
assert.deepEqual(releaseVersions, ["0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
assert.deepEqual(releaseVersions, ["0.2.24", "0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
for (const release of releaseIndex.releases) {
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
@ -37,6 +37,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
const baseTarget = {
current_version: "0.2.4",
available_versions: [
{ version: "0.2.24", ref: "refs/tags/v0.2.24", rollback_safe: true },
{ version: "0.2.23", ref: "refs/tags/v0.2.23", rollback_safe: true },
{ version: "0.2.22", ref: "refs/tags/v0.2.22", rollback_safe: true },
{ version: "0.2.21", ref: "refs/tags/v0.2.21", rollback_safe: true },
@ -83,7 +84,7 @@ const corrected = buildStatus({
channel: "stable"
});
assert.equal(corrected.version_correction, true);
assert.equal(corrected.safe_target_version, "0.2.23");
assert.equal(corrected.safe_target_version, "0.2.24");
assert.equal(corrected.update_available, true);
assert.equal(corrected.blocked, false);

View File

@ -417,25 +417,16 @@ function setResourceAccess(id, value) {
function deleteResource(id) {
const current = requireResource(id);
const source = safeStoragePath(current.stored_name);
const trashName = `${current.id}-${Date.now()}${current.extension}`;
const trashPath = path.join(TRASH_DIR, trashName);
let moved = false;
try {
if (fs.existsSync(source)) {
const stat = fs.lstatSync(source);
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("The resource storage entry is not a regular file.");
moveFile(source, trashPath);
moved = true;
// Deleting directly is more reliable than renaming to a temporary trash
// path on Windows network drives. If the database write then fails, the
// retained metadata can still be removed safely by retrying this action.
fs.rmSync(source, { force: true });
}
db.prepare("DELETE FROM content_resources WHERE id = ?").run(current.id);
if (moved) fs.rmSync(trashPath, { force: true });
return current;
} catch (error) {
if (moved && fs.existsSync(trashPath) && !fs.existsSync(source)) {
try { moveFile(trashPath, source); } catch {}
}
throw error;
}
}
function updateStorageSettings(values = {}) {
@ -488,6 +479,11 @@ function adminUrl(resourceOrId, baseUrl = "") {
return joinBaseUrl(baseUrl, `/admin/resources/${encodeURIComponent(resource.id)}/raw/${encodeURIComponent(resource.original_name)}`);
}
function downloadUrl(resourceOrId, baseUrl = "") {
const resource = typeof resourceOrId === "string" ? requireResource(resourceOrId) : resourceOrId;
return joinBaseUrl(baseUrl, `/admin/resources/${encodeURIComponent(resource.id)}/download/${encodeURIComponent(resource.original_name)}`);
}
function createSignedUrl(id, options = {}) {
const resource = requireResource(id);
const ttlSeconds = clampInteger(
@ -530,6 +526,7 @@ function serializeResource(resource, options = {}) {
created_at: resource.created_at,
updated_at: resource.updated_at,
admin_url: adminUrl(resource, baseUrl),
download_url: downloadUrl(resource, baseUrl),
public_url: publicUrl(resource, baseUrl)
};
}
@ -863,6 +860,7 @@ module.exports = {
adminUrl,
createSignedUrl,
deleteResource,
downloadUrl,
ensureContentLibrary,
formatBytes,
frameworkApi,

View File

@ -92,6 +92,16 @@ function registerContentAdminRoutes(app, options = {}) {
});
});
app.get("/admin/resources/:id/download/:filename", requireAdmin, (req, res) => {
const resource = getResource(req.params.id);
if (!resource) return res.status(404).send("Not found.");
return serveResource(req, res, resource, {
cache_control: "private, no-store",
cors: false,
disposition: "attachment"
});
});
app.post(
"/admin/resources/upload",
requireAdmin,

View File

@ -222,6 +222,10 @@
color: var(--lumi-success, var(--lumi-primary));
}
.resource-browser-status:empty {
display: none;
}
.resource-settings-panel > summary {
min-height: var(--lumi-control-height);
display: flex;
@ -282,6 +286,27 @@
gap: var(--lumi-space-4);
}
.resource-empty-state {
min-height: 0;
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--lumi-space-2);
padding: var(--lumi-space-3) var(--lumi-space-4);
border: 1px dashed var(--lumi-border);
border-radius: var(--lumi-radius-md);
background: var(--lumi-surface-subtle);
color: var(--lumi-text-muted);
}
.resource-empty-state strong {
color: var(--lumi-text);
}
.resource-empty-state[hidden] {
display: none !important;
}
.resource-card {
min-width: 0;
overflow: hidden;
@ -298,6 +323,7 @@
}
.resource-preview-tile {
position: relative;
width: 100%;
min-width: 0;
min-height: 0;
@ -314,7 +340,6 @@
radial-gradient(circle at 25% 15%, color-mix(in srgb, var(--lumi-primary) 16%, transparent), transparent 48%),
var(--lumi-surface-subtle);
color: var(--lumi-text-muted);
cursor: pointer;
}
.resource-preview-tile:hover {
@ -323,6 +348,28 @@
var(--lumi-surface-raised);
}
.resource-preview-open {
width: 100%;
height: 100%;
min-width: 0;
display: grid;
place-items: center;
align-content: center;
gap: var(--lumi-space-2);
padding: 0;
overflow: hidden;
border: 0;
border-radius: 0;
background: transparent;
color: inherit;
cursor: pointer;
}
.resource-preview-open:focus-visible {
outline: 3px solid var(--lumi-focus);
outline-offset: -3px;
}
.resource-preview-tile img {
width: 100%;
height: 100%;
@ -333,6 +380,103 @@
) 50% / 1rem 1rem;
}
.resource-preview-fallback-tile {
padding: var(--lumi-space-3);
}
.resource-media-preview {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
display: grid;
place-items: center;
overflow: hidden;
}
.resource-media-preview.is-video::after {
content: "";
position: absolute;
inset: 45% 0 0;
z-index: 1;
pointer-events: none;
background: linear-gradient(transparent, rgb(0 0 0 / 72%));
}
.resource-media-preview video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
background: #080a0c;
}
.resource-media-preview audio {
display: none;
}
.resource-media-controls {
position: absolute;
right: var(--lumi-space-3);
bottom: var(--lumi-space-3);
left: var(--lumi-space-3);
z-index: 2;
min-width: 0;
display: grid;
justify-items: start;
gap: var(--lumi-space-2);
padding: var(--lumi-space-2);
border-radius: var(--lumi-radius-sm);
background: color-mix(in srgb, #07090b 78%, transparent);
color: #fff;
opacity: 0;
pointer-events: none;
transform: translateY(0.35rem);
transition: opacity 160ms ease, transform 160ms ease;
}
.resource-media-preview.is-audio .resource-media-controls {
background: color-mix(in srgb, var(--lumi-surface-raised) 90%, transparent);
color: var(--lumi-text);
}
.resource-preview-tile:hover .resource-media-controls,
.resource-preview-tile:focus-within .resource-media-controls {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.resource-media-toggle {
min-height: 2rem;
padding-block: 0.3rem;
}
.resource-media-timeline {
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--lumi-space-2);
}
.resource-media-timeline input {
width: 100%;
min-width: 0;
min-height: 1.5rem;
margin: 0;
padding: 0;
}
.resource-media-timeline span {
color: inherit;
font-family: var(--lumi-font-mono);
font-size: 0.7rem;
white-space: nowrap;
}
.resource-type-mark {
min-width: 4.5rem;
min-height: 3.2rem;
@ -356,14 +500,16 @@
}
.resource-card-heading {
display: flex;
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
justify-content: space-between;
gap: var(--lumi-space-2);
}
.resource-card-heading > div {
min-width: 0;
overflow: hidden;
}
.resource-card-heading h3 {
@ -374,10 +520,19 @@
.resource-card-heading p {
margin: 0;
display: -webkit-box;
overflow: hidden;
overflow-wrap: anywhere;
color: var(--lumi-text-muted);
font-size: 0.82rem;
text-overflow: ellipsis;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.resource-card-heading .badge {
max-width: 100%;
flex: 0 0 auto;
white-space: nowrap;
}
@ -431,6 +586,11 @@
gap: var(--lumi-space-2);
}
.resource-url-row .button {
min-width: 0;
max-width: 100%;
}
.resource-url-row input {
min-width: 0;
font-family: var(--lumi-font-mono);
@ -438,6 +598,7 @@
}
.resource-card-actions {
min-width: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
@ -448,6 +609,11 @@
display: contents;
}
.resource-card-actions .button {
min-width: 0;
max-width: 100%;
}
.resource-edit-details {
padding-top: var(--lumi-space-2);
border-top: 1px solid var(--lumi-border);
@ -466,23 +632,44 @@
margin-bottom: var(--lumi-space-3);
}
.resource-edit-details form + form {
margin-top: var(--lumi-space-3);
}
.resource-rename-form {
.resource-more-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: var(--lumi-space-3);
}
.resource-rename-form {
min-width: 0;
display: grid;
justify-items: start;
gap: var(--lumi-space-2);
}
.resource-rename-form label {
width: 100%;
min-width: 0;
display: grid;
gap: var(--lumi-space-1);
}
.resource-rename-submit {
opacity: 0;
visibility: hidden;
transform: translateY(-0.25rem);
transition: opacity 140ms ease, transform 140ms ease, visibility 140ms;
}
.resource-rename-submit.is-dirty {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.resource-delete-form {
justify-self: end;
}
.resource-preview-modal {
z-index: 90;
}
@ -602,15 +789,29 @@
grid-template-columns: minmax(0, 1fr);
}
.resource-more-actions {
grid-template-columns: minmax(0, 1fr) auto;
}
.resource-preview-dialog {
width: 100%;
max-height: 96vh;
}
}
@media (hover: none) {
.resource-media-controls {
opacity: 1;
pointer-events: auto;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.resource-dropzone,
.resource-meter > span {
.resource-meter > span,
.resource-media-controls,
.resource-rename-submit {
transition: none;
}
}

View File

@ -6,6 +6,7 @@
const initializedLibraries = new WeakSet();
const initializedUploads = new WeakSet();
const initializedMedia = new WeakSet();
let previewReturnFocus = null;
function init(root = document) {
@ -20,6 +21,8 @@
library.querySelector("[data-resource-search]")?.addEventListener("input", update);
library.querySelector("[data-resource-category-filter]")?.addEventListener("change", update);
library.querySelector("[data-resource-access-filter]")?.addEventListener("change", update);
library.querySelectorAll("[data-resource-media]").forEach(wireCardMedia);
library.querySelectorAll("[data-resource-rename-input]").forEach(updateRenameState);
applyFilters(library);
}
@ -82,6 +85,24 @@
row.append(name, size);
list.append(row);
});
validateSelectedFiles(form, files);
}
function validateSelectedFiles(form, files) {
const input = form.querySelector("[data-resource-file-input]");
const error = selectedFilesError(input, files);
if (error) setStatus(form, error, "error");
else if (files.length) setStatus(form, `${files.length} file${files.length === 1 ? "" : "s"} ready to upload.`, "");
else setStatus(form, "", "");
}
function selectedFilesError(input, files) {
const maxFiles = Math.max(1, Number(input?.dataset.maxFiles) || Number.MAX_SAFE_INTEGER);
const maxFileBytes = Math.max(1, Number(input?.dataset.maxFileBytes) || Number.MAX_SAFE_INTEGER);
if (files.length > maxFiles) return `Choose no more than ${maxFiles} files at once.`;
const oversized = files.find((file) => file.size > maxFileBytes);
if (oversized) return `${oversized.name} exceeds the ${formatBytes(maxFileBytes)} per-file limit.`;
return "";
}
function uploadResources(form) {
@ -91,6 +112,11 @@
setStatus(form, "Choose at least one file to upload.", "error");
return;
}
const validationError = selectedFilesError(input, files);
if (validationError) {
setStatus(form, validationError, "error");
return;
}
const button = form.querySelector("[data-resource-upload-submit]");
const progress = form.querySelector("[data-resource-upload-progress]");
const bar = form.querySelector("[data-resource-upload-progress-bar]");
@ -159,20 +185,27 @@
document.addEventListener("submit", async (event) => {
const form = event.target.closest?.("[data-resource-action-form]");
if (!form) return;
if (event.defaultPrevented) return;
event.preventDefault();
const submitter = event.submitter;
const button = submitter || form.querySelector("button[type='submit'], input[type='submit']");
const body = new FormData(form);
if (body.has("confirmation_token")) {
document.querySelector("[data-destructive-cancel]")?.click();
const action = effectiveAction(form, submitter);
const headers = { Accept: "application/json", "X-Requested-With": "XMLHttpRequest" };
let releasedCard = null;
if (isTimedResourceAction(action)) {
const token = await requestResourceConfirmation(form, action);
if (!token) return;
headers["X-Confirmation-Token"] = token;
if (/\/delete(?:\/|$)/i.test(new URL(action, window.location.origin).pathname)) {
releasedCard = form.closest("[data-resource-card]");
releaseCardMedia(releasedCard);
}
}
if (button) button.disabled = true;
try {
const response = await fetch(effectiveAction(form, submitter), {
const response = await fetch(action, {
method: effectiveMethod(form, submitter),
body,
headers: { Accept: "application/json", "X-Requested-With": "XMLHttpRequest" },
body: new FormData(form),
headers,
credentials: "same-origin"
});
const payload = await response.json().catch(() => ({}));
@ -182,12 +215,13 @@
if (payload.deleted_id) removeResource(library, payload.deleted_id);
if (payload.storage) updateStorage(library, payload.storage);
if (payload.settings) updateSettingsForm(library, payload.settings);
setStatus(library, payload.message || "Saved.", "success");
setActionStatus(form, library, payload.message || "Saved.", "success");
notify(payload.message || "Saved.", "info");
applyFilters(library);
} catch (error) {
restoreCardMedia(releasedCard);
const library = form.closest("[data-content-library]") || document.querySelector("[data-content-library]");
setStatus(library, error.message || "The resource update failed.", "error");
setActionStatus(form, library, error.message || "The resource update failed.", "error");
notify(error.message || "The resource update failed.", "danger");
} finally {
if (button && document.contains(button)) button.disabled = false;
@ -195,6 +229,12 @@
});
document.addEventListener("click", (event) => {
const mediaToggle = event.target.closest?.("[data-resource-media-toggle]");
if (mediaToggle) {
event.preventDefault();
toggleCardMedia(mediaToggle);
return;
}
const previewButton = event.target.closest?.("[data-resource-preview]");
if (previewButton) {
event.preventDefault();
@ -218,6 +258,13 @@
if (modal && event.target === modal) closePreview();
});
document.addEventListener("input", (event) => {
const renameInput = event.target.closest?.("[data-resource-rename-input]");
if (renameInput) updateRenameState(renameInput);
const seek = event.target.closest?.("[data-resource-media-seek]");
if (seek) seekCardMedia(seek);
});
document.addEventListener("keydown", (event) => {
const modal = document.querySelector("[data-resource-preview-modal].is-open");
if (!modal) return;
@ -265,7 +312,11 @@
card.dataset.resourceAccess = resource.access_level;
card.querySelector("[data-resource-title]").textContent = resource.display_name;
const rename = card.querySelector('input[name="display_name"]');
if (rename) rename.value = resource.display_name;
if (rename) {
rename.value = resource.display_name;
rename.dataset.originalValue = resource.display_name;
updateRenameState(rename);
}
const badge = card.querySelector("[data-resource-access-badge]");
if (badge) {
badge.textContent = resource.access_level === "exposed" ? "Exposed" : "Locked";
@ -292,11 +343,26 @@
}
}
if (accessButton) accessButton.textContent = resource.access_level === "exposed" ? "Lock" : "Expose";
card.querySelectorAll("[data-resource-preview]").forEach((button) => applyPreviewData(button, resource));
const download = card.querySelector("[data-resource-download]");
if (download) download.href = resource.download_url;
const deleteForm = card.querySelector(".resource-delete-form");
if (deleteForm) deleteForm.dataset.confirmText = `Permanently delete '${resource.display_name}' from Lumi? Any overlay or feature using it will stop working.`;
card.querySelectorAll("[data-resource-preview-data], [data-resource-preview]").forEach((target) => applyPreviewData(target, resource));
}
function updateRenameState(input) {
const form = input?.closest(".resource-rename-form");
const submit = form?.querySelector("[data-resource-rename-submit]");
if (!submit) return;
const dirty = input.value.trim() !== String(input.dataset.originalValue || "").trim() && input.value.trim().length > 0;
submit.disabled = !dirty;
submit.classList.toggle("is-dirty", dirty);
}
function removeResource(library, id) {
library?.querySelector(`[data-resource-card][data-resource-id="${cssEscape(id)}"]`)?.remove();
const card = library?.querySelector(`[data-resource-card][data-resource-id="${cssEscape(id)}"]`);
releaseCardMedia(card);
card?.remove();
}
function buildResourceCard(resource) {
@ -307,21 +373,31 @@
card.dataset.resourceCategory = resource.category;
card.dataset.resourceAccess = resource.access_level;
const tile = element("button", "resource-preview-tile");
tile.type = "button";
tile.dataset.resourcePreview = "";
tile.setAttribute("aria-label", `Preview ${resource.display_name}`);
const tile = element("div", "resource-preview-tile");
tile.dataset.resourcePreviewData = "";
applyPreviewData(tile, resource);
if (resource.preview_kind === "image") {
const open = button("", "resource-preview-open");
open.dataset.resourcePreview = "";
open.setAttribute("aria-label", `Open a larger preview of ${resource.display_name}`);
applyPreviewData(open, resource);
const image = document.createElement("img");
image.src = resource.admin_url;
image.alt = "";
image.alt = resource.display_name;
image.loading = "lazy";
tile.append(image);
open.append(image);
tile.append(open);
} else if (["video", "audio"].includes(resource.preview_kind)) {
tile.append(buildMediaPreview(resource));
} else {
const open = button("", "resource-preview-open resource-preview-fallback-tile");
open.dataset.resourcePreview = "";
open.setAttribute("aria-label", `Preview ${resource.display_name}`);
applyPreviewData(open, resource);
const mark = element("span", "resource-type-mark", resource.extension.replace(".", "").toUpperCase());
mark.setAttribute("aria-hidden", "true");
tile.append(mark, element("span", "", "Preview"));
open.append(mark, element("span", "", "Open preview"));
tile.append(open);
}
const body = element("div", "resource-card-body");
@ -359,8 +435,8 @@
preview.dataset.resourcePreview = "";
applyPreviewData(preview, resource);
const download = element("a", "button subtle", "Download");
download.href = `${resource.admin_url}?download=1`;
download.download = "";
download.href = resource.download_url;
download.dataset.resourceDownload = "";
const accessForm = actionForm(`/admin/resources/${encodeURIComponent(resource.id)}/${resource.access_level === "exposed" ? "revoke" : "access"}`);
accessForm.dataset.resourceAccessForm = "";
if (resource.access_level === "exposed") {
@ -376,6 +452,7 @@
const details = element("details", "resource-edit-details");
details.append(element("summary", "", "More actions"));
const moreActions = element("div", "resource-more-actions");
const renameForm = actionForm(`/admin/resources/${encodeURIComponent(resource.id)}/rename`, "resource-rename-form");
const renameLabel = document.createElement("label");
renameLabel.append(element("span", "", "Display name"));
@ -384,15 +461,21 @@
renameInput.maxLength = 160;
renameInput.required = true;
renameInput.value = resource.display_name;
renameInput.dataset.resourceRenameInput = "";
renameInput.dataset.originalValue = resource.display_name;
renameLabel.append(renameInput);
renameForm.append(renameLabel, button("Rename", "button subtle", "submit"));
const deleteForm = actionForm(`/admin/resources/${encodeURIComponent(resource.id)}/delete`);
const renameButton = button("Save name", "button subtle resource-rename-submit", "submit");
renameButton.dataset.resourceRenameSubmit = "";
renameButton.disabled = true;
renameForm.append(renameLabel, renameButton);
const deleteForm = actionForm(`/admin/resources/${encodeURIComponent(resource.id)}/delete`, "resource-delete-form");
deleteForm.dataset.confirmMode = "modal";
deleteForm.dataset.confirmTitle = "Delete resource";
deleteForm.dataset.confirmText = `Permanently delete '${resource.display_name}' from Lumi? Any overlay or feature using it will stop working.`;
deleteForm.dataset.confirmLabel = "Delete resource";
deleteForm.append(button("Delete resource", "button danger", "submit"));
details.append(renameForm, deleteForm);
moreActions.append(renameForm, deleteForm);
details.append(moreActions);
body.append(heading, metadata, publicBlock, actions, details);
card.append(tile, body);
@ -404,6 +487,7 @@
form.method = "post";
form.action = action;
form.dataset.resourceActionForm = "";
form.dataset.noDestructiveConfirm = "";
return form;
}
@ -431,11 +515,134 @@
button.dataset.resourceDisplayName = resource.display_name;
button.dataset.resourceOriginalName = resource.original_name;
button.dataset.resourceUrl = resource.admin_url;
button.dataset.resourceDownloadUrl = resource.download_url;
button.dataset.resourcePreviewKind = resource.preview_kind;
button.dataset.resourceMime = resource.mime;
button.dataset.resourceSize = resource.size_display;
}
function buildMediaPreview(resource) {
const wrapper = element("div", `resource-media-preview is-${resource.preview_kind}`);
wrapper.dataset.resourceMediaPreview = "";
if (resource.preview_kind === "audio") {
const mark = element("span", "resource-type-mark", resource.extension.replace(".", "").toUpperCase());
mark.setAttribute("aria-hidden", "true");
wrapper.append(mark);
}
const media = document.createElement(resource.preview_kind === "video" ? "video" : "audio");
media.src = resource.admin_url;
media.preload = "metadata";
media.dataset.resourceMedia = "";
if (media instanceof HTMLVideoElement) media.playsInline = true;
const controls = element("div", "resource-media-controls");
const toggle = button("Play", "button subtle resource-media-toggle");
toggle.dataset.resourceMediaToggle = "";
toggle.setAttribute("aria-label", `Play ${resource.display_name}`);
const timeline = element("div", "resource-media-timeline");
const seek = document.createElement("input");
seek.type = "range";
seek.min = "0";
seek.max = "1000";
seek.step = "1";
seek.value = "0";
seek.dataset.resourceMediaSeek = "";
seek.setAttribute("aria-label", `Playback position for ${resource.display_name}`);
const time = element("span", "", "0:00 / --:--");
time.dataset.resourceMediaTime = "";
timeline.append(seek, time);
controls.append(toggle, timeline);
wrapper.append(media, controls);
wireCardMedia(media);
return wrapper;
}
function wireCardMedia(media) {
if (!media || initializedMedia.has(media)) return;
initializedMedia.add(media);
const update = () => updateMediaControls(media);
["loadedmetadata", "durationchange", "timeupdate", "play", "pause", "ended", "emptied"].forEach((type) => {
media.addEventListener(type, update);
});
update();
}
async function toggleCardMedia(buttonTarget) {
const preview = buttonTarget.closest("[data-resource-media-preview]");
const media = preview?.querySelector("[data-resource-media]");
if (!media) return;
wireCardMedia(media);
if (!media.paused && !media.ended) {
media.pause();
try { media.currentTime = 0; } catch {}
updateMediaControls(media);
return;
}
document.querySelectorAll("[data-resource-media]").forEach((other) => {
if (other === media) return;
other.pause();
try { other.currentTime = 0; } catch {}
});
try {
await media.play();
} catch {
notify("This browser could not play the media preview.", "danger");
}
updateMediaControls(media);
}
function seekCardMedia(input) {
const media = input.closest("[data-resource-media-preview]")?.querySelector("[data-resource-media]");
if (!media || !Number.isFinite(media.duration) || media.duration <= 0) return;
media.currentTime = (Number(input.value) / 1000) * media.duration;
updateMediaControls(media);
}
function updateMediaControls(media) {
const preview = media.closest("[data-resource-media-preview]");
const toggle = preview?.querySelector("[data-resource-media-toggle]");
const seek = preview?.querySelector("[data-resource-media-seek]");
const time = preview?.querySelector("[data-resource-media-time]");
const duration = Number.isFinite(media.duration) && media.duration > 0 ? media.duration : 0;
const current = Number.isFinite(media.currentTime) ? media.currentTime : 0;
const playing = !media.paused && !media.ended;
if (toggle) {
toggle.textContent = playing ? "Stop" : "Play";
const name = media.closest("[data-resource-card]")?.querySelector("[data-resource-title]")?.textContent || "media";
toggle.setAttribute("aria-label", `${playing ? "Stop" : "Play"} ${name}`);
}
if (seek) seek.value = duration ? String(Math.round((current / duration) * 1000)) : "0";
if (time) time.textContent = `${formatDuration(current)} / ${duration ? formatDuration(duration) : "--:--"}`;
}
function releaseCardMedia(card) {
card?.querySelectorAll?.("[data-resource-media]").forEach((media) => {
media.pause();
media.removeAttribute("src");
media.load();
});
}
function restoreCardMedia(card) {
if (!card || !document.contains(card)) return;
const url = card.querySelector("[data-resource-preview-data]")?.dataset.resourceUrl;
if (!url) return;
card.querySelectorAll("[data-resource-media]").forEach((media) => {
if (media.getAttribute("src")) return;
media.src = url;
media.load();
wireCardMedia(media);
});
}
function formatDuration(value) {
const seconds = Math.max(0, Math.floor(Number(value) || 0));
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainder = String(seconds % 60).padStart(2, "0");
return hours ? `${hours}:${String(minutes).padStart(2, "0")}:${remainder}` : `${minutes}:${remainder}`;
}
function applyFilters(library) {
if (!library) return;
const search = (library.querySelector("[data-resource-search]")?.value || "").trim().toLowerCase();
@ -458,7 +665,7 @@
async function openPreview(source) {
const card = source.closest("[data-resource-card]");
const dataSource = source.dataset.resourceUrl ? source : card?.querySelector(".resource-preview-tile[data-resource-preview]");
const dataSource = source.dataset.resourceUrl ? source : card?.querySelector("[data-resource-preview-data]");
if (!dataSource) return;
const modal = document.querySelector("[data-resource-preview-modal]");
const stage = modal?.querySelector("[data-resource-preview-stage]");
@ -470,7 +677,7 @@
modal.querySelector("[data-resource-preview-title]").textContent = dataSource.dataset.resourceDisplayName || "Resource preview";
modal.querySelector("[data-resource-preview-meta]").textContent = `${dataSource.dataset.resourceOriginalName || ""} · ${dataSource.dataset.resourceSize || ""}`;
const download = modal.querySelector("[data-resource-preview-download]");
download.href = `${dataSource.dataset.resourceUrl}?download=1`;
download.href = dataSource.dataset.resourceDownloadUrl || dataSource.dataset.resourceUrl;
previewReturnFocus = source instanceof HTMLElement ? source : document.activeElement;
modal.classList.add("is-open");
modal.setAttribute("aria-hidden", "false");
@ -569,6 +776,14 @@
if (meter) meter.style.width = `${Math.max(0, Math.min(100, Number(storage.usage_percent) || 0))}%`;
const progress = meter?.closest("[role='progressbar']");
progress?.setAttribute("aria-valuenow", String(storage.usage_percent || 0));
const fileInput = library.querySelector("[data-resource-file-input]");
if (fileInput) {
fileInput.dataset.maxFileBytes = String(storage.max_file_bytes);
fileInput.dataset.maxFiles = String(storage.max_files);
}
setText(library, "[data-resource-upload-limit-copy]", `Up to ${storage.max_files} files per upload · ${formatBytes(storage.max_file_bytes)} per file`);
const uploadForm = library.querySelector("[data-resource-upload-form]");
if (uploadForm) renderSelectedFiles(uploadForm);
}
function updateSettingsForm(library, settings) {
@ -596,6 +811,41 @@
target.classList.toggle("is-success", tone === "success");
}
function setActionStatus(form, library, message, tone) {
const settingsStatus = form?.matches?.("[data-resource-settings-form]")
? form.querySelector("[data-resource-settings-status]")
: null;
setStatus(settingsStatus || library?.querySelector("[data-resource-browser-status]") || library, message, tone);
}
function isTimedResourceAction(action) {
try {
return /\/(?:delete|revoke)(?:\/|$)/i.test(new URL(action, window.location.origin).pathname);
} catch {
return false;
}
}
async function requestResourceConfirmation(form, action) {
const confirmApi = window.LumiConfirm?.destructiveToken;
if (typeof confirmApi !== "function") {
notify("Timed confirmation is unavailable. Reload the page and try again.", "danger");
return null;
}
try {
return await confirmApi({
action,
title: form.dataset.confirmTitle || "Confirm action",
text: form.dataset.confirmText || "This action cannot be undone.",
label: form.dataset.confirmLabel || "Confirm",
danger: true
});
} catch (error) {
notify(error.message || "Timed confirmation is unavailable.", "danger");
return null;
}
}
function notify(message, tone) {
window.LumiInteractions?.showEventNotice?.({ message }, tone);
}

View File

@ -54,12 +54,14 @@
name="resources"
multiple
accept="<%= formatGroups.flatMap((group) => group.extensions).join(',') %>"
data-max-file-bytes="<%= storage.max_file_bytes %>"
data-max-files="<%= storage.max_files %>"
data-resource-file-input
/>
<span class="resource-dropzone-icon" aria-hidden="true"></span>
<strong>Drop files here</strong>
<span>or select files from this device</span>
<small>Up to <%= settings.max_files %> files per upload · <%= formatBytes(storage.max_file_bytes) %> per file</small>
<small data-resource-upload-limit-copy>Up to <%= settings.max_files %> files per upload · <%= formatBytes(storage.max_file_bytes) %> per file</small>
</label>
<div class="resource-selected-files" data-resource-selected-files hidden></div>
<div class="resource-upload-options">
@ -105,6 +107,7 @@
</div>
<div class="field full">
<button type="submit" class="button subtle">Save storage settings</button>
<p class="resource-status" data-resource-status data-resource-settings-status aria-live="polite"></p>
</div>
</form>
<div class="resource-format-groups">
@ -148,12 +151,15 @@
</label>
</div>
</div>
<p class="resource-status resource-browser-status" data-resource-status data-resource-browser-status aria-live="polite"></p>
<div class="resource-empty-state empty-state" data-resource-empty <%= resources.length ? "hidden" : "" %>>
No resources yet. Drop media above to make it available to Lumi.
<div class="resource-empty-state" data-resource-empty <%= resources.length ? "hidden" : "" %>>
<strong>No files uploaded yet.</strong>
<span>Drop media in the upload area above to make it available to Lumi.</span>
</div>
<div class="resource-empty-state empty-state" data-resource-filter-empty hidden>
No resources match the current filters.
<div class="resource-empty-state" data-resource-filter-empty hidden>
<strong>No matching files.</strong>
<span>Try clearing the search or changing the type and access filters.</span>
</div>
<div class="resource-grid" data-resource-grid>
@ -166,26 +172,45 @@
data-resource-category="<%= resource.category %>"
data-resource-access="<%= resource.access_level %>"
>
<button
type="button"
<div
class="resource-preview-tile"
data-resource-preview
data-resource-preview-data
data-resource-id="<%= resource.id %>"
data-resource-display-name="<%= resource.display_name %>"
data-resource-original-name="<%= resource.original_name %>"
data-resource-url="<%= resource.admin_url %>"
data-resource-download-url="<%= resource.download_url %>"
data-resource-preview-kind="<%= resource.preview_kind %>"
data-resource-mime="<%= resource.mime %>"
data-resource-size="<%= resource.size_display %>"
aria-label="Preview <%= resource.display_name %>"
>
<% if (resource.preview_kind === "image") { %>
<img src="<%= resource.admin_url %>" alt="" loading="lazy" />
<button type="button" class="resource-preview-open" data-resource-preview aria-label="Open a larger preview of <%= resource.display_name %>">
<img src="<%= resource.admin_url %>" alt="<%= resource.display_name %>" loading="lazy" />
</button>
<% } else if (resource.preview_kind === "video" || resource.preview_kind === "audio") { %>
<div class="resource-media-preview <%= resource.preview_kind === 'audio' ? 'is-audio' : 'is-video' %>" data-resource-media-preview>
<% if (resource.preview_kind === "video") { %>
<video src="<%= resource.admin_url %>" preload="metadata" playsinline data-resource-media></video>
<% } else { %>
<span class="resource-type-mark" aria-hidden="true"><%= resource.extension.replace('.', '').toUpperCase() %></span>
<span>Preview</span>
<audio src="<%= resource.admin_url %>" preload="metadata" data-resource-media></audio>
<% } %>
<div class="resource-media-controls">
<button type="button" class="button subtle resource-media-toggle" data-resource-media-toggle aria-label="Play <%= resource.display_name %>">Play</button>
<div class="resource-media-timeline">
<input type="range" min="0" max="1000" value="0" step="1" data-resource-media-seek aria-label="Playback position for <%= resource.display_name %>" />
<span data-resource-media-time>0:00 / --:--</span>
</div>
</div>
</div>
<% } else { %>
<button type="button" class="resource-preview-open resource-preview-fallback-tile" data-resource-preview aria-label="Preview <%= resource.display_name %>">
<span class="resource-type-mark" aria-hidden="true"><%= resource.extension.replace('.', '').toUpperCase() %></span>
<span>Open preview</span>
</button>
<% } %>
</div>
<div class="resource-card-body">
<div class="resource-card-heading">
<div>
@ -207,12 +232,13 @@
</div>
<div class="resource-card-actions">
<button type="button" class="button subtle" data-resource-preview>Preview</button>
<a class="button subtle" href="<%= resource.admin_url %>?download=1" download>Download</a>
<a class="button subtle" href="<%= resource.download_url %>" data-resource-download>Download</a>
<form
method="post"
action="/admin/resources/<%= resource.id %>/<%= resource.access_level === 'exposed' ? 'revoke' : 'access' %>"
data-resource-action-form
data-resource-access-form
data-no-destructive-confirm
<% if (resource.access_level === "exposed") { %>
data-confirm-mode="modal"
data-confirm-title="Lock resource"
@ -225,17 +251,20 @@
</div>
<details class="resource-edit-details">
<summary>More actions</summary>
<form method="post" action="/admin/resources/<%= resource.id %>/rename" class="resource-rename-form" data-resource-action-form>
<div class="resource-more-actions">
<form method="post" action="/admin/resources/<%= resource.id %>/rename" class="resource-rename-form" data-resource-action-form data-no-destructive-confirm>
<label>
<span>Display name</span>
<input name="display_name" maxlength="160" value="<%= resource.display_name %>" required />
<input name="display_name" maxlength="160" value="<%= resource.display_name %>" data-resource-rename-input data-original-value="<%= resource.display_name %>" required />
</label>
<button type="submit" class="button subtle">Rename</button>
<button type="submit" class="button subtle resource-rename-submit" data-resource-rename-submit disabled>Save name</button>
</form>
<form
method="post"
action="/admin/resources/<%= resource.id %>/delete"
class="resource-delete-form"
data-resource-action-form
data-no-destructive-confirm
data-confirm-mode="modal"
data-confirm-title="Delete resource"
data-confirm-text="Permanently delete '<%= resource.display_name %>' from Lumi? Any overlay or feature using it will stop working."
@ -243,6 +272,7 @@
>
<button type="submit" class="button danger">Delete resource</button>
</form>
</div>
</details>
</div>
</article>
@ -262,7 +292,7 @@
<div class="resource-preview-stage" data-resource-preview-stage></div>
<p class="resource-preview-error" data-resource-preview-error role="status" hidden></p>
<div class="modal-actions">
<a class="button subtle" href="#" data-resource-preview-download download>Download</a>
<a class="button subtle" href="#" data-resource-preview-download>Download</a>
<button type="button" class="button" data-resource-preview-close>Close</button>
</div>
</div>

View File

@ -1,14 +1,14 @@
{
"name": "Lumi Core",
"version": "0.2.23",
"version": "0.2.24",
"channel": "stable",
"released_at": "2026-07-19",
"released_at": "2026-07-20",
"compatible_from": "0.1.9",
"migration_kind": "patch",
"replaces_versions": [
"1.2.0"
],
"migration_notes": "Adds an admin content-delivery library with verified uploads, locked or revocably exposed resources, streaming byte ranges, storage limits, and a shared plugin API. Also allows configured remote and blob audio/video in clean OBS Browser Sources. Existing commands, policies, groups, overlays, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets are preserved; content metadata and files are additive under the existing preserved data directory.",
"migration_notes": "Repairs resource locking, deletion, downloads, responsive cards, embedded media previews, empty/filter states, and live storage-limit reflection. Existing content files, metadata, settings, tokens, overlays, plugins, databases, uploads, models, feedback, and secrets are preserved; no data migration is required.",
"rollback_safe": true,
"requirements": [
"Node.js 18 or newer"
@ -301,6 +301,18 @@
],
"rollback_safe": true,
"migration_notes": "Adds an admin content-delivery library with verified uploads, revocable read-only delivery, storage controls, and a shared plugin API, plus the OBS media CSP fix; existing data remains preserved and content files are additive under data/content-library."
},
{
"version": "0.2.24",
"channel": "stable",
"released_at": "2026-07-20",
"compatible_from": "0.1.9",
"migration_kind": "patch",
"replaces_versions": [
"1.2.0"
],
"rollback_safe": true,
"migration_notes": "Repairs resource locking, deletion, downloads, responsive cards, embedded media previews, empty/filter states, and live storage-limit reflection; existing content files, metadata, settings, and all other preserved data remain unchanged."
}
]
}