303 lines
17 KiB
Plaintext
303 lines
17 KiB
Plaintext
<%- 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") %>
|