Files
volumen/internal/web/templates/media.html
T

197 lines
8.2 KiB
HTML
Raw Normal View History

2026-09-18 12:03:35 +02:00
{{define "title"}}{{ tr "Media" }} · {{ tr "Volumen admin" }}{{end}}
{{define "content"}}
<div class="page-head">
<div class="page-head__text">
<h1>{{ tr "Media" }}</h1>
<p class="page-head__lede">{{ trn (len .MediaItems) "media.count" }}{{if .MediaTotal}} · {{ .MediaTotal }}{{end}}</p>
</div>
<div class="page-head__actions">
<button type="button" class="btn btn--primary" id="media-upload-btn">
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#plus"></use></svg>
{{ tr "Upload images" }}
</button>
</div>
</div>
{{if .Error}}
<div class="status status--error" role="alert">
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#alert-circle"></use></svg>
<div>{{ .Error }}</div>
</div>
{{end}}
<label class="dropzone dropzone--row" id="media-drop">
<input id="media-file" type="file" accept="image/webp,image/avif,image/svg+xml" multiple class="dropzone__input"
data-csrf="{{ .CSRFToken }}">
<span class="dropzone__icon" aria-hidden="true">
<svg><use href="/admin/assets/graphis.svg#upload"></use></svg>
</span>
<span>
<span class="dropzone__title">{{ tr "Drop images here, or click to browse" }}</span>
<span class="dropzone__hint">{{ tr "WebP, AVIF or SVG, up to 10 MB each." }}</span>
</span>
</label>
{{if not .MediaItems}}
<div class="empty gap-top">
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#image"></use></svg>
<h2>{{ tr "No media yet" }}</h2>
<p class="empty__note">{{ tr "Upload an image to use as a cover or inline content. WebP, AVIF and SVG are supported." }}</p>
<p class="empty__note">{{ tr "You can also paste or drop an image directly into the editor body." }}</p>
</div>
{{else}}
<div class="toolbar">
<div class="search">
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#search"></use></svg>
<label class="visually-hidden" for="media-search">{{ tr "Search files..." }}</label>
<input id="media-search" class="control" type="search"
placeholder="{{ tr "Search files..." }}" autocomplete="off">
</div>
<span class="toolbar__count" id="media-count" aria-live="polite" hidden></span>
</div>
<div class="media-grid" id="media-grid">
{{range .MediaItems}}
<div class="card media-tile" data-name="{{ lower .Name }}">
<a href="{{ .URL }}" target="_blank" rel="noopener" class="media-tile__thumb">
<img src="{{ .URL }}" alt="{{ .Name }}" loading="lazy" decoding="async">
</a>
<div class="media-tile__name" title="{{ .Name }}">{{ .Name }}</div>
<div class="media-tile__meta">{{ .SizeKB }} kB{{if .Dimensions}} · {{ .Dimensions }}{{end}}</div>
<div class="media-tile__actions">
<button type="button" class="icon-btn" data-copy="{{ .URL }}" title="{{ tr "Copy link" }}" aria-label="{{ tr "Copy link" }}">
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#copy"></use></svg>
</button>
<a class="icon-btn" href="{{ .URL }}" target="_blank" rel="noopener"
title="{{ tr "Open in new tab" }}" aria-label="{{ tr "Open in new tab" }}">
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#external-link"></use></svg>
</a>
<span class="flex"></span>
<form method="post" action="/admin/media/{{ .Name }}/delete"
data-confirm="{{ trf "Delete %s?" .Name }}" class="form-inline">
<input type="hidden" name="_csrf" value="{{ $.CSRFToken }}">
<button type="submit" class="icon-btn icon-btn--danger" title="{{ tr "Delete" }}" aria-label="{{ tr "Delete" }}">
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#trash"></use></svg>
</button>
</form>
</div>
</div>
{{end}}
</div>
<div class="empty empty--span gap-top" id="media-no-results" hidden>
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#search"></use></svg>
<h2>{{ tr "No files match your search." }}</h2>
</div>
{{end}}
<script nonce="{{ .CSPNonce }}">
(() => {
const drop = document.getElementById("media-drop");
const input = document.getElementById("media-file");
const headBtn = document.getElementById("media-upload-btn");
const search = document.getElementById("media-search");
const countEl = document.getElementById("media-count");
const noResults = document.getElementById("media-no-results");
const grid = document.getElementById("media-grid");
let depth = 0;
headBtn.addEventListener("click", () => input.click());
/* The whole window tracks the drag so the strip answers anywhere. */
["dragover", "drop"].forEach((evt) =>
window.addEventListener(evt, (event) => event.preventDefault()));
window.addEventListener("dragenter", (event) => {
if (!event.dataTransfer?.types.includes("Files")) return;
depth += 1;
drop.classList.add("is-over");
});
window.addEventListener("dragleave", () => {
depth = Math.max(0, depth - 1);
if (depth === 0) drop.classList.remove("is-over");
});
drop.addEventListener("drop", (event) => {
event.preventDefault();
event.stopPropagation();
depth = 0;
drop.classList.remove("is-over");
const files = Array.from(event.dataTransfer?.files || []);
if (files.length) uploadAll(files);
});
input.addEventListener("change", () => {
const files = Array.from(input.files || []);
if (files.length) uploadAll(files);
input.value = "";
});
const isUploadable = (file) => /\.(webp|avif|svg)$/i.test(file.name)
|| file.type === "image/webp" || file.type === "image/avif" || file.type === "image/svg+xml";
async function uploadOne(file) {
window.__volumen.toast(window.__f("media.uploading", file.name), { kind: "ok", ttl: 1500 });
const data = new FormData();
data.append("file", file);
data.append("_csrf", input.dataset.csrf);
try {
const response = await fetch("/admin/uploads", { method: "POST", body: data, credentials: "same-origin" });
const json = await response.json();
if (response.ok && json.url) return true;
window.__volumen.toast(
window.__f("media.upload_failed", json.message || json.error || file.name),
{ kind: "error" });
} catch (error) {
window.__volumen.toast(window.__f("media.upload_failed", file.name), { kind: "error" });
}
return false;
}
async function uploadAll(files) {
let done = 0;
for (const file of files) {
if (!isUploadable(file)) {
window.__volumen.toast(window.__f("upload.rejected", file.name), { kind: "error" });
continue;
}
if (await uploadOne(file)) done += 1;
}
if (done > 0) {
window.__volumen.toast(window.__("upload.complete"), { kind: "ok" });
setTimeout(() => window.location.reload(), 600);
}
}
// --- Copy link -----------------------------------------------------------
grid?.addEventListener("click", async (event) => {
const btn = event.target.closest("[data-copy]");
if (!btn) return;
const url = btn.dataset.copy;
try {
await navigator.clipboard.writeText(url);
window.__volumen.toast(window.__("media.copied"), { kind: "ok" });
} catch (error) {
const shown = await window.__volumen.prompt(window.__("media.copy_prompt"), url);
if (shown) try { await navigator.clipboard.writeText(shown); } catch (inner) {}
}
});
// --- Search ----------------------------------------------------------------
search?.addEventListener("input", () => {
const q = search.value.toLowerCase().trim();
let visible = 0;
grid.querySelectorAll(".media-tile").forEach((tile) => {
const hit = !q || (tile.dataset.name || "").includes(q);
tile.classList.toggle("is-hidden", !hit);
if (hit) visible += 1;
});
const total = grid.querySelectorAll(".media-tile").length;
countEl.hidden = !q;
if (q) {
countEl.textContent = window.__("filter.of")
.replace("%d", String(visible)).replace("%d", String(total));
}
noResults.hidden = visible !== 0;
});
})();
</script>
{{end}}