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-поля выбранного блока.",
|
||||
};
|
||||
|
||||
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 = {}) {
|
||||
return { path, label, kind, renderAs, group: group || inferFieldGroup(path), ...options };
|
||||
}
|
||||
|
|
@ -247,6 +249,85 @@ function uniqueId(base) {
|
|||
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) {
|
||||
if (region.id === "beforeCViz") return "Главная";
|
||||
if (region.id === "cViz") return "Секции";
|
||||
|
|
@ -255,16 +336,22 @@ function labelForRegion(region) {
|
|||
|
||||
function renderBlockButton({ list, block, meta, active, disabled, onClick }) {
|
||||
const button = document.createElement("button");
|
||||
const icon = document.createElement("span");
|
||||
const copy = document.createElement("span");
|
||||
const name = document.createElement("span");
|
||||
const description = document.createElement("span");
|
||||
|
||||
button.type = "button";
|
||||
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.textContent = block.adminLabel || block.id;
|
||||
description.className = "block-meta";
|
||||
description.textContent = meta;
|
||||
button.append(name, description);
|
||||
copy.append(name, description);
|
||||
button.append(icon, copy);
|
||||
button.addEventListener("click", onClick);
|
||||
list.append(button);
|
||||
}
|
||||
|
|
@ -396,6 +483,210 @@ function groupEditableFields(block) {
|
|||
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) {
|
||||
el.contentFields.innerHTML = "";
|
||||
|
||||
|
|
@ -425,6 +716,11 @@ function renderContentFields(block) {
|
|||
grid.className = "group-grid";
|
||||
|
||||
for (const editableField of fields) {
|
||||
if (editableField.kind === "media") {
|
||||
renderMediaField({ block, editableField, grid });
|
||||
continue;
|
||||
}
|
||||
|
||||
const label = document.createElement("label");
|
||||
const labelRow = document.createElement("span");
|
||||
const labelText = document.createElement("span");
|
||||
|
|
|
|||
|
|
@ -8,15 +8,56 @@
|
|||
</head>
|
||||
<body>
|
||||
<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">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-head">
|
||||
<a class="back-link" href="/" target="_blank" aria-label="Открыть сайт">
|
||||
<span>←</span>
|
||||
<span>Открыть сайт</span>
|
||||
</a>
|
||||
<div class="workspace-card">
|
||||
<div class="workspace-logo">ND</div>
|
||||
<div>
|
||||
<div class="section-kicker">NODE.DC</div>
|
||||
<h1>Администрирование</h1>
|
||||
</div>
|
||||
<a class="admin-panel-close" href="/" target="_blank" aria-label="Открыть сайт">×</a>
|
||||
<div class="workspace-card admin-panel-client-select">
|
||||
<div class="workspace-logo admin-panel-client-select__icon">DC</div>
|
||||
<div>
|
||||
<div class="workspace-name">NODE.DC</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 { readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { extname, join, normalize } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
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 host = process.env.HOST || "127.0.0.1";
|
||||
const pagePath = join(repoRoot, "content", "pages", "home.json");
|
||||
const uploadRoot = join(repoRoot, "assets", "uploads");
|
||||
|
||||
const MIME = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
|
|
@ -19,10 +20,19 @@ const MIME = {
|
|||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".avif": "image/avif",
|
||||
".webp": "image/webp",
|
||||
".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",
|
||||
".gltf": "model/gltf+json",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
};
|
||||
|
||||
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) {
|
||||
const withoutQuery = decodeURIComponent(urlPath.split("?")[0]);
|
||||
const requestPath =
|
||||
|
|
@ -133,6 +237,13 @@ const server = createServer(async (req, res) => {
|
|||
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") {
|
||||
const result = await runNodeScript("extract-home-blocks.mjs");
|
||||
sendJson(res, 200, { ok: true, ...result });
|
||||
|
|
|
|||
Loading…
Reference in New Issue