Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
{{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}}
|
||||
Reference in New Issue
Block a user