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 # 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 ## 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. - 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 OKF's generated core reference now discovers shared route modules so the AI can
also find and explain the Resources and overlay endpoints. 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 ## Current Local State / Source of Truth
### P0 OBS Overlay System ### P0 OBS Overlay System

View File

@ -10,7 +10,7 @@ Files are stored under:
data/content-library/files/ 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: 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. 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 ## Access levels
### Locked ### Locked
@ -86,6 +88,8 @@ Raw delivery includes:
- permissive CORS/Cross-Origin-Resource-Policy headers for exposed and signed media; - permissive CORS/Cross-Origin-Resource-Policy headers for exposed and signed media;
- read-only routes with no mutation capability. - 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 ## Verification
Run: Run:

View File

@ -14,7 +14,7 @@ editable: false
Lumi is the core web UI and bot runtime. Lumi is the core web UI and bot runtime.
## Runtime ## Runtime
Package: lumi-bot Package: lumi-bot
Version: 0.2.23 Version: 0.2.24
## Routes ## Routes
- POST /api/diagnostics/v1/run - POST /api/diagnostics/v1/run
- GET /api/events - GET /api/events
@ -163,6 +163,7 @@ Version: 0.2.23
- GET /admin/resources - GET /admin/resources
- GET /api/admin/resources - GET /api/admin/resources
- GET /admin/resources/:id/raw/:filename - GET /admin/resources/:id/raw/:filename
- GET /admin/resources/:id/download/:filename
- POST /admin/resources/upload - POST /admin/resources/upload
- POST /admin/resources/settings - POST /admin/resources/settings
- POST /admin/resources/:id/rename - POST /admin/resources/:id/rename
@ -1535,6 +1536,15 @@ Version: 0.2.23
- Side effects: Usually read-only. - Side effects: Usually read-only.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. - 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 ### POST /admin/resources/upload
- Purpose: Processes the admin resources upload action and stores or applies submitted form data. - 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", "name": "lumi-bot",
"version": "0.2.23", "version": "0.2.24",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.2.23", "version": "0.2.24",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.12", "adm-zip": "^0.5.12",
"better-sqlite3": "^11.5.0", "better-sqlite3": "^11.5.0",

View File

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

View File

