Align admin shell with launcher UI
This commit is contained in:
parent
3e789d6887
commit
cc45b1c445
903
admin/admin.css
903
admin/admin.css
File diff suppressed because it is too large
Load Diff
298
admin/admin.js
298
admin/admin.js
|
|
@ -132,6 +132,8 @@ const GROUP_COPY = {
|
||||||
Контент: "Остальные typed-поля выбранного блока.",
|
Контент: "Остальные typed-поля выбранного блока.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MEDIA_ACCEPT = "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv,.glb,.gltf,.svg,.avif,.webp,.woff,.woff2";
|
||||||
|
|
||||||
function field(path, label, kind = "text", renderAs = kind === "html" ? "html" : "text", group = null, options = {}) {
|
function field(path, label, kind = "text", renderAs = kind === "html" ? "html" : "text", group = null, options = {}) {
|
||||||
return { path, label, kind, renderAs, group: group || inferFieldGroup(path), ...options };
|
return { path, label, kind, renderAs, group: group || inferFieldGroup(path), ...options };
|
||||||
}
|
}
|
||||||
|
|
@ -247,6 +249,85 @@ function uniqueId(base) {
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function truncateText(value, maxLength = 22) {
|
||||||
|
if (!value) return "";
|
||||||
|
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileNameFromUrl(value) {
|
||||||
|
if (!value) return "Файл не выбран";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const clean = value.split("?")[0].split("#")[0];
|
||||||
|
return decodeURIComponent(clean.slice(clean.lastIndexOf("/") + 1)) || value;
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeBucketSegment(value) {
|
||||||
|
const safe = String(value || "site")
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[^\w.-]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
return safe || "site";
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminPreviewUrl(value) {
|
||||||
|
if (!value) return "";
|
||||||
|
if (/^(blob:|data:|https?:|\/)/i.test(value)) return value;
|
||||||
|
if (value.startsWith("./")) return `.${value}`;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaKindFromValue(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
if (/\.(mp4|webm|mov|m4v|avi|mkv)(\?.*)?$/i.test(value)) return "video";
|
||||||
|
if (/\.(png|jpe?g|gif|svg|avif|webp)(\?.*)?$/i.test(value)) return "image";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadBucketForField(block, editableField) {
|
||||||
|
const path = editableField.path.toLowerCase();
|
||||||
|
const currentValue = String(getByPath(block, editableField.path) ?? "").toLowerCase();
|
||||||
|
const blockSegment = safeBucketSegment(block?.id || block?.type || "site");
|
||||||
|
let root = "images";
|
||||||
|
|
||||||
|
if (path.includes("webgl") || currentValue.endsWith(".glb") || currentValue.endsWith(".gltf")) root = "models";
|
||||||
|
if (path.includes("video") || path.includes("media") || path.includes("sliders") || path.includes("slideritems")) root = "media";
|
||||||
|
if (path.includes("font") || /\.(woff2?|ttf|otf)(\?.*)?$/i.test(currentValue)) root = "fonts";
|
||||||
|
|
||||||
|
return `${root}/${blockSegment}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockIcon(block) {
|
||||||
|
const id = block.id || "";
|
||||||
|
const type = block.type || "";
|
||||||
|
|
||||||
|
if (id === "static-elements") return "SE";
|
||||||
|
if (id.includes("hero")) return "H";
|
||||||
|
if (id.includes("feature") || id.includes("video")) return "V";
|
||||||
|
if (id.includes("component")) return "C";
|
||||||
|
if (id.includes("modes")) return "AI";
|
||||||
|
if (id.includes("faq")) return "Q";
|
||||||
|
if (id.includes("pricing")) return "P";
|
||||||
|
if (id.includes("waitlist")) return "W";
|
||||||
|
if (id.includes("footer")) return "F";
|
||||||
|
if (type.includes("Cta")) return "GO";
|
||||||
|
return "B";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileAsDataUrl(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.addEventListener("load", () => resolve(String(reader.result)));
|
||||||
|
reader.addEventListener("error", () => reject(reader.error ?? new Error("Не удалось прочитать файл")));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function labelForRegion(region) {
|
function labelForRegion(region) {
|
||||||
if (region.id === "beforeCViz") return "Главная";
|
if (region.id === "beforeCViz") return "Главная";
|
||||||
if (region.id === "cViz") return "Секции";
|
if (region.id === "cViz") return "Секции";
|
||||||
|
|
@ -255,16 +336,22 @@ function labelForRegion(region) {
|
||||||
|
|
||||||
function renderBlockButton({ list, block, meta, active, disabled, onClick }) {
|
function renderBlockButton({ list, block, meta, active, disabled, onClick }) {
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
|
const icon = document.createElement("span");
|
||||||
|
const copy = document.createElement("span");
|
||||||
const name = document.createElement("span");
|
const name = document.createElement("span");
|
||||||
const description = document.createElement("span");
|
const description = document.createElement("span");
|
||||||
|
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.className = `block-btn${active ? " active" : ""}${disabled ? " disabled" : ""}`;
|
button.className = `block-btn${active ? " active" : ""}${disabled ? " disabled" : ""}`;
|
||||||
|
icon.className = "block-icon";
|
||||||
|
icon.textContent = blockIcon(block);
|
||||||
|
copy.className = "block-copy";
|
||||||
name.className = "block-name";
|
name.className = "block-name";
|
||||||
name.textContent = block.adminLabel || block.id;
|
name.textContent = block.adminLabel || block.id;
|
||||||
description.className = "block-meta";
|
description.className = "block-meta";
|
||||||
description.textContent = meta;
|
description.textContent = meta;
|
||||||
button.append(name, description);
|
copy.append(name, description);
|
||||||
|
button.append(icon, copy);
|
||||||
button.addEventListener("click", onClick);
|
button.addEventListener("click", onClick);
|
||||||
list.append(button);
|
list.append(button);
|
||||||
}
|
}
|
||||||
|
|
@ -396,6 +483,210 @@ function groupEditableFields(block) {
|
||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderMediaPreview(preview, value) {
|
||||||
|
preview.innerHTML = "";
|
||||||
|
|
||||||
|
const src = adminPreviewUrl(value);
|
||||||
|
const kind = mediaKindFromValue(value);
|
||||||
|
|
||||||
|
if (!src) {
|
||||||
|
preview.textContent = "FILE";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "video") {
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.src = src;
|
||||||
|
video.autoplay = true;
|
||||||
|
video.loop = true;
|
||||||
|
video.muted = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
preview.append(video);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "image") {
|
||||||
|
const image = document.createElement("img");
|
||||||
|
image.src = src;
|
||||||
|
image.alt = "";
|
||||||
|
image.loading = "lazy";
|
||||||
|
image.addEventListener("error", () => {
|
||||||
|
preview.innerHTML = "";
|
||||||
|
preview.textContent = "FILE";
|
||||||
|
});
|
||||||
|
preview.append(image);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
preview.textContent = "FILE";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMediaFieldValue({ block, editableField, hiddenInput, urlInput, fileName, preview, value }) {
|
||||||
|
hiddenInput.value = value;
|
||||||
|
urlInput.value = value;
|
||||||
|
fileName.textContent = truncateText(fileNameFromUrl(value), 28);
|
||||||
|
fileName.title = fileNameFromUrl(value);
|
||||||
|
setByPath(block, editableField.path, value);
|
||||||
|
renderMediaPreview(preview, value);
|
||||||
|
|
||||||
|
if (activeBlock() === block) {
|
||||||
|
el.json.value = JSON.stringify(block, null, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMediaField({ block, editableField, grid }) {
|
||||||
|
const value = getByPath(block, editableField.path) ?? "";
|
||||||
|
const container = document.createElement("div");
|
||||||
|
const labelRow = document.createElement("div");
|
||||||
|
const labelText = document.createElement("span");
|
||||||
|
const kind = document.createElement("span");
|
||||||
|
const hiddenInput = document.createElement("input");
|
||||||
|
const control = document.createElement("div");
|
||||||
|
const fileControl = document.createElement("div");
|
||||||
|
const fileLabel = document.createElement("label");
|
||||||
|
const fileName = document.createElement("span");
|
||||||
|
const fileInput = document.createElement("input");
|
||||||
|
const urlInput = document.createElement("input");
|
||||||
|
const sourceSwitch = document.createElement("div");
|
||||||
|
const fileSourceButton = document.createElement("button");
|
||||||
|
const urlSourceButton = document.createElement("button");
|
||||||
|
const preview = document.createElement("div");
|
||||||
|
const path = document.createElement("span");
|
||||||
|
const error = document.createElement("span");
|
||||||
|
const inputId = `media-${editableField.path.replace(/[^a-z0-9_-]+/gi, "-")}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
let source = /^(https?:)?\/\//i.test(value) ? "url" : "file";
|
||||||
|
|
||||||
|
function setSource(nextSource) {
|
||||||
|
source = nextSource;
|
||||||
|
fileControl.hidden = source !== "file";
|
||||||
|
urlInput.hidden = source !== "url";
|
||||||
|
fileSourceButton.dataset.active = String(source === "file");
|
||||||
|
urlSourceButton.dataset.active = String(source === "url");
|
||||||
|
}
|
||||||
|
|
||||||
|
container.className = "field-control wide service-media-field";
|
||||||
|
labelRow.className = "field-label-row";
|
||||||
|
labelText.className = "field-label-text";
|
||||||
|
labelText.textContent = editableField.label || editableField.path;
|
||||||
|
kind.className = "field-kind";
|
||||||
|
kind.textContent = editableField.kind || "media";
|
||||||
|
labelRow.append(labelText, kind);
|
||||||
|
|
||||||
|
hiddenInput.className = "service-media-hidden-input";
|
||||||
|
hiddenInput.type = "hidden";
|
||||||
|
hiddenInput.dataset.path = editableField.path;
|
||||||
|
hiddenInput.value = value;
|
||||||
|
|
||||||
|
control.className = "service-media-control";
|
||||||
|
|
||||||
|
fileControl.className = "service-media-file-control";
|
||||||
|
fileLabel.className = "service-media-file-button";
|
||||||
|
fileLabel.htmlFor = inputId;
|
||||||
|
fileLabel.textContent = "Выберите файл";
|
||||||
|
fileName.className = "service-media-file-name";
|
||||||
|
fileName.textContent = truncateText(fileNameFromUrl(value), 28);
|
||||||
|
fileName.title = fileNameFromUrl(value);
|
||||||
|
fileInput.id = inputId;
|
||||||
|
fileInput.type = "file";
|
||||||
|
fileInput.accept = MEDIA_ACCEPT;
|
||||||
|
fileControl.append(fileLabel, fileName, fileInput);
|
||||||
|
|
||||||
|
urlInput.className = "service-media-url-input";
|
||||||
|
urlInput.type = "text";
|
||||||
|
urlInput.placeholder = "https://...";
|
||||||
|
urlInput.autocomplete = "off";
|
||||||
|
urlInput.value = value;
|
||||||
|
|
||||||
|
sourceSwitch.className = "service-media-source-switch";
|
||||||
|
sourceSwitch.setAttribute("aria-label", `${editableField.label || editableField.path}: источник`);
|
||||||
|
fileSourceButton.className = "service-media-source-button";
|
||||||
|
fileSourceButton.type = "button";
|
||||||
|
fileSourceButton.title = "Файл с диска";
|
||||||
|
fileSourceButton.setAttribute("aria-label", "Файл с диска");
|
||||||
|
fileSourceButton.textContent = "HD";
|
||||||
|
urlSourceButton.className = "service-media-source-button";
|
||||||
|
urlSourceButton.type = "button";
|
||||||
|
urlSourceButton.title = "Внешняя ссылка";
|
||||||
|
urlSourceButton.setAttribute("aria-label", "Внешняя ссылка");
|
||||||
|
urlSourceButton.textContent = "URL";
|
||||||
|
sourceSwitch.append(fileSourceButton, urlSourceButton);
|
||||||
|
|
||||||
|
preview.className = "service-media-preview";
|
||||||
|
renderMediaPreview(preview, value);
|
||||||
|
|
||||||
|
path.className = "field-path";
|
||||||
|
path.textContent = `${editableField.path} -> assets/uploads/${uploadBucketForField(block, editableField)}`;
|
||||||
|
error.className = "media-upload-error";
|
||||||
|
|
||||||
|
fileSourceButton.addEventListener("click", () => setSource("file"));
|
||||||
|
urlSourceButton.addEventListener("click", () => {
|
||||||
|
setSource("url");
|
||||||
|
urlInput.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
urlInput.addEventListener("input", () => {
|
||||||
|
updateMediaFieldValue({
|
||||||
|
block,
|
||||||
|
editableField,
|
||||||
|
hiddenInput,
|
||||||
|
urlInput,
|
||||||
|
fileName,
|
||||||
|
preview,
|
||||||
|
value: urlInput.value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.addEventListener("change", async () => {
|
||||||
|
const file = fileInput.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
error.textContent = "";
|
||||||
|
fileName.textContent = "Сохраняем в assets...";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dataUrl = await readFileAsDataUrl(file);
|
||||||
|
const response = await fetch("/api/upload/asset", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
fileName: file.name,
|
||||||
|
mimeType: file.type || "application/octet-stream",
|
||||||
|
bucket: uploadBucketForField(block, editableField),
|
||||||
|
dataUrl,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || !payload.ok) {
|
||||||
|
throw new Error(payload.error || "Файл не удалось сохранить");
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMediaFieldValue({
|
||||||
|
block,
|
||||||
|
editableField,
|
||||||
|
hiddenInput,
|
||||||
|
urlInput,
|
||||||
|
fileName,
|
||||||
|
preview,
|
||||||
|
value: payload.url,
|
||||||
|
});
|
||||||
|
setSource("file");
|
||||||
|
setStatus(`Файл сохранён: ${payload.url}. Нажмите «Сохранить JSON», чтобы записать путь в content/pages/home.json.`);
|
||||||
|
} catch (uploadError) {
|
||||||
|
error.textContent = uploadError.message;
|
||||||
|
fileName.textContent = truncateText(fileNameFromUrl(hiddenInput.value), 28);
|
||||||
|
setStatus(`Ошибка загрузки: ${uploadError.message}`);
|
||||||
|
} finally {
|
||||||
|
fileInput.value = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setSource(source);
|
||||||
|
control.append(fileControl, urlInput, sourceSwitch, preview);
|
||||||
|
container.append(labelRow, hiddenInput, control, path, error);
|
||||||
|
grid.append(container);
|
||||||
|
}
|
||||||
|
|
||||||
function renderContentFields(block) {
|
function renderContentFields(block) {
|
||||||
el.contentFields.innerHTML = "";
|
el.contentFields.innerHTML = "";
|
||||||
|
|
||||||
|
|
@ -425,6 +716,11 @@ function renderContentFields(block) {
|
||||||
grid.className = "group-grid";
|
grid.className = "group-grid";
|
||||||
|
|
||||||
for (const editableField of fields) {
|
for (const editableField of fields) {
|
||||||
|
if (editableField.kind === "media") {
|
||||||
|
renderMediaField({ block, editableField, grid });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const label = document.createElement("label");
|
const label = document.createElement("label");
|
||||||
const labelRow = document.createElement("span");
|
const labelRow = document.createElement("span");
|
||||||
const labelText = document.createElement("span");
|
const labelText = document.createElement("span");
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,56 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="admin-backdrop" aria-hidden="true"></div>
|
<div class="admin-backdrop" aria-hidden="true"></div>
|
||||||
|
<header class="nodedc-expanded-toolbar-shell">
|
||||||
|
<div class="nodedc-expanded-toolbar">
|
||||||
|
<div class="nodedc-expanded-toolbar-top">
|
||||||
|
<div class="nodedc-expanded-toolbar-left">
|
||||||
|
<a href="/" class="nodedc-expanded-brand-link" target="_blank" aria-label="NODE.DC">
|
||||||
|
<img src="./nodedc-logo.svg" alt="NODE.DC" class="nodedc-expanded-brand-logo" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="nodedc-expanded-toolbar-center">
|
||||||
|
<span class="nodedc-expanded-workspace-button" aria-hidden="true">
|
||||||
|
<img src="../assets/custom/logo.png" alt="" class="nodedc-expanded-workspace-avatar" />
|
||||||
|
</span>
|
||||||
|
<nav class="nodedc-expanded-nav-group" aria-label="Навигация админки">
|
||||||
|
<a class="nodedc-expanded-nav-button" href="/" target="_blank" data-active="false">
|
||||||
|
<span>Витрина</span>
|
||||||
|
</a>
|
||||||
|
<button class="nodedc-expanded-nav-button" type="button" data-active="true">
|
||||||
|
<span>Администрирование</span>
|
||||||
|
</button>
|
||||||
|
<button class="nodedc-expanded-nav-button" type="button" data-active="false">
|
||||||
|
<span>Платформа</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="nodedc-expanded-toolbar-right">
|
||||||
|
<div class="nodedc-expanded-user-group">
|
||||||
|
<span class="nodedc-toolbar-icon-button" aria-hidden="true">▣</span>
|
||||||
|
<span class="nodedc-expanded-profile-trigger">
|
||||||
|
<span class="nodedc-expanded-nav-button" data-active="false">
|
||||||
|
<span>Профиль</span>
|
||||||
|
</span>
|
||||||
|
<span class="nodedc-expanded-user-avatar-button" aria-hidden="true">
|
||||||
|
<span class="nodedc-expanded-user-avatar">DC</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
<main class="admin-shell">
|
<main class="admin-shell">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-head">
|
<div class="sidebar-head">
|
||||||
<a class="back-link" href="/" target="_blank" aria-label="Открыть сайт">
|
<div>
|
||||||
<span>←</span>
|
<div class="section-kicker">NODE.DC</div>
|
||||||
<span>Открыть сайт</span>
|
<h1>Администрирование</h1>
|
||||||
</a>
|
</div>
|
||||||
<div class="workspace-card">
|
<a class="admin-panel-close" href="/" target="_blank" aria-label="Открыть сайт">×</a>
|
||||||
<div class="workspace-logo">ND</div>
|
<div class="workspace-card admin-panel-client-select">
|
||||||
|
<div class="workspace-logo admin-panel-client-select__icon">DC</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="workspace-name">NODE.DC</div>
|
<div class="workspace-name">NODE.DC</div>
|
||||||
<div class="workspace-role">Администратор сайта</div>
|
<div class="workspace-role">Администратор сайта</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
<svg id="nodedc-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220.82 54.55"><defs><style>.cls-1{fill:#e2e1e1;}.cls-2{fill:#dbdbdb;stroke:#dbdbdb;stroke-miterlimit:10;stroke-width:0.75px;}</style></defs><path class="cls-1" d="M52.8,23.61,46.92,33.76,41.05,23.61H52.8m18-10.39H23.06L46.92,54.55Z"/><polygon class="cls-1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/><path class="cls-2" d="M116.35,18.49V1h1.27l10.34,15V1h1.33V18.49H128l-10.34-15v15Z"/><path class="cls-2" d="M140.43,18.64c-4.79,0-8.16-3.72-8.16-8.89S135.64.86,140.43.86s8.17,3.72,8.17,8.89S145.25,18.64,140.43,18.64Zm0-1.25c4,0,6.79-3.17,6.79-7.64s-2.77-7.64-6.79-7.64-6.77,3.17-6.77,7.64S136.44,17.39,140.43,17.39Z"/><path class="cls-2" d="M151.6,18.49V1h5.1c5.54,0,8.79,3.42,8.79,8.74s-3.25,8.74-8.79,8.74ZM153,17.24h3.75c4.77,0,7.42-2.92,7.42-7.49s-2.65-7.49-7.42-7.49H153Z"/><path class="cls-2" d="M168.49,1h10.77V2.26h-9.42V8.93h7.89v1.25h-7.89v7.06h9.74v1.25H168.49Z"/><path class="cls-2" d="M188.88,18.49V1H194c5.54,0,8.79,3.42,8.79,8.74s-3.25,8.74-8.79,8.74Zm1.35-1.25H194c4.77,0,7.41-2.92,7.41-7.49S198.75,2.26,194,2.26h-3.75Z"/><path class="cls-2" d="M205.15,9.75c0-5.24,3.19-8.89,8.11-8.89a6.8,6.8,0,0,1,7.1,5.52h-1.43a5.54,5.54,0,0,0-5.74-4.27c-4.05,0-6.64,3.17-6.64,7.64s2.54,7.64,6.59,7.64a5.46,5.46,0,0,0,5.74-4.29h1.43c-.75,3.52-3.4,5.54-7.15,5.54C208.27,18.64,205.15,15.05,205.15,9.75Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -1,5 +1,5 @@
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { readFile, stat, writeFile } from "node:fs/promises";
|
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||||
import { extname, join, normalize } from "node:path";
|
import { extname, join, normalize } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
|
|
@ -9,6 +9,7 @@ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
const port = Number(process.env.PORT || 8090);
|
const port = Number(process.env.PORT || 8090);
|
||||||
const host = process.env.HOST || "127.0.0.1";
|
const host = process.env.HOST || "127.0.0.1";
|
||||||
const pagePath = join(repoRoot, "content", "pages", "home.json");
|
const pagePath = join(repoRoot, "content", "pages", "home.json");
|
||||||
|
const uploadRoot = join(repoRoot, "assets", "uploads");
|
||||||
|
|
||||||
const MIME = {
|
const MIME = {
|
||||||
".html": "text/html; charset=utf-8",
|
".html": "text/html; charset=utf-8",
|
||||||
|
|
@ -19,10 +20,19 @@ const MIME = {
|
||||||
".png": "image/png",
|
".png": "image/png",
|
||||||
".jpg": "image/jpeg",
|
".jpg": "image/jpeg",
|
||||||
".jpeg": "image/jpeg",
|
".jpeg": "image/jpeg",
|
||||||
|
".gif": "image/gif",
|
||||||
".avif": "image/avif",
|
".avif": "image/avif",
|
||||||
".webp": "image/webp",
|
".webp": "image/webp",
|
||||||
".mp4": "video/mp4",
|
".mp4": "video/mp4",
|
||||||
|
".webm": "video/webm",
|
||||||
|
".mov": "video/quicktime",
|
||||||
|
".m4v": "video/x-m4v",
|
||||||
|
".avi": "video/x-msvideo",
|
||||||
|
".mkv": "video/x-matroska",
|
||||||
".glb": "model/gltf-binary",
|
".glb": "model/gltf-binary",
|
||||||
|
".gltf": "model/gltf+json",
|
||||||
|
".woff": "font/woff",
|
||||||
|
".woff2": "font/woff2",
|
||||||
};
|
};
|
||||||
|
|
||||||
function send(res, status, body, contentType = "text/plain; charset=utf-8") {
|
function send(res, status, body, contentType = "text/plain; charset=utf-8") {
|
||||||
|
|
@ -71,6 +81,100 @@ function runNodeScript(scriptName) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isUploadPayload(payload) {
|
||||||
|
return (
|
||||||
|
payload &&
|
||||||
|
typeof payload.fileName === "string" &&
|
||||||
|
typeof payload.dataUrl === "string" &&
|
||||||
|
(!payload.mimeType || typeof payload.mimeType === "string") &&
|
||||||
|
(!payload.bucket || typeof payload.bucket === "string")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePathSegment(value) {
|
||||||
|
const safe = value
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[^\w.-]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
return safe || "media";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeUploadBucket(value) {
|
||||||
|
const segments = String(value || "media")
|
||||||
|
.split(/[\\/]+/)
|
||||||
|
.map((segment) => sanitizePathSegment(segment))
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 4);
|
||||||
|
|
||||||
|
return segments.length ? segments.join("/") : "media";
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionFromMimeType(mimeType) {
|
||||||
|
const map = {
|
||||||
|
"image/svg+xml": ".svg",
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/gif": ".gif",
|
||||||
|
"image/avif": ".avif",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
"video/mp4": ".mp4",
|
||||||
|
"video/webm": ".webm",
|
||||||
|
"video/quicktime": ".mov",
|
||||||
|
"model/gltf-binary": ".glb",
|
||||||
|
"model/gltf+json": ".gltf",
|
||||||
|
"font/woff": ".woff",
|
||||||
|
"font/woff2": ".woff2",
|
||||||
|
};
|
||||||
|
|
||||||
|
return map[mimeType] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStoredFileName(fileName, mimeType) {
|
||||||
|
const extension = (extname(fileName) || extensionFromMimeType(mimeType) || ".bin").toLowerCase();
|
||||||
|
const base = fileName.slice(0, extension ? -extension.length : undefined);
|
||||||
|
const safeBase =
|
||||||
|
base
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[^\w.-]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.toLowerCase()
|
||||||
|
.slice(0, 72) || "asset";
|
||||||
|
|
||||||
|
return `${safeBase}-${Date.now().toString(36)}${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUploadedAsset(payload) {
|
||||||
|
if (!isUploadPayload(payload)) {
|
||||||
|
throw new Error("Некорректный payload загрузки");
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = /^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/s.exec(payload.dataUrl);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new Error("Файл должен прийти data-url с base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = payload.mimeType || match[1] || "application/octet-stream";
|
||||||
|
const bucket = sanitizeUploadBucket(payload.bucket || "media");
|
||||||
|
const storedName = buildStoredFileName(payload.fileName, mimeType);
|
||||||
|
const fileBuffer = Buffer.from(match[2], "base64");
|
||||||
|
const directory = join(uploadRoot, ...bucket.split("/"));
|
||||||
|
|
||||||
|
await mkdir(directory, { recursive: true });
|
||||||
|
await writeFile(join(directory, storedName), fileBuffer);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
url: `./assets/uploads/${bucket}/${storedName}`,
|
||||||
|
fileName: storedName,
|
||||||
|
originalFileName: payload.fileName,
|
||||||
|
mimeType,
|
||||||
|
bucket,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function safePublicPath(urlPath) {
|
function safePublicPath(urlPath) {
|
||||||
const withoutQuery = decodeURIComponent(urlPath.split("?")[0]);
|
const withoutQuery = decodeURIComponent(urlPath.split("?")[0]);
|
||||||
const requestPath =
|
const requestPath =
|
||||||
|
|
@ -133,6 +237,13 @@ const server = createServer(async (req, res) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/upload/asset") {
|
||||||
|
const body = await readBody(req);
|
||||||
|
const result = await saveUploadedAsset(JSON.parse(body));
|
||||||
|
sendJson(res, 200, result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && url.pathname === "/api/extract/home") {
|
if (req.method === "POST" && url.pathname === "/api/extract/home") {
|
||||||
const result = await runNodeScript("extract-home-blocks.mjs");
|
const result = await runNodeScript("extract-home-blocks.mjs");
|
||||||
sendJson(res, 200, { ok: true, ...result });
|
sendJson(res, 200, { ok: true, ...result });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue