Align admin shell with launcher UI

This commit is contained in:
dcconstructions 2026-06-26 01:13:41 +03:00
parent 3e789d6887
commit cc45b1c445
5 changed files with 1115 additions and 253 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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");

View File

@ -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>

1
admin/nodedc-logo.svg Normal file
View File

@ -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

View File

@ -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 });