Polish resource library controls
This commit is contained in:
parent
7ec23e5709
commit
a95e0789cc
@ -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.
|
||||
|
||||
6
TODO.md
6
TODO.md
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
4
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lumi-bot",
|
||||
"version": "0.2.23",
|
||||
"version": "0.2.24",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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.");
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user