Update promo admin media blocks
This commit is contained in:
+121
-15
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user