5bb488ccd0
release / release (push) Has been cancelled
build / test (push) Successful in 11m1s
build / cross-compile (amd64, linux) (push) Failing after 5m43s
build / cross-compile (arm64, darwin) (push) Failing after 5m23s
build / cross-compile (arm64, linux) (push) Failing after 5m23s
372 lines
13 KiB
Plaintext
372 lines
13 KiB
Plaintext
package ui
|
|
|
|
templ Home() {
|
|
@Layout("Markdown → standalone HTML") {
|
|
<div class="mx-auto max-w-3xl px-6 py-10">
|
|
<header class="mb-8">
|
|
<h1 class="text-2xl font-semibold tracking-tight text-foreground sm:text-[2rem]">Markdown → standalone HTML</h1>
|
|
<p class="mt-1.5 max-w-prose text-sm leading-6 text-muted-foreground">
|
|
Загрузите .md файл или вставьте Markdown-текст. Результат — готовый самодостаточный HTML со встроенными стилями.
|
|
</p>
|
|
</header>
|
|
|
|
<form
|
|
id="convert-form"
|
|
class="space-y-6"
|
|
hx-post="/ui/convert"
|
|
hx-target="#result"
|
|
hx-swap="outerHTML"
|
|
hx-encoding="multipart/form-data"
|
|
onreset="window.setTimeout(window.mdToHTMLResetForm, 0)"
|
|
>
|
|
<input id="source-field" type="hidden" name="source" value="file"/>
|
|
|
|
<section class="overflow-hidden rounded-xl border border-border bg-background shadow-xs">
|
|
<div class="border-b border-border px-4 py-4">
|
|
<div class="inline-flex items-center gap-1 rounded-lg bg-muted p-1" role="tablist" aria-label="Источник markdown">
|
|
<button
|
|
id="tab-file"
|
|
type="button"
|
|
value="file"
|
|
class="tabs-trigger"
|
|
data-state="active"
|
|
onclick="window.mdToHTMLSetSource('file')"
|
|
>
|
|
@FileIcon("size-3.5")
|
|
<span>Загрузить файл</span>
|
|
</button>
|
|
<button
|
|
id="tab-text"
|
|
type="button"
|
|
value="text"
|
|
class="tabs-trigger"
|
|
onclick="window.mdToHTMLSetSource('text')"
|
|
>
|
|
@AlignLeftIcon("size-3.5")
|
|
<span>Вставить текст</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="p-6">
|
|
<div id="panel-file" class="flex flex-col gap-4">
|
|
<label
|
|
id="markdown-dropzone"
|
|
for="markdown-file"
|
|
class="dropzone group block cursor-pointer rounded-lg border-2 border-dashed border-border p-10 text-center transition hover:border-foreground/25"
|
|
>
|
|
<input
|
|
id="markdown-file"
|
|
type="file"
|
|
name="markdown_file"
|
|
accept=".md,.markdown,.mdown,text/markdown"
|
|
class="sr-only"
|
|
onchange="window.mdToHTMLHandleFileChange(this)"
|
|
/>
|
|
<div class="mx-auto mb-3 grid size-10 place-items-center rounded-full bg-muted text-muted-foreground transition group-hover:bg-primary/5 group-hover:text-foreground">
|
|
@UploadIcon("size-5")
|
|
</div>
|
|
<div class="text-sm font-medium text-foreground">Перетащите .md файл сюда</div>
|
|
<div class="mt-1 text-xs text-muted-foreground">
|
|
или <span class="text-foreground underline underline-offset-2">выберите на диске</span>
|
|
</div>
|
|
<div class="mt-3 text-[11px] text-muted-foreground">Лимит: 200 MB · Тип: text/markdown</div>
|
|
</label>
|
|
|
|
<div id="selected-file" class="hidden items-center gap-3 rounded-lg border border-border bg-muted/40 p-3">
|
|
<div class="grid size-9 shrink-0 place-items-center rounded-md border border-border bg-background text-muted-foreground">
|
|
@FileIcon("size-4")
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<div id="selected-file-name" class="truncate text-sm font-medium text-foreground">README.md</div>
|
|
<div id="selected-file-meta" class="text-xs text-muted-foreground font-mono">3.4 KB · изменён только что</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
|
aria-label="Удалить файл"
|
|
onclick="window.mdToHTMLClearFile()"
|
|
>
|
|
@CloseIcon("size-4")
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="panel-text" class="hidden flex-col gap-3">
|
|
<label for="markdown-text" class="text-[13px] font-medium text-foreground">Markdown-текст</label>
|
|
<div class="relative">
|
|
<textarea
|
|
id="markdown-text"
|
|
name="markdown_text"
|
|
rows="10"
|
|
class="focus-ring min-h-[16rem] w-full resize-y rounded-md border border-border bg-background px-3 py-2.5 font-mono text-sm leading-6 text-foreground placeholder:text-muted-foreground"
|
|
placeholder="# Мой заголовок Здесь будет текст..."
|
|
oninput="window.mdToHTMLUpdateCharCount(this)"
|
|
></textarea>
|
|
<span id="markdown-char-count" class="pointer-events-none absolute bottom-2.5 right-3 text-[10px] text-muted-foreground font-mono">
|
|
0 символов
|
|
</span>
|
|
</div>
|
|
<p class="flex items-center gap-2 text-[11px] text-muted-foreground">
|
|
@InfoIcon("size-3.5")
|
|
<span>Поддерживается CommonMark + GFM</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-4">
|
|
<p class="text-xs text-muted-foreground">
|
|
Конвертация использует публичный <code class="font-mono text-foreground">GitHub API</code>
|
|
</p>
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
type="reset"
|
|
class="focus-ring inline-flex h-9 items-center justify-center rounded-md border border-border bg-background px-3.5 text-sm font-medium text-foreground transition hover:bg-muted"
|
|
>
|
|
Сбросить
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
class="focus-ring inline-flex h-9 items-center justify-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
|
|
>
|
|
<span>Конвертировать</span>
|
|
@ArrowRightIcon("size-4")
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</form>
|
|
|
|
<div id="result" class="mt-6"></div>
|
|
|
|
<section class="mt-6 overflow-hidden rounded-xl border border-border bg-background shadow-xs">
|
|
<div class="flex items-center justify-between border-b border-border px-5 py-3.5">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-sm font-medium text-foreground">API</span>
|
|
<span class="inline-flex items-center rounded-md border border-border bg-muted px-1.5 py-px text-[10px] font-medium text-foreground font-mono">
|
|
POST /convert
|
|
</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="focus-ring inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
|
aria-label="Скопировать curl"
|
|
data-copy-target="api-curl"
|
|
data-copy-label="curl"
|
|
onclick="window.mdToHTMLCopyButton(this)"
|
|
>
|
|
@CopyIcon("size-3.5")
|
|
</button>
|
|
</div>
|
|
<textarea id="api-curl" class="sr-only" readonly>curl -X POST http://localhost:8000/convert \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"markdown":"# Hello"}'</textarea>
|
|
<pre class="overflow-x-auto px-5 py-4 font-mono text-[12px] leading-relaxed text-foreground"><span class="text-muted-foreground">$</span> curl -X POST http://localhost:8000/convert \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"markdown":"# Hello"}'</pre>
|
|
</section>
|
|
</div>
|
|
|
|
<script>
|
|
(() => {
|
|
function byId(id) {
|
|
return document.getElementById(id);
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (bytes < 1024) {
|
|
return bytes + " B";
|
|
}
|
|
return (bytes / 1024).toFixed(1) + " KB";
|
|
}
|
|
|
|
function formatRelativeTime(lastModified) {
|
|
const delta = Date.now() - lastModified;
|
|
const minute = 60 * 1000;
|
|
const hour = 60 * minute;
|
|
const day = 24 * hour;
|
|
|
|
if (delta < minute) {
|
|
return "изменён только что";
|
|
}
|
|
if (delta < hour) {
|
|
const value = Math.max(1, Math.round(delta / minute));
|
|
return "изменён " + value + " мин назад";
|
|
}
|
|
if (delta < day) {
|
|
const value = Math.max(1, Math.round(delta / hour));
|
|
return "изменён " + value + " ч назад";
|
|
}
|
|
const value = Math.max(1, Math.round(delta / day));
|
|
return "изменён " + value + " дн назад";
|
|
}
|
|
|
|
function flashCopyState(button) {
|
|
if (!button) {
|
|
return;
|
|
}
|
|
const original = button.dataset.copyFlashOriginal || button.innerHTML;
|
|
button.dataset.copyFlashOriginal = original;
|
|
button.innerHTML = "Скопировано";
|
|
window.setTimeout(() => {
|
|
button.innerHTML = original;
|
|
}, 1400);
|
|
}
|
|
|
|
async function copyText(value) {
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
await navigator.clipboard.writeText(value);
|
|
return;
|
|
}
|
|
|
|
const helper = document.createElement("textarea");
|
|
helper.value = value;
|
|
helper.setAttribute("readonly", "readonly");
|
|
helper.style.position = "absolute";
|
|
helper.style.left = "-9999px";
|
|
document.body.appendChild(helper);
|
|
helper.select();
|
|
document.execCommand("copy");
|
|
document.body.removeChild(helper);
|
|
}
|
|
|
|
function bindDropzone() {
|
|
const dropzone = byId("markdown-dropzone");
|
|
const input = byId("markdown-file");
|
|
if (!dropzone || !input || dropzone.dataset.bound === "true") {
|
|
return;
|
|
}
|
|
|
|
const activeClasses = ["border-foreground/35", "bg-muted/60"];
|
|
dropzone.dataset.bound = "true";
|
|
|
|
dropzone.addEventListener("dragover", (event) => {
|
|
event.preventDefault();
|
|
activeClasses.forEach((className) => dropzone.classList.add(className));
|
|
});
|
|
dropzone.addEventListener("dragleave", () => {
|
|
activeClasses.forEach((className) => dropzone.classList.remove(className));
|
|
});
|
|
dropzone.addEventListener("drop", (event) => {
|
|
event.preventDefault();
|
|
activeClasses.forEach((className) => dropzone.classList.remove(className));
|
|
if (!event.dataTransfer || !event.dataTransfer.files.length) {
|
|
return;
|
|
}
|
|
input.files = event.dataTransfer.files;
|
|
window.mdToHTMLHandleFileChange(input);
|
|
});
|
|
}
|
|
|
|
window.mdToHTMLSetSource = function(source) {
|
|
const sourceField = byId("source-field");
|
|
const filePanel = byId("panel-file");
|
|
const textPanel = byId("panel-text");
|
|
const fileTab = byId("tab-file");
|
|
const textTab = byId("tab-text");
|
|
if (!sourceField || !filePanel || !textPanel || !fileTab || !textTab) {
|
|
return;
|
|
}
|
|
|
|
const showFile = source === "file";
|
|
sourceField.value = source;
|
|
filePanel.classList.toggle("hidden", !showFile);
|
|
filePanel.classList.toggle("flex", showFile);
|
|
textPanel.classList.toggle("hidden", showFile);
|
|
textPanel.classList.toggle("flex", !showFile);
|
|
fileTab.setAttribute("data-state", showFile ? "active" : "inactive");
|
|
textTab.setAttribute("data-state", showFile ? "inactive" : "active");
|
|
};
|
|
|
|
window.mdToHTMLHandleFileChange = function(input) {
|
|
const file = input && input.files && input.files[0];
|
|
const summary = byId("selected-file");
|
|
const fileName = byId("selected-file-name");
|
|
const fileMeta = byId("selected-file-meta");
|
|
if (!summary || !fileName || !fileMeta) {
|
|
return;
|
|
}
|
|
|
|
if (!file) {
|
|
summary.classList.add("hidden");
|
|
summary.classList.remove("flex");
|
|
return;
|
|
}
|
|
|
|
fileName.textContent = file.name;
|
|
fileMeta.textContent = formatBytes(file.size) + " · " + formatRelativeTime(file.lastModified);
|
|
summary.classList.remove("hidden");
|
|
summary.classList.add("flex");
|
|
};
|
|
|
|
window.mdToHTMLClearFile = function() {
|
|
const input = byId("markdown-file");
|
|
const summary = byId("selected-file");
|
|
if (input) {
|
|
input.value = "";
|
|
}
|
|
if (summary) {
|
|
summary.classList.add("hidden");
|
|
summary.classList.remove("flex");
|
|
}
|
|
};
|
|
|
|
window.mdToHTMLUpdateCharCount = function(textarea) {
|
|
const counter = byId("markdown-char-count");
|
|
if (!counter || !textarea) {
|
|
return;
|
|
}
|
|
const count = textarea.value.length;
|
|
counter.textContent = count + " символов";
|
|
};
|
|
|
|
window.mdToHTMLResetForm = function() {
|
|
window.mdToHTMLSetSource("file");
|
|
window.mdToHTMLClearFile();
|
|
const textarea = byId("markdown-text");
|
|
if (textarea) {
|
|
window.mdToHTMLUpdateCharCount(textarea);
|
|
}
|
|
const result = byId("result");
|
|
if (result) {
|
|
result.innerHTML = "";
|
|
}
|
|
};
|
|
|
|
window.mdToHTMLCopyButton = async function(button) {
|
|
if (!button) {
|
|
return;
|
|
}
|
|
const targetID = button.dataset.copyTarget;
|
|
const target = targetID ? byId(targetID) : null;
|
|
const value = target ? target.value || target.textContent || "" : "";
|
|
if (!value) {
|
|
return;
|
|
}
|
|
try {
|
|
await copyText(value);
|
|
flashCopyState(button);
|
|
} catch (_) {
|
|
// noop
|
|
}
|
|
};
|
|
|
|
function init() {
|
|
bindDropzone();
|
|
window.mdToHTMLSetSource("file");
|
|
const textarea = byId("markdown-text");
|
|
if (textarea) {
|
|
window.mdToHTMLUpdateCharCount(textarea);
|
|
}
|
|
}
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", init, { once: true });
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|
|
</script>
|
|
}
|
|
}
|