197 lines
8.2 KiB
HTML
197 lines
8.2 KiB
HTML
{{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}}
|