Files
volumen/internal/web/templates/import.html
T
petrbalvin f8ed33df83
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
Initial commit
Assisted-by: GLM 5.3
2026-09-29 10:03:32 +02:00

190 lines
6.8 KiB
HTML

{{define "title"}}{{ tr "Import post" }} · {{ tr "Volumen admin" }}{{end}}
{{define "content"}}
<div class="page-head">
<div class="page-head__text">
<h1>{{ tr "Import post" }}</h1>
<p class="page-head__lede">{{ tr "Bring a post written anywhere: a Markdown file with optional TOML frontmatter." }}</p>
</div>
<div class="page-head__actions">
<a class="btn btn--ghost" href="/admin/">{{ tr "Cancel" }}</a>
</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}}
<form method="post" action="/admin/posts/import" enctype="multipart/form-data" id="import-form" class="stack">
<input type="hidden" name="_csrf" value="{{ .CSRFToken }}">
<label class="dropzone" id="dropzone">
<input id="import-file" type="file" name="file" accept=".md,text/markdown" class="dropzone__input">
<span class="dropzone__icon" aria-hidden="true">
<svg><use href="/admin/assets/graphis.svg#upload"></use></svg>
</span>
<span class="dropzone__title">{{ tr "Drop your post here" }}</span>
<span class="dropzone__sub">{{ tr "or click to browse" }} · .md · UTF-8</span>
<span class="dropzone__hint">markdown&nbsp;+&nbsp;toml frontmatter&nbsp;(&nbsp;+++&nbsp;)</span>
<span class="dropzone__error" id="dz-error" role="alert" hidden></span>
</label>
<div class="file-row" id="dz-file" hidden>
<span class="file-row__ok" aria-hidden="true">
<svg><use href="/admin/assets/graphis.svg#check"></use></svg>
</span>
<span class="file-row__info">
<span class="file-row__name" id="dz-name"></span>
<span class="file-row__meta" id="dz-meta"></span>
</span>
<button type="button" class="btn btn--ghost btn--sm" id="dz-clear">{{ tr "Remove" }}</button>
</div>
<div class="card" id="dz-preview" hidden>
<div class="card__head">
<h2 class="mono">{{ tr "frontmatter" }}</h2>
</div>
<dl class="kv" id="dz-kv"></dl>
<p class="field__hint" id="dz-note" hidden>
{{ tr "No frontmatter found; the slug will be derived from the file name." }}
</p>
</div>
<button type="submit" class="btn btn--primary" id="dz-submit" disabled>
{{ tr "Import post" }}
<svg aria-hidden="true"><use href="/admin/assets/graphis.svg#arrow-right"></use></svg>
</button>
</form>
<script nonce="{{ .CSPNonce }}">
(() => {
const zone = document.getElementById("dropzone");
const input = document.getElementById("import-file");
const fileRow = document.getElementById("dz-file");
const nameEl = document.getElementById("dz-name");
const metaEl = document.getElementById("dz-meta");
const preview = document.getElementById("dz-preview");
const kvEl = document.getElementById("dz-kv");
const noteEl = document.getElementById("dz-note");
const errorEl = document.getElementById("dz-error");
const clearBtn = document.getElementById("dz-clear");
const submitBtn = document.getElementById("dz-submit");
let depth = 0;
/* Keep the browser from navigating away when a file misses the zone. */
["dragover", "drop"].forEach((evt) =>
window.addEventListener(evt, (event) => event.preventDefault()));
window.addEventListener("dragenter", () => {
depth += 1;
zone.classList.add("is-over");
});
window.addEventListener("dragleave", () => {
depth = Math.max(0, depth - 1);
if (depth === 0) zone.classList.remove("is-over");
});
zone.addEventListener("drop", (event) => {
event.preventDefault();
event.stopPropagation();
depth = 0;
zone.classList.remove("is-over");
const files = Array.from(event.dataTransfer?.files || []);
if (files.length) acceptFile(files[0]);
});
input.addEventListener("change", () => {
if (input.files?.length) acceptFile(input.files[0]);
});
clearBtn.addEventListener("click", () => {
input.value = "";
zone.classList.remove("has-file");
fileRow.hidden = true;
preview.hidden = true;
errorEl.hidden = true;
submitBtn.disabled = true;
});
function acceptFile(file) {
errorEl.hidden = true;
zone.classList.remove("is-rejected");
if (!/\.md$/i.test(file.name)) {
zone.classList.add("is-rejected");
errorEl.textContent = window.__f("import.rejected", file.name);
errorEl.hidden = false;
return;
}
/* Re-attach a dropped file so the real form carries it. */
if (!input.files?.length || input.files[0] !== file) {
const transfer = new DataTransfer();
transfer.items.add(file);
input.files = transfer.files;
}
zone.classList.add("has-file");
nameEl.textContent = file.name;
metaEl.textContent = formatSize(file.size);
fileRow.hidden = false;
submitBtn.disabled = false;
readPreview(file);
}
async function readPreview(file) {
const text = await file.text();
const fm = parseFrontmatter(text);
const body = text.replace(/^\+\+\+[\s\S]*?\+\+\+\r?\n?/, "");
const words = (body.match(/\S+/g) || []).length;
metaEl.textContent += " · " + window.__n("editor.words", words);
kvEl.replaceChildren();
let shown = 0;
for (const key of ["title", "slug", "tags", "date", "lang"]) {
if (!fm || !(key in fm)) continue;
const dt = document.createElement("dt");
dt.textContent = key;
const dd = document.createElement("dd");
dd.textContent = formatValue(fm[key]);
kvEl.append(dt, dd);
shown += 1;
}
noteEl.hidden = shown > 0;
preview.hidden = false;
}
/* The preview shows values the way they read, not the way TOML quotes
them: surrounding quotes go, and an array becomes a comma list. */
function formatValue(raw) {
const value = String(raw).trim();
if (value.startsWith("[") && value.endsWith("]")) {
return value.slice(1, -1)
.split(",")
.map((item) => unquote(item.trim()))
.filter(Boolean)
.join(", ");
}
return unquote(value);
}
function unquote(value) {
const quoted = value.length > 1 &&
((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'")));
return quoted ? value.slice(1, -1) : value;
}
function parseFrontmatter(text) {
const m = /^\+\+\+\r?\n([\s\S]*?)\r?\n\+\+\+/.exec(text);
if (!m) return null;
const out = {};
for (const line of m[1].split(/\r?\n/)) {
const pair = /^\s*([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(.+?)\s*$/.exec(line);
if (pair) out[pair[1]] = pair[2];
}
return out;
}
const formatSize = (bytes) =>
bytes < 1024 ? bytes + " B" : (bytes / 1024).toFixed(1) + " kB";
})();
</script>
{{end}}