diff --git a/CHANGELOG.md b/CHANGELOG.md index 5393c4c..7166f0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/TODO.md b/TODO.md index b33fc89..50b34a3 100644 --- a/TODO.md +++ b/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 diff --git a/docs/content-library.md b/docs/content-library.md index 1378e9e..0917025 100644 --- a/docs/content-library.md +++ b/docs/content-library.md @@ -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: diff --git a/knowledge/core/lumi-core.md b/knowledge/core/lumi-core.md index 2a9307f..7ddedda 100644 --- a/knowledge/core/lumi-core.md +++ b/knowledge/core/lumi-core.md @@ -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. diff --git a/package-lock.json b/package-lock.json index a971c8a..82e9dfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7f4be75..c956c92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lumi-bot", - "version": "0.2.23", + "version": "0.2.24", "private": true, "type": "commonjs", "scripts": { diff --git a/release-index.json b/release-index.json index f7a8656..2cbed77 100644 --- a/release-index.json +++ b/release-index.json @@ -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", diff --git a/scripts/verify-content-library.js b/scripts/verify-content-library.js index d207996..84b1ec0 100644 --- a/scripts/verify-content-library.js +++ b/scripts/verify-content-library.js @@ -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")); diff --git a/scripts/verify-release-metadata.js b/scripts/verify-release-metadata.js index a4993b7..809608b 100644 --- a/scripts/verify-release-metadata.js +++ b/scripts/verify-release-metadata.js @@ -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."); diff --git a/scripts/verify-update-system.js b/scripts/verify-update-system.js index 27d204f..498447b 100644 --- a/scripts/verify-update-system.js +++ b/scripts/verify-update-system.js @@ -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); diff --git a/src/services/content-library.js b/src/services/content-library.js index 89a2962..195fc82 100644 --- a/src/services/content-library.js +++ b/src/services/content-library.js @@ -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; - } - 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; + 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."); + // 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); + return current; } 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, diff --git a/src/services/content-routes.js b/src/services/content-routes.js index c6cd99f..1d4eac5 100644 --- a/src/services/content-routes.js +++ b/src/services/content-routes.js @@ -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, diff --git a/src/web/public/content-library.css b/src/web/public/content-library.css index 8d12ca3..ac97e44 100644 --- a/src/web/public/content-library.css +++ b/src/web/public/content-library.css @@ -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; } } diff --git a/src/web/public/content-library.js b/src/web/public/content-library.js index 54b7a66..7fb1e6c 100644 --- a/src/web/public/content-library.js +++ b/src/web/public/content-library.js @@ -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); } diff --git a/src/web/views/admin-resources.ejs b/src/web/views/admin-resources.ejs index f86db71..1f1a5b2 100644 --- a/src/web/views/admin-resources.ejs +++ b/src/web/views/admin-resources.ejs @@ -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 /> Drop files here or select files from this device - Up to <%= settings.max_files %> files per upload · <%= formatBytes(storage.max_file_bytes) %> per file + Up to <%= settings.max_files %> files per upload · <%= formatBytes(storage.max_file_bytes) %> per file
@@ -105,6 +107,7 @@
+

@@ -148,12 +151,15 @@
+

-
> - No resources yet. Drop media above to make it available to Lumi. +
> + No files uploaded yet. + Drop media in the upload area above to make it available to Lumi.
-