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
190 lines
6.8 KiB
HTML
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 + toml frontmatter ( +++ )</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}}
|