Update promo admin media blocks

This commit is contained in:
dcconstructions
2026-06-26 22:04:59 +03:00
parent fb1531f7c5
commit 82cdab07f2
43 changed files with 1901 additions and 1095 deletions
+121 -15
View File
@@ -48,12 +48,16 @@ function sendJson(res, status, payload) {
send(res, status, `${JSON.stringify(payload, null, 2)}\n`, "application/json; charset=utf-8");
}
async function readBody(req) {
async function readBodyBuffer(req) {
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
return Buffer.concat(chunks).toString("utf8");
return Buffer.concat(chunks);
}
async function readBody(req) {
return (await readBodyBuffer(req)).toString("utf8");
}
function runNodeScript(scriptName) {
@@ -146,6 +150,29 @@ function buildStoredFileName(fileName, mimeType) {
return `${safeBase}-${Date.now().toString(36)}${extension}`;
}
async function saveUploadedAssetBuffer({ fileName, mimeType, bucket, fileBuffer }) {
if (typeof fileName !== "string" || !fileName || !Buffer.isBuffer(fileBuffer)) {
throw new Error("Некорректный файл загрузки");
}
const resolvedMimeType = mimeType || "application/octet-stream";
const resolvedBucket = sanitizeUploadBucket(bucket || "media");
const storedName = buildStoredFileName(fileName, resolvedMimeType);
const directory = join(uploadRoot, ...resolvedBucket.split("/"));
await mkdir(directory, { recursive: true });
await writeFile(join(directory, storedName), fileBuffer);
return {
ok: true,
url: `./assets/uploads/${resolvedBucket}/${storedName}`,
fileName: storedName,
originalFileName: fileName,
mimeType: resolvedMimeType,
bucket: resolvedBucket,
};
}
async function saveUploadedAsset(payload) {
if (!isUploadPayload(payload)) {
throw new Error("Некорректный payload загрузки");
@@ -158,21 +185,99 @@ async function saveUploadedAsset(payload) {
}
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 saveUploadedAssetBuffer({
fileName: payload.fileName,
mimeType,
bucket: payload.bucket || "media",
fileBuffer,
});
}
function isMultipartRequest(req) {
return String(req.headers["content-type"] || "").toLowerCase().startsWith("multipart/form-data");
}
function multipartBoundary(req) {
const contentType = String(req.headers["content-type"] || "");
const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType);
return match?.[1] || match?.[2] || "";
}
function parseHeaderParams(value) {
const params = {};
const parts = String(value || "").split(";");
for (const part of parts.slice(1)) {
const [rawKey, ...rawValueParts] = part.trim().split("=");
if (!rawKey || !rawValueParts.length) continue;
const rawValue = rawValueParts.join("=").trim();
params[rawKey.toLowerCase()] = rawValue.replace(/^"|"$/g, "");
}
return params;
}
function parseMultipartUpload(req, bodyBuffer) {
const boundary = multipartBoundary(req);
if (!boundary) {
throw new Error("Не найден multipart boundary");
}
const fields = {};
let filePart = null;
const body = bodyBuffer.toString("latin1");
const delimiter = `--${boundary}`;
for (const rawPart of body.split(delimiter)) {
let part = rawPart;
if (!part || part === "--" || part === "--\r\n") continue;
if (part.startsWith("\r\n")) part = part.slice(2);
if (part.endsWith("--")) part = part.slice(0, -2);
if (part.endsWith("\r\n")) part = part.slice(0, -2);
const headerEnd = part.indexOf("\r\n\r\n");
if (headerEnd < 0) continue;
const headerLines = part.slice(0, headerEnd).split("\r\n");
const headers = Object.fromEntries(
headerLines
.map((line) => {
const separator = line.indexOf(":");
if (separator < 0) return null;
return [line.slice(0, separator).trim().toLowerCase(), line.slice(separator + 1).trim()];
})
.filter(Boolean),
);
const disposition = headers["content-disposition"];
const params = parseHeaderParams(disposition);
const fieldName = params.name;
const value = part.slice(headerEnd + 4);
if (!fieldName) continue;
if (Object.prototype.hasOwnProperty.call(params, "filename")) {
filePart = {
fileName: params.filename || fields.fileName || "asset.bin",
mimeType: headers["content-type"] || fields.mimeType || "application/octet-stream",
fileBuffer: Buffer.from(value, "latin1"),
};
continue;
}
fields[fieldName] = Buffer.from(value, "latin1").toString("utf8");
}
if (!filePart) {
throw new Error("Файл не найден в multipart payload");
}
return {
ok: true,
url: `./assets/uploads/${bucket}/${storedName}`,
fileName: storedName,
originalFileName: payload.fileName,
mimeType,
bucket,
fileName: fields.fileName || filePart.fileName,
mimeType: fields.mimeType || filePart.mimeType,
bucket: fields.bucket || "media",
fileBuffer: filePart.fileBuffer,
};
}
@@ -244,8 +349,9 @@ const server = createServer(async (req, res) => {
}
if (req.method === "POST" && url.pathname === "/api/upload/asset") {
const body = await readBody(req);
const result = await saveUploadedAsset(JSON.parse(body));
const result = isMultipartRequest(req)
? await saveUploadedAssetBuffer(parseMultipartUpload(req, await readBodyBuffer(req)))
: await saveUploadedAsset(JSON.parse(await readBody(req)));
sendJson(res, 200, result);
return;
}
+8 -8
View File
@@ -63,8 +63,8 @@ const blocks = [
region: "beforeCViz",
id: "hero-waitlist",
type: "staticHtml",
template: "hero-waitlist.html",
adminLabel: "Первый экран и заявка",
template: "theses-request-demo.html",
adminLabel: "ТЕЗИСЫ + ЗАПРОС ДЕМО",
anchor: "hero",
html: html.slice(heroStart, featuresStart),
},
@@ -72,8 +72,8 @@ const blocks = [
region: "beforeCViz",
id: "feature-video",
type: "staticHtml",
template: "feature-video.html",
adminLabel: "Демо и базовая логика",
template: "project-video-first.html",
adminLabel: "PROJECT VIDEO - FIRST",
anchor: "features",
html: html.slice(featuresStart, cVizStart),
},
@@ -81,8 +81,8 @@ const blocks = [
region: "cViz",
id: "component-library",
type: "staticHtml",
template: "component-library.html",
adminLabel: "Компонентная библиотека и MCP",
template: "project-video-detail.html",
adminLabel: "PROJECT VIDEO - DETAIL",
anchor: null,
html: productSections[0],
},
@@ -90,8 +90,8 @@ const blocks = [
region: "cViz",
id: "ai-modes",
type: "staticHtml",
template: "ai-modes.html",
adminLabel: "Гибкие режимы ИИ",
template: "project-composition.html",
adminLabel: "СОСТАВ ПРОЕКТА",
anchor: null,
html: productSections[1],
},
+41 -6
View File
@@ -11,6 +11,7 @@ const blockRoot = join(templateRoot, "blocks");
const REGION_TOKEN = {
beforeCViz: "{{NODEDC_BLOCKS_BEFORE_CVIZ}}",
cViz: "{{NODEDC_BLOCKS_CVIZ}}",
sections: "{{NODEDC_BLOCKS_SECTIONS}}",
};
const page = JSON.parse(await readFile(pagePath, "utf8"));
@@ -176,14 +177,48 @@ async function renderRegion(region) {
return blocks.join("");
}
let output = renderTemplate(shell, { id: page.slug, ...page });
for (const region of page.regions ?? []) {
const token = REGION_TOKEN[region.id];
if (!token) {
throw new Error(`Unknown region: ${region.id}`);
async function renderSections() {
const renderCounts = new Map();
const output = [];
let cVizRun = [];
const flushCVizRun = () => {
if (!cVizRun.length) return;
output.push(`<div class="c-viz">${cVizRun.join("")}</div>`);
cVizRun = [];
};
for (const region of page.regions ?? []) {
for (const block of region.blocks ?? []) {
const html = await renderBlock(block, renderCounts);
if (!html) continue;
if (block.region === "cViz") {
cVizRun.push(html);
} else {
flushCVizRun();
output.push(html);
}
}
}
output = output.replace(token, await renderRegion(region));
flushCVizRun();
return output.join("");
}
let output = renderTemplate(shell, { id: page.slug, ...page });
if (output.includes(REGION_TOKEN.sections)) {
output = output.replace(REGION_TOKEN.sections, await renderSections());
} else {
for (const region of page.regions ?? []) {
const token = REGION_TOKEN[region.id];
if (!token) {
throw new Error(`Unknown region: ${region.id}`);
}
output = output.replace(token, await renderRegion(region));
}
}
for (const token of Object.values(REGION_TOKEN)) {