Lumi/src/web/views/admin-resources.ejs
2026-07-22 10:20:32 +02:00

303 lines
17 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<%- include("partials/layout-top", { title }) %>
<link rel="stylesheet" href="/content-library.css?v=<%= assetVersion %>" />
<section class="card content-library" data-content-library>
<%- include("partials/page-header", {
eyebrow: "Content delivery",
pageTitle: "Resources",
description: "Upload and manage media for Lumi, overlays, alerts, and other streaming features. Locked resources stay internal; exposed resources receive a read-only URL."
}) %>
<div class="resource-storage-grid" aria-label="Resource storage status">
<article class="resource-storage-card resource-storage-primary">
<span class="eyebrow">Available to Lumi</span>
<strong data-storage-available><%= storage.effective_available_bytes === null ? "Unknown" : formatBytes(storage.effective_available_bytes) %></strong>
<p data-storage-available-note>
<% if (storage.quota_enabled) { %>
Limited by the smaller of remaining quota and usable disk space.
<% } else { %>
Based on disk space after Lumi's safety reserve.
<% } %>
</p>
<div class="resource-meter" role="progressbar" aria-label="Storage usage" aria-valuemin="0" aria-valuemax="100" aria-valuenow="<%= storage.usage_percent %>">
<span data-storage-meter style="width: <%= storage.usage_percent %>%"></span>
</div>
</article>
<article class="resource-storage-card">
<span>Library usage</span>
<strong data-storage-used><%= formatBytes(storage.used_bytes) %></strong>
<small><span data-storage-count><%= storage.file_count %></span> file<%= storage.file_count === 1 ? "" : "s" %></small>
</article>
<article class="resource-storage-card">
<span>Disk free</span>
<strong data-storage-disk-free><%= storage.disk_free_bytes === null ? "Unavailable" : formatBytes(storage.disk_free_bytes) %></strong>
<small><%= storage.disk_available ? `${formatBytes(storage.reserve_bytes)} reserved` : "Disk statistics unavailable" %></small>
</article>
<article class="resource-storage-card">
<span>Library limit</span>
<strong data-storage-limit><%= storage.quota_enabled ? formatBytes(storage.limit_bytes) : "Unlimited" %></strong>
<small>Per-file maximum: <span data-storage-max-file><%= formatBytes(storage.max_file_bytes) %></span></small>
</article>
</div>
<div class="resource-workspace">
<section class="resource-upload-panel" aria-labelledby="resource-upload-title">
<div class="section-header">
<div>
<h2 id="resource-upload-title">Add resources</h2>
<p class="hint">Uploads are stored under generated filenames and never executed by Lumi.</p>
</div>
</div>
<form method="post" action="/admin/resources/upload" enctype="multipart/form-data" data-resource-upload-form>
<label class="resource-dropzone" data-resource-dropzone tabindex="0">
<input
type="file"
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 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">
<div class="field">
<label for="resource-upload-access">Initial access</label>
<select id="resource-upload-access" name="access_level">
<option value="locked" selected>Locked — admins and Lumi only</option>
<option value="exposed">Exposed — create read-only URLs</option>
</select>
</div>
<button type="submit" class="button" data-resource-upload-submit>Upload resources</button>
</div>
<div class="resource-upload-progress" data-resource-upload-progress hidden>
<div class="resource-meter" role="progressbar" aria-label="Upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<span data-resource-upload-progress-bar></span>
</div>
<span data-resource-upload-progress-text>Preparing upload…</span>
</div>
<p class="resource-status" data-resource-status aria-live="polite"></p>
</form>
</section>
<details class="resource-settings-panel">
<summary><span><strong>Storage limits and supported formats</strong><span class="hint" data-resource-settings-summary><%= settings.limit_gib > 0 ? `${settings.limit_gib} GiB library limit` : "Unlimited library" %> · <%= settings.max_file_mib %> MiB per file · <%= settings.max_files %> files per upload</span></span></summary>
<form method="post" action="/admin/resources/settings" class="form-grid" data-resource-action-form data-resource-settings-form>
<div class="field">
<label for="resource-limit-gib">Library limit (GiB)</label>
<input id="resource-limit-gib" name="limit_gib" type="number" min="0" step="0.1" value="<%= settings.limit_gib %>" required />
<span class="hint">Use 0 for no Lumi-specific quota. Physical disk capacity still applies.</span>
</div>
<div class="field">
<label for="resource-reserve-gib">Keep free on disk (GiB)</label>
<input id="resource-reserve-gib" name="reserve_gib" type="number" min="0" step="0.1" value="<%= settings.reserve_gib %>" required />
<span class="hint">Lumi stops accepting uploads before the disk falls below this reserve.</span>
</div>
<div class="field">
<label for="resource-max-file-mib">Maximum file size (MiB)</label>
<input id="resource-max-file-mib" name="max_file_mib" type="number" min="1" max="8192" step="1" value="<%= settings.max_file_mib %>" required />
</div>
<div class="field">
<label for="resource-max-files">Files per upload</label>
<input id="resource-max-files" name="max_files" type="number" min="1" max="50" step="1" value="<%= settings.max_files %>" required />
</div>
<div class="field full">
<button type="submit" class="button">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">
<% formatGroups.forEach((group) => { %>
<div>
<strong><%= group.category.charAt(0).toUpperCase() + group.category.slice(1) %></strong>
<span><%= group.extensions.join(" · ") %></span>
</div>
<% }) %>
</div>
<p class="hint">Some accepted formats, such as MKV, AVI, HEIC, or PSD, may not decode in the browser preview. Lumi will still store and serve them correctly.</p>
</details>
</div>
<div class="resource-browser-header">
<div>
<h2>File browser</h2>
<p class="hint">Preview, rename, lock, expose, copy, or remove uploaded resources.</p>
</div>
<div class="resource-browser-controls">
<label class="resource-search">
<span class="visually-hidden">Search resources</span>
<input type="search" placeholder="Search files" data-resource-search />
</label>
<label>
<span class="visually-hidden">Filter by type</span>
<select data-resource-category-filter>
<option value="">All types</option>
<% formatGroups.forEach((group) => { %>
<option value="<%= group.category %>"><%= group.category.charAt(0).toUpperCase() + group.category.slice(1) %></option>
<% }) %>
</select>
</label>
<label>
<span class="visually-hidden">Filter by access</span>
<select data-resource-access-filter>
<option value="">All access</option>
<option value="locked">Locked</option>
<option value="exposed">Exposed</option>
</select>
</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" 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" 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>
<% resources.forEach((resource) => { %>
<article
class="resource-card"
data-resource-card
data-resource-id="<%= resource.id %>"
data-resource-name="<%= resource.display_name.toLowerCase() %> <%= resource.original_name.toLowerCase() %> <%= resource.mime.toLowerCase() %>"
data-resource-category="<%= resource.category %>"
data-resource-access="<%= resource.access_level %>"
>
<div
class="resource-preview-tile"
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 %>"
>
<% if (resource.preview_kind === "image") { %>
<button type="button" class="resource-preview-open" data-resource-preview aria-label="Open a larger preview of <%= resource.display_name %>">
<img src="<%= resource.admin_url %>" alt="<%= resource.display_name %>" loading="lazy" />
</button>
<% } else if (resource.preview_kind === "video" || resource.preview_kind === "audio") { %>
<div class="resource-media-preview <%= resource.preview_kind === 'audio' ? 'is-audio' : 'is-video' %>" data-resource-media-preview>
<% if (resource.preview_kind === "video") { %>
<video src="<%= resource.admin_url %>" preload="metadata" playsinline data-resource-media></video>
<% } else { %>
<span class="resource-type-mark" aria-hidden="true"><%= resource.extension.replace('.', '').toUpperCase() %></span>
<audio src="<%= resource.admin_url %>" preload="metadata" data-resource-media></audio>
<% } %>
<div class="resource-media-controls">
<button type="button" class="button subtle resource-media-toggle" data-resource-media-toggle aria-label="Play <%= resource.display_name %>">Play</button>
<div class="resource-media-timeline">
<input type="range" min="0" max="1000" value="0" step="1" data-resource-media-seek aria-label="Playback position for <%= resource.display_name %>" />
<span data-resource-media-time>0:00 / --:--</span>
</div>
</div>
</div>
<% } else { %>
<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>
<h3 data-resource-title><%= resource.display_name %></h3>
<p title="<%= resource.original_name %>"><%= resource.original_name %></p>
</div>
<span class="badge <%= resource.access_level === 'exposed' ? 'success' : '' %>" data-resource-access-badge><%= resource.access_level === "exposed" ? "Exposed" : "Locked" %></span>
</div>
<dl class="resource-metadata">
<div><dt>Type</dt><dd><%= resource.category %> · <%= resource.extension %></dd></div>
<div><dt>Size</dt><dd><%= resource.size_display %></dd></div>
</dl>
<div class="resource-public-url" data-resource-public-block <%= resource.public_url ? "" : "hidden" %>>
<label>Read-only URL</label>
<div class="resource-url-row">
<input readonly value="<%= resource.public_url || '' %>" data-resource-public-url aria-label="Read-only URL for <%= resource.display_name %>" />
<button type="button" class="button subtle" data-resource-copy-url>Copy</button>
</div>
</div>
<div class="resource-card-actions">
<button type="button" class="button subtle" data-resource-preview>Preview</button>
<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"
data-confirm-text="Lock this resource and revoke its current read-only URL? Anything using that URL will stop working."
data-confirm-label="Lock resource"
<% } %>
>
<button type="submit" class="button subtle" data-resource-access-button><%= resource.access_level === "exposed" ? "Lock" : "Expose" %></button>
</form>
</div>
<details class="resource-edit-details">
<summary>More actions</summary>
<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 %>" data-resource-rename-input data-original-value="<%= resource.display_name %>" required />
</label>
<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."
data-confirm-label="Delete resource"
>
<button type="submit" class="button danger">Delete resource</button>
</form>
</div>
</details>
</div>
</article>
<% }) %>
</div>
</section>
<div class="modal-backdrop resource-preview-modal" data-resource-preview-modal aria-hidden="true">
<div class="modal resource-preview-dialog" role="dialog" aria-modal="true" aria-labelledby="resource-preview-title">
<div class="modal-header">
<div>
<h2 id="resource-preview-title" data-resource-preview-title>Resource preview</h2>
<p class="hint" data-resource-preview-meta></p>
</div>
<button type="button" class="icon-button" data-resource-preview-close aria-label="Close preview">×</button>
</div>
<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</a>
<button type="button" class="button" data-resource-preview-close>Close</button>
</div>
</div>
</div>
<script src="/content-library.js?v=<%= assetVersion %>" defer></script>
<%- include("partials/layout-bottom") %>