@ -2,6 +2,36 @@
"schema_version": 1, "schema_version": 1,
"channel": "stable", "channel": "stable",
"releases": [ "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", "version": "0.2.23",
"ref": "refs/tags/v0.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(created.preview_kind, "image");
assert.strictEqual(library.publicUrl(created, "https://lumi.example"), null); assert.strictEqual(library.publicUrl(created, "https://lumi.example"), null);
assert(fs.existsSync(library.resourceFilePath(created))); 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, { const signed = new URL(library.createSignedUrl(created.id, {
base_url: "https://lumi.example", base_url: "https://lumi.example",
@ -91,12 +92,25 @@ let database = null;
assert(stats.used_bytes > 0); assert(stats.used_bytes > 0);
assert(stats.max_file_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"); const lockedAgain = library.setResourceAccess(created.id, "locked");
assert.strictEqual(library.getResourceByExposedToken(publicToken), null); assert.strictEqual(library.getResourceByExposedToken(publicToken), null);
assert.strictEqual(lockedAgain.public_token, ""); assert.strictEqual(lockedAgain.public_token, "");
fs.rmSync(library.resourceFilePath(created), { force: true }); const storedPath = library.resourceFilePath(created);
library.deleteResource(created.id); library.deleteResource(created.id);
assert.strictEqual(fs.existsSync(storedPath), false);
assert.strictEqual(library.getResource(created.id), null); assert.strictEqual(library.getResource(created.id), null);
assert.strictEqual(library.getStorageStats().file_count, 0); 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"); 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-dropzone"));
assert(viewSource.includes("data-resource-preview-modal")); 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("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"); 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/revoke'));
assert(contentRouteSource.includes('/admin/resources/:id/download/:filename'));
const clientSource = fs.readFileSync(path.join(root, "src", "web", "public", "content-library.js"), "utf8"); 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("window.LumiConfirm?.destructiveToken"), "Resource actions must use the shared timed confirmation API");
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('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"); 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")); 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 { findSafeTarget } = require("../src/services/versioning");
const root = path.join(__dirname, ".."); const root = path.join(__dirname, "..");
const releaseVersion = "0.2.23"; const releaseVersion = "0.2.24";
const previousCoreVersion = "0.2.22"; const previousCoreVersion = "0.2.23";
const earliestCompatibleCoreVersion = "0.1.9"; const earliestCompatibleCoreVersion = "0.1.9";
const changedPlugins = { const changedPlugins = {
"auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" }, "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(webSearch.minimum_lumi_ai_version, "0.8.2");
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true); 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 releaseIndex = readJson("release-index.json");
const releaseVersions = releaseIndex.releases.map((release) => release.version); 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"); assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
for (const release of releaseIndex.releases) { for (const release of releaseIndex.releases) {
assert.equal(normalizeRepositoryRef(release.ref), release.ref); assert.equal(normalizeRepositoryRef(release.ref), release.ref);
@ -37,6 +37,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
const baseTarget = { const baseTarget = {
current_version: "0.2.4", current_version: "0.2.4",
available_versions: [ 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.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.22", ref: "refs/tags/v0.2.22", rollback_safe: true },
{ version: "0.2.21", ref: "refs/tags/v0.2.21", 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" channel: "stable"
}); });
assert.equal(corrected.version_correction, true); 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.update_available, true);
assert.equal(corrected.blocked, false); assert.equal(corrected.blocked, false);

View File

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

View File

@ -222,6 +222,10 @@
color: var(--lumi-success, var(--lumi-primary)); color: var(--lumi-success, var(--lumi-primary));
} }
.resource-browser-status:empty {
display: none;
}
.resource-settings-panel > summary { .resource-settings-panel > summary {
min-height: var(--lumi-control-height); min-height: var(--lumi-control-height);
display: flex; display: flex;
@ -282,6 +286,27 @@
gap: var(--lumi-space-4); 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 { .resource-card {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@ -298,6 +323,7 @@
} }
.resource-preview-tile { .resource-preview-tile {
position: relative;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
min-height: 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%), radial-gradient(circle at 25% 15%, color-mix(in srgb, var(--lumi-primary) 16%, transparent), transparent 48%),
var(--lumi-surface-subtle); var(--lumi-surface-subtle);
color: var(--lumi-text-muted); color: var(--lumi-text-muted);
cursor: pointer;
} }
.resource-preview-tile:hover { .resource-preview-tile:hover {
@ -323,6 +348,28 @@
var(--lumi-surface-raised); 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 { .resource-preview-tile img {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -333,6 +380,103 @@
) 50% / 1rem 1rem; ) 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 { .resource-type-mark {
min-width: 4.5rem; min-width: 4.5rem;
min-height: 3.2rem; min-height: 3.2rem;
@ -356,14 +500,16 @@
} }
.resource-card-heading { .resource-card-heading {
display: flex; min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start; align-items: start;
justify-content: space-between;
gap: var(--lumi-space-2); gap: var(--lumi-space-2);
} }
.resource-card-heading > div { .resource-card-heading > div {
min-width: 0; min-width: 0;
overflow: hidden;
} }
.resource-card-heading h3 { .resource-card-heading h3 {
@ -374,10 +520,19 @@
.resource-card-heading p { .resource-card-heading p {
margin: 0; margin: 0;
display: -webkit-box;
overflow: hidden; overflow: hidden;
overflow-wrap: anywhere;
color: var(--lumi-text-muted); color: var(--lumi-text-muted);
font-size: 0.82rem; 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; white-space: nowrap;
} }
@ -431,6 +586,11 @@
gap: var(--lumi-space-2); gap: var(--lumi-space-2);
} }
.resource-url-row .button {
min-width: 0;
max-width: 100%;
}
.resource-url-row input { .resource-url-row input {
min-width: 0; min-width: 0;
font-family: var(--lumi-font-mono); font-family: var(--lumi-font-mono);
@ -438,6 +598,7 @@
} }
.resource-card-actions { .resource-card-actions {
min-width: 0;
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
@ -448,6 +609,11 @@
display: contents; display: contents;
} }
.resource-card-actions .button {
min-width: 0;
max-width: 100%;
}
.resource-edit-details { .resource-edit-details {
padding-top: var(--lumi-space-2); padding-top: var(--lumi-space-2);
border-top: 1px solid var(--lumi-border); border-top: 1px solid var(--lumi-border);
@ -466,23 +632,44 @@
margin-bottom: var(--lumi-space-3); margin-bottom: var(--lumi-space-3);
} }
.resource-edit-details form + form { .resource-more-actions {
margin-top: var(--lumi-space-3);
}
.resource-rename-form {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
align-items: end; align-items: end;
gap: var(--lumi-space-3);
}
.resource-rename-form {
min-width: 0;
display: grid;
justify-items: start;
gap: var(--lumi-space-2); gap: var(--lumi-space-2);
} }
.resource-rename-form label { .resource-rename-form label {
width: 100%;
min-width: 0; min-width: 0;
display: grid; display: grid;
gap: var(--lumi-space-1); 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 { .resource-preview-modal {
z-index: 90; z-index: 90;
} }
@ -602,15 +789,29 @@
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.resource-more-actions {
grid-template-columns: minmax(0, 1fr) auto;
}
.resource-preview-dialog { .resource-preview-dialog {
width: 100%; width: 100%;
max-height: 96vh; max-height: 96vh;
} }
} }
@media (hover: none) {
.resource-media-controls {
opacity: 1;
pointer-events: auto;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.resource-dropzone, .resource-dropzone,
.resource-meter > span { .resource-meter > span,
.resource-media-controls,
.resource-rename-submit {
transition: none; transition: none;
} }
} }

View File

@ -6,6 +6,7 @@
const initializedLibraries = new WeakSet(); const initializedLibraries = new WeakSet();
const initializedUploads = new WeakSet(); const initializedUploads = new WeakSet();
const initializedMedia = new WeakSet();
let previewReturnFocus = null; let previewReturnFocus = null;
function init(root = document) { function init(root = document) {
@ -20,6 +21,8 @@
library.querySelector("[data-resource-search]")?.addEventListener("input", update); library.querySelector("[data-resource-search]")?.addEventListener("input", update);
library.querySelector("[data-resource-category-filter]")?.addEventListener("change", update); library.querySelector("[data-resource-category-filter]")?.addEventListener("change", update);
library.querySelector("[data-resource-access-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); applyFilters(library);
} }
@ -82,6 +85,24 @@
row.append(name, size); row.append(name, size);
list.append(row); 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) { function uploadResources(form) {
@ -91,6 +112,11 @@
setStatus(form, "Choose at least one file to upload.", "error"); setStatus(form, "Choose at least one file to upload.", "error");
return; return;
} }
const validationError = selectedFilesError(input, files);
if (validationError) {
setStatus(form, validationError, "error");
return;
}
const button = form.querySelector("[data-resource-upload-submit]"); const button = form.querySelector("[data-resource-upload-submit]");
const progress = form.querySelector("[data-resource-upload-progress]"); const progress = form.querySelector("[data-resource-upload-progress]");
const bar = form.querySelector("[data-resource-upload-progress-bar]"); const bar = form.querySelector("[data-resource-upload-progress-bar]");
@ -159,20 +185,27 @@
document.addEventListener("submit", async (event) => { document.addEventListener("submit", async (event) => {
const form = event.target.closest?.("[data-resource-action-form]"); const form = event.target.closest?.("[data-resource-action-form]");
if (!form) return; if (!form) return;
if (event.defaultPrevented) return;
event.preventDefault(); event.preventDefault();
const submitter = event.submitter; const submitter = event.submitter;
const button = submitter || form.querySelector("button[type='submit'], input[type='submit']"); const button = submitter || form.querySelector("button[type='submit'], input[type='submit']");
const body = new FormData(form); const action = effectiveAction(form, submitter);
if (body.has("confirmation_token")) { const headers = { Accept: "application/json", "X-Requested-With": "XMLHttpRequest" };
document.querySelector("[data-destructive-cancel]")?.click(); 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; if (button) button.disabled = true;
try { try {
const response = await fetch(effectiveAction(form, submitter), { const response = await fetch(action, {
method: effectiveMethod(form, submitter), method: effectiveMethod(form, submitter),
body, body: new FormData(form),
headers: { Accept: "application/json", "X-Requested-With": "XMLHttpRequest" }, headers,
credentials: "same-origin" credentials: "same-origin"
}); });
const payload = await response.json().catch(() => ({})); const payload = await response.json().catch(() => ({}));
@ -182,12 +215,13 @@
if (payload.deleted_id) removeResource(library, payload.deleted_id); if (payload.deleted_id) removeResource(library, payload.deleted_id);
if (payload.storage) updateStorage(library, payload.storage); if (payload.storage) updateStorage(library, payload.storage);
if (payload.settings) updateSettingsForm(library, payload.settings); 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"); notify(payload.message || "Saved.", "info");
applyFilters(library); applyFilters(library);
} catch (error) { } catch (error) {
restoreCardMedia(releasedCard);
const library = form.closest("[data-content-library]") || document.querySelector("[data-content-library]"); 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"); notify(error.message || "The resource update failed.", "danger");
} finally { } finally {
if (button && document.contains(button)) button.disabled = false; if (button && document.contains(button)) button.disabled = false;
@ -195,6 +229,12 @@
}); });
document.addEventListener("click", (event) => { 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]"); const previewButton = event.target.closest?.("[data-resource-preview]");
if (previewButton) { if (previewButton) {
event.preventDefault(); event.preventDefault();
@ -218,6 +258,13 @@
if (modal && event.target === modal) closePreview(); 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) => { document.addEventListener("keydown", (event) => {
const modal = document.querySelector("[data-resource-preview-modal].is-open"); const modal = document.querySelector("[data-resource-preview-modal].is-open");
if (!modal) return; if (!modal) return;
@ -265,7 +312,11 @@
card.dataset.resourceAccess = resource.access_level; card.dataset.resourceAccess = resource.access_level;
card.querySelector("[data-resource-title]").textContent = resource.display_name; card.querySelector("[data-resource-title]").textContent = resource.display_name;
const rename = card.querySelector('input[name="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]"); const badge = card.querySelector("[data-resource-access-badge]");
if (badge) { if (badge) {
badge.textContent = resource.access_level === "exposed" ? "Exposed" : "Locked"; badge.textContent = resource.access_level === "exposed" ? "Exposed" : "Locked";
@ -292,11 +343,26 @@
} }
} }
if (accessButton) accessButton.textContent = resource.access_level === "exposed" ? "Lock" : "Expose"; 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) { 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) { function buildResourceCard(resource) {
@ -307,21 +373,31 @@
card.dataset.resourceCategory = resource.category; card.dataset.resourceCategory = resource.category;
card.dataset.resourceAccess = resource.access_level; card.dataset.resourceAccess = resource.access_level;
const tile = element("button", "resource-preview-tile"); const tile = element("div", "resource-preview-tile");
tile.type = "button"; tile.dataset.resourcePreviewData = "";
tile.dataset.resourcePreview = "";
tile.setAttribute("aria-label", `Preview ${resource.display_name}`);
applyPreviewData(tile, resource); applyPreviewData(tile, resource);
if (resource.preview_kind === "image") { 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"); const image = document.createElement("img");
image.src = resource.admin_url; image.src = resource.admin_url;
image.alt = ""; image.alt = resource.display_name;
image.loading = "lazy"; 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 { } 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()); const mark = element("span", "resource-type-mark", resource.extension.replace(".", "").toUpperCase());
mark.setAttribute("aria-hidden", "true"); 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"); const body = element("div", "resource-card-body");
@ -359,8 +435,8 @@
preview.dataset.resourcePreview = ""; preview.dataset.resourcePreview = "";
applyPreviewData(preview, resource); applyPreviewData(preview, resource);
const download = element("a", "button subtle", "Download"); const download = element("a", "button subtle", "Download");
download.href = `${resource.admin_url}?download=1`; download.href = resource.download_url;
download.download = ""; download.dataset.resourceDownload = "";
const accessForm = actionForm(`/admin/resources/${encodeURIComponent(resource.id)}/${resource.access_level === "exposed" ? "revoke" : "access"}`); const accessForm = actionForm(`/admin/resources/${encodeURIComponent(resource.id)}/${resource.access_level === "exposed" ? "revoke" : "access"}`);
accessForm.dataset.resourceAccessForm = ""; accessForm.dataset.resourceAccessForm = "";
if (resource.access_level === "exposed") { if (resource.access_level === "exposed") {
@ -376,6 +452,7 @@
const details = element("details", "resource-edit-details"); const details = element("details", "resource-edit-details");
details.append(element("summary", "", "More actions")); 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 renameForm = actionForm(`/admin/resources/${encodeURIComponent(resource.id)}/rename`, "resource-rename-form");
const renameLabel = document.createElement("label"); const renameLabel = document.createElement("label");
renameLabel.append(element("span", "", "Display name")); renameLabel.append(element("span", "", "Display name"));
@ -384,15 +461,21 @@
renameInput.maxLength = 160; renameInput.maxLength = 160;
renameInput.required = true; renameInput.required = true;
renameInput.value = resource.display_name; renameInput.value = resource.display_name;
renameInput.dataset.resourceRenameInput = "";
renameInput.dataset.originalValue = resource.display_name;
renameLabel.append(renameInput); renameLabel.append(renameInput);
renameForm.append(renameLabel, button("Rename", "button subtle", "submit")); const renameButton = button("Save name", "button subtle resource-rename-submit", "submit");
const deleteForm = actionForm(`/admin/resources/${encodeURIComponent(resource.id)}/delete`); 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.confirmMode = "modal";
deleteForm.dataset.confirmTitle = "Delete resource"; 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.confirmText = `Permanently delete '${resource.display_name}' from Lumi? Any overlay or feature using it will stop working.`;
deleteForm.dataset.confirmLabel = "Delete resource"; deleteForm.dataset.confirmLabel = "Delete resource";
deleteForm.append(button("Delete resource", "button danger", "submit")); 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); body.append(heading, metadata, publicBlock, actions, details);
card.append(tile, body); card.append(tile, body);
@ -404,6 +487,7 @@
form.method = "post"; form.method = "post";
form.action = action; form.action = action;
form.dataset.resourceActionForm = ""; form.dataset.resourceActionForm = "";
form.dataset.noDestructiveConfirm = "";
return form; return form;
} }
@ -431,11 +515,134 @@
button.dataset.resourceDisplayName = resource.display_name; button.dataset.resourceDisplayName = resource.display_name;
button.dataset.resourceOriginalName = resource.original_name; button.dataset.resourceOriginalName = resource.original_name;
button.dataset.resourceUrl = resource.admin_url; button.dataset.resourceUrl = resource.admin_url;
button.dataset.resourceDownloadUrl = resource.download_url;
button.dataset.resourcePreviewKind = resource.preview_kind; button.dataset.resourcePreviewKind = resource.preview_kind;
button.dataset.resourceMime = resource.mime; button.dataset.resourceMime = resource.mime;
button.dataset.resourceSize = resource.size_display; 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) { function applyFilters(library) {
if (!library) return; if (!library) return;
const search = (library.querySelector("[data-resource-search]")?.value || "").trim().toLowerCase(); const search = (library.querySelector("[data-resource-search]")?.value || "").trim().toLowerCase();
@ -458,7 +665,7 @@
async function openPreview(source) { async function openPreview(source) {
const card = source.closest("[data-resource-card]"); 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; if (!dataSource) return;
const modal = document.querySelector("[data-resource-preview-modal]"); const modal = document.querySelector("[data-resource-preview-modal]");
const stage = modal?.querySelector("[data-resource-preview-stage]"); 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-title]").textContent = dataSource.dataset.resourceDisplayName || "Resource preview";
modal.querySelector("[data-resource-preview-meta]").textContent = `${dataSource.dataset.resourceOriginalName || ""} · ${dataSource.dataset.resourceSize || ""}`; modal.querySelector("[data-resource-preview-meta]").textContent = `${dataSource.dataset.resourceOriginalName || ""} · ${dataSource.dataset.resourceSize || ""}`;
const download = modal.querySelector("[data-resource-preview-download]"); 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; previewReturnFocus = source instanceof HTMLElement ? source : document.activeElement;
modal.classList.add("is-open"); modal.classList.add("is-open");
modal.setAttribute("aria-hidden", "false"); 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))}%`; if (meter) meter.style.width = `${Math.max(0, Math.min(100, Number(storage.usage_percent) || 0))}%`;
const progress = meter?.closest("[role='progressbar']"); const progress = meter?.closest("[role='progressbar']");
progress?.setAttribute("aria-valuenow", String(storage.usage_percent || 0)); 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) { function updateSettingsForm(library, settings) {
@ -596,6 +811,41 @@
target.classList.toggle("is-success", tone === "success"); 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) { function notify(message, tone) {
window.LumiInteractions?.showEventNotice?.({ message }, tone); window.LumiInteractions?.showEventNotice?.({ message }, tone);
} }

View File

@ -54,12 +54,14 @@
name="resources" name="resources"
multiple multiple
accept="<%= formatGroups.flatMap((group) => group.extensions).join(',') %>" 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 data-resource-file-input
/> />
<span class="resource-dropzone-icon" aria-hidden="true"></span> <span class="resource-dropzone-icon" aria-hidden="true"></span>
<strong>Drop files here</strong> <strong>Drop files here</strong>
<span>or select files from this device</span> <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> </label>
<div class="resource-selected-files" data-resource-selected-files hidden></div> <div class="resource-selected-files" data-resource-selected-files hidden></div>
<div class="resource-upload-options"> <div class="resource-upload-options">
@ -105,6 +107,7 @@
</div> </div>
<div class="field full"> <div class="field full">
<button type="submit" class="button subtle">Save storage settings</button> <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> </div>
</form> </form>
<div class="resource-format-groups"> <div class="resource-format-groups">
@ -148,12 +151,15 @@
</label> </label>
</div> </div>
</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" : "" %>> <div class="resource-empty-state" data-resource-empty <%= resources.length ? "hidden" : "" %>>
No resources yet. Drop media above to make it available to Lumi. <strong>No files uploaded yet.</strong>
<span>Drop media in the upload area above to make it available to Lumi.</span>
</div> </div>
<div class="resource-empty-state empty-state" data-resource-filter-empty hidden> <div class="resource-empty-state" data-resource-filter-empty hidden>
No resources match the current filters. <strong>No matching files.</strong>
<span>Try clearing the search or changing the type and access filters.</span>
</div> </div>
<div class="resource-grid" data-resource-grid> <div class="resource-grid" data-resource-grid>
@ -166,26 +172,45 @@
data-resource-category="<%= resource.category %>" data-resource-category="<%= resource.category %>"
data-resource-access="<%= resource.access_level %>" data-resource-access="<%= resource.access_level %>"
> >
<button <div
type="button"
class="resource-preview-tile" class="resource-preview-tile"
data-resource-preview data-resource-preview-data
data-resource-id="<%= resource.id %>" data-resource-id="<%= resource.id %>"
data-resource-display-name="<%= resource.display_name %>" data-resource-display-name="<%= resource.display_name %>"
data-resource-original-name="<%= resource.original_name %>" data-resource-original-name="<%= resource.original_name %>"
data-resource-url="<%= resource.admin_url %>" data-resource-url="<%= resource.admin_url %>"
data-resource-download-url="<%= resource.download_url %>"
data-resource-preview-kind="<%= resource.preview_kind %>" data-resource-preview-kind="<%= resource.preview_kind %>"
data-resource-mime="<%= resource.mime %>" data-resource-mime="<%= resource.mime %>"
data-resource-size="<%= resource.size_display %>" data-resource-size="<%= resource.size_display %>"
aria-label="Preview <%= resource.display_name %>"
> >
<% if (resource.preview_kind === "image") { %> <% 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>
<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 { %> <% } else { %>
<span class="resource-type-mark" aria-hidden="true"><%= resource.extension.replace('.', '').toUpperCase() %></span> <button type="button" class="resource-preview-open resource-preview-fallback-tile" data-resource-preview aria-label="Preview <%= resource.display_name %>">
<span>Preview</span> <span class="resource-type-mark" aria-hidden="true"><%= resource.extension.replace('.', '').toUpperCase() %></span>
<span>Open preview</span>
</button>
<% } %> <% } %>
</button> </div>
<div class="resource-card-body"> <div class="resource-card-body">
<div class="resource-card-heading"> <div class="resource-card-heading">
<div> <div>
@ -207,12 +232,13 @@
</div> </div>
<div class="resource-card-actions"> <div class="resource-card-actions">
<button type="button" class="button subtle" data-resource-preview>Preview</button> <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 <form
method="post" method="post"
action="/admin/resources/<%= resource.id %>/<%= resource.access_level === 'exposed' ? 'revoke' : 'access' %>" action="/admin/resources/<%= resource.id %>/<%= resource.access_level === 'exposed' ? 'revoke' : 'access' %>"
data-resource-action-form data-resource-action-form
data-resource-access-form data-resource-access-form
data-no-destructive-confirm
<% if (resource.access_level === "exposed") { %> <% if (resource.access_level === "exposed") { %>
data-confirm-mode="modal" data-confirm-mode="modal"
data-confirm-title="Lock resource" data-confirm-title="Lock resource"
@ -225,24 +251,28 @@
</div> </div>
<details class="resource-edit-details"> <details class="resource-edit-details">
<summary>More actions</summary> <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">
<label> <form method="post" action="/admin/resources/<%= resource.id %>/rename" class="resource-rename-form" data-resource-action-form data-no-destructive-confirm>
<span>Display name</span> <label>
<input name="display_name" maxlength="160" value="<%= resource.display_name %>" required /> <span>Display name</span>
</label> <input name="display_name" maxlength="160" value="<%= resource.display_name %>" data-resource-rename-input data-original-value="<%= resource.display_name %>" required />
<button type="submit" class="button subtle">Rename</button> </label>
</form> <button type="submit" class="button subtle resource-rename-submit" data-resource-rename-submit disabled>Save name</button>
<form </form>
method="post" <form
action="/admin/resources/<%= resource.id %>/delete" method="post"
data-resource-action-form action="/admin/resources/<%= resource.id %>/delete"
data-confirm-mode="modal" class="resource-delete-form"
data-confirm-title="Delete resource" data-resource-action-form
data-confirm-text="Permanently delete '<%= resource.display_name %>' from Lumi? Any overlay or feature using it will stop working." data-no-destructive-confirm
data-confirm-label="Delete resource" data-confirm-mode="modal"
> data-confirm-title="Delete resource"
<button type="submit" class="button danger">Delete resource</button> data-confirm-text="Permanently delete '<%= resource.display_name %>' from Lumi? Any overlay or feature using it will stop working."
</form> data-confirm-label="Delete resource"
>
<button type="submit" class="button danger">Delete resource</button>
</form>
</div>
</details> </details>
</div> </div>
</article> </article>
@ -262,7 +292,7 @@
<div class="resource-preview-stage" data-resource-preview-stage></div> <div class="resource-preview-stage" data-resource-preview-stage></div>
<p class="resource-preview-error" data-resource-preview-error role="status" hidden></p> <p class="resource-preview-error" data-resource-preview-error role="status" hidden></p>
<div class="modal-actions"> <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> <button type="button" class="button" data-resource-preview-close>Close</button>
</div> </div>
</div> </div>

View File

@ -1,14 +1,14 @@
{ {
"name": "Lumi Core", "name": "Lumi Core",
"version": "0.2.23", "version": "0.2.24",
"channel": "stable", "channel": "stable",
"released_at": "2026-07-19", "released_at": "2026-07-20",
"compatible_from": "0.1.9", "compatible_from": "0.1.9",
"migration_kind": "patch", "migration_kind": "patch",
"replaces_versions": [ "replaces_versions": [
"1.2.0" "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, "rollback_safe": true,
"requirements": [ "requirements": [
"Node.js 18 or newer" "Node.js 18 or newer"
@ -301,6 +301,18 @@
], ],
"rollback_safe": true, "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." "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."
} }
] ]
} }