feat(admin): add visibility controls and link modes
This commit is contained in:
parent
26f9e5daeb
commit
12493711d1
|
|
@ -1017,6 +1017,60 @@ body[data-file-picker-active="true"] .editor {
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.link-mode-field {
|
||||||
|
gap: 0.48rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-mode-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.48rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-mode-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(4.5rem, 0.22fr) minmax(0, 1fr);
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
border-radius: var(--launcher-radius-control);
|
||||||
|
background: var(--field);
|
||||||
|
padding: 0.42rem 0.6rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-mode-row input[type="checkbox"] {
|
||||||
|
width: 1.18rem;
|
||||||
|
height: 1.18rem;
|
||||||
|
margin: 0;
|
||||||
|
appearance: none;
|
||||||
|
border: 0.18rem solid rgba(255, 255, 255, 0.84);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-mode-row input[type="checkbox"]:checked {
|
||||||
|
box-shadow: inset 0 0 0 0.22rem #050506;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-mode-row span {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 850;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-mode-row input[type="text"] {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 2rem;
|
||||||
|
border-radius: 0.78rem;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
padding: 0 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
.boolean-field input,
|
.boolean-field input,
|
||||||
.group-head-toggle input,
|
.group-head-toggle input,
|
||||||
.collection-visibility-toggle input {
|
.collection-visibility-toggle input {
|
||||||
|
|
|
||||||
219
admin/admin.js
219
admin/admin.js
|
|
@ -10,6 +10,7 @@ const state = {
|
||||||
mediaUploadActive: false,
|
mediaUploadActive: false,
|
||||||
filePickerResetTimer: null,
|
filePickerResetTimer: null,
|
||||||
deleteConfirmResolve: null,
|
deleteConfirmResolve: null,
|
||||||
|
collectionOpenState: new Map(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const SECTIONS_REGION_ID = "sections";
|
const SECTIONS_REGION_ID = "sections";
|
||||||
|
|
@ -62,26 +63,36 @@ const STATIC_FALLBACK = {
|
||||||
logoAlt: "главная",
|
logoAlt: "главная",
|
||||||
brandLabel: "NODE.DC",
|
brandLabel: "NODE.DC",
|
||||||
links: [
|
links: [
|
||||||
{ label: "О продукте", href: "index.html#features", target: "" },
|
{ enabled: true, label: "О продукте", href: "", target: "index.html#features", linkMode: "target", linkTarget: "" },
|
||||||
{ label: "Лист ожидания", href: "index.html#hero", target: "" },
|
{ enabled: true, label: "Лист ожидания", href: "", target: "index.html#hero", linkMode: "target", linkTarget: "" },
|
||||||
{ label: "Документация", href: "https://docs.ssscript.app/", target: "_blank" },
|
{
|
||||||
{ label: "Тарифы", href: "index.html#pricing-wrap", target: "" },
|
enabled: true,
|
||||||
|
label: "Документация",
|
||||||
|
href: "https://docs.ssscript.app/",
|
||||||
|
target: "",
|
||||||
|
linkMode: "href",
|
||||||
|
linkTarget: "_blank",
|
||||||
|
},
|
||||||
|
{ enabled: true, label: "Тарифы", href: "", target: "index.html#pricing-wrap", linkMode: "target", linkTarget: "" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
notifications: [
|
notifications: [
|
||||||
{
|
{
|
||||||
|
enabled: true,
|
||||||
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> — ИЮЛЬ 2026',
|
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> — ИЮЛЬ 2026',
|
||||||
eyebrowClass: "red-txt gray",
|
eyebrowClass: "red-txt gray",
|
||||||
title: "Открытая бета",
|
title: "Открытая бета",
|
||||||
body: "Открываем доступ всем. Будем собирать и отлаживать вместе.",
|
body: "Открываем доступ всем. Будем собирать и отлаживать вместе.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
enabled: true,
|
||||||
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> —<span class="darker-70"> </span>ИЮЛЬ 2026',
|
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> —<span class="darker-70"> </span>ИЮЛЬ 2026',
|
||||||
eyebrowClass: "red-txt gray",
|
eyebrowClass: "red-txt gray",
|
||||||
title: "Альфа для Founder-доступа",
|
title: "Альфа для Founder-доступа",
|
||||||
body: "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа.",
|
body: "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
enabled: true,
|
||||||
eyebrowHtml: "СЕЙЧАС",
|
eyebrowHtml: "СЕЙЧАС",
|
||||||
eyebrowClass: "red-txt",
|
eyebrowClass: "red-txt",
|
||||||
title: "Инвайты в закрытую бету уже отправлены.",
|
title: "Инвайты в закрытую бету уже отправлены.",
|
||||||
|
|
@ -135,11 +146,10 @@ const STATIC_FALLBACK = {
|
||||||
"content.navigation.links",
|
"content.navigation.links",
|
||||||
"Кнопки шапки",
|
"Кнопки шапки",
|
||||||
"Шапка: ссылки",
|
"Шапка: ссылки",
|
||||||
{ label: "Новая ссылка", href: "index.html#", target: "" },
|
{ enabled: true, label: "Новая ссылка", href: "", target: "index.html#", linkMode: "target", linkTarget: "" },
|
||||||
[
|
[
|
||||||
field("label", "Текст кнопки", "text", "text", "Шапка: ссылки"),
|
field("label", "Текст кнопки", "text", "text", "Шапка: ссылки"),
|
||||||
field("href", "Адрес", "url", "attr", "Шапка: ссылки"),
|
field("linkMode", "Источник ссылки", "link-mode", "attr", "Шапка: ссылки"),
|
||||||
field("target", "Target", "text", "attr", "Шапка: ссылки"),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
collection(
|
collection(
|
||||||
|
|
@ -147,6 +157,7 @@ const STATIC_FALLBACK = {
|
||||||
"Уведомления",
|
"Уведомления",
|
||||||
"Уведомления",
|
"Уведомления",
|
||||||
{
|
{
|
||||||
|
enabled: true,
|
||||||
eyebrowHtml: "СЕЙЧАС",
|
eyebrowHtml: "СЕЙЧАС",
|
||||||
eyebrowClass: "red-txt gray",
|
eyebrowClass: "red-txt gray",
|
||||||
title: "Новое уведомление",
|
title: "Новое уведомление",
|
||||||
|
|
@ -235,6 +246,8 @@ const GROUP_COPY = {
|
||||||
|
|
||||||
const MEDIA_ACCEPT = "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv,.glb,.gltf,.svg,.avif,.webp,.woff,.woff2";
|
const MEDIA_ACCEPT = "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv,.glb,.gltf,.svg,.avif,.webp,.woff,.woff2";
|
||||||
const mediaFilePicker = createMediaFilePicker();
|
const mediaFilePicker = createMediaFilePicker();
|
||||||
|
const collectionItemRuntimeKeys = new WeakMap();
|
||||||
|
let collectionItemRuntimeKeyCounter = 0;
|
||||||
|
|
||||||
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 };
|
||||||
|
|
@ -332,6 +345,48 @@ function setByPath(source, path, nextValue) {
|
||||||
target[lastKey] = nextValue;
|
target[lastKey] = nextValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LINK_WINDOW_TARGETS = new Set(["_blank", "_self", "_parent", "_top"]);
|
||||||
|
|
||||||
|
function isWindowTarget(value) {
|
||||||
|
return LINK_WINDOW_TARGETS.has(String(value || "").trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExternalHref(value) {
|
||||||
|
return /^(https?:)?\/\//i.test(String(value || "")) || /^(mailto:|tel:)/i.test(String(value || ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeChoiceLink(item, fallback = {}) {
|
||||||
|
if (!item || typeof item !== "object") return;
|
||||||
|
|
||||||
|
let href = item.href ?? fallback.href ?? "";
|
||||||
|
let target = item.target ?? fallback.target ?? "";
|
||||||
|
let linkTarget = item.linkTarget ?? fallback.linkTarget ?? "";
|
||||||
|
|
||||||
|
if (isWindowTarget(target)) {
|
||||||
|
linkTarget ||= target;
|
||||||
|
target = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const inferredMode = isExternalHref(href) ? "href" : "target";
|
||||||
|
const linkMode = item.linkMode === "href" || item.linkMode === "target" ? item.linkMode : inferredMode;
|
||||||
|
|
||||||
|
if (linkMode === "target" && !target && href && !isExternalHref(href)) {
|
||||||
|
target = href;
|
||||||
|
href = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
item.enabled = item.enabled !== false;
|
||||||
|
item.href = href;
|
||||||
|
item.target = target;
|
||||||
|
item.linkMode = linkMode;
|
||||||
|
item.linkTarget = linkTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeChoiceLinks(items, fallbacks = []) {
|
||||||
|
if (!Array.isArray(items)) return;
|
||||||
|
items.forEach((item, index) => normalizeChoiceLink(item, fallbacks[index] || fallbacks[0] || {}));
|
||||||
|
}
|
||||||
|
|
||||||
function inferFieldGroup(path) {
|
function inferFieldGroup(path) {
|
||||||
if (path.startsWith("content.navigation.")) return "Шапка";
|
if (path.startsWith("content.navigation.")) return "Шапка";
|
||||||
if (path.startsWith("content.notifications.")) return "Уведомления";
|
if (path.startsWith("content.notifications.")) return "Уведомления";
|
||||||
|
|
@ -433,8 +488,10 @@ function ensureStaticElements() {
|
||||||
state.page.staticElements.content.navigation ||= clone(STATIC_FALLBACK.content.navigation);
|
state.page.staticElements.content.navigation ||= clone(STATIC_FALLBACK.content.navigation);
|
||||||
state.page.staticElements.content.navigation.logoSrc ||= STATIC_FALLBACK.content.navigation.logoSrc;
|
state.page.staticElements.content.navigation.logoSrc ||= STATIC_FALLBACK.content.navigation.logoSrc;
|
||||||
state.page.staticElements.content.navigation.links ||= clone(STATIC_FALLBACK.content.navigation.links);
|
state.page.staticElements.content.navigation.links ||= clone(STATIC_FALLBACK.content.navigation.links);
|
||||||
|
normalizeChoiceLinks(state.page.staticElements.content.navigation.links, STATIC_FALLBACK.content.navigation.links);
|
||||||
state.page.staticElements.content.notifications ||= clone(STATIC_FALLBACK.content.notifications);
|
state.page.staticElements.content.notifications ||= clone(STATIC_FALLBACK.content.notifications);
|
||||||
state.page.staticElements.content.notifications.forEach((notification, index) => {
|
state.page.staticElements.content.notifications.forEach((notification, index) => {
|
||||||
|
notification.enabled = notification.enabled !== false;
|
||||||
notification.eyebrowClass ||= STATIC_FALLBACK.content.notifications[index]?.eyebrowClass || "red-txt gray";
|
notification.eyebrowClass ||= STATIC_FALLBACK.content.notifications[index]?.eyebrowClass || "red-txt gray";
|
||||||
});
|
});
|
||||||
state.page.staticElements.content.assets ||= clone(STATIC_FALLBACK.content.assets);
|
state.page.staticElements.content.assets ||= clone(STATIC_FALLBACK.content.assets);
|
||||||
|
|
@ -1467,6 +1524,42 @@ function prepareCollectionItemForInsert(items, item) {
|
||||||
return nextItem;
|
return nextItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectionInstanceKey(block, editableField) {
|
||||||
|
return `${block?.id || "static-elements"}::${editableField.path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectionItemKey(item, index) {
|
||||||
|
if (item && typeof item === "object") {
|
||||||
|
if (item.id) return `id:${item.id}`;
|
||||||
|
|
||||||
|
let runtimeKey = collectionItemRuntimeKeys.get(item);
|
||||||
|
if (!runtimeKey) {
|
||||||
|
collectionItemRuntimeKeyCounter += 1;
|
||||||
|
runtimeKey = `runtime:${collectionItemRuntimeKeyCounter}`;
|
||||||
|
collectionItemRuntimeKeys.set(item, runtimeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return runtimeKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `value:${index}:${String(item)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectionOpenKeys(block, editableField, items) {
|
||||||
|
const key = collectionInstanceKey(block, editableField);
|
||||||
|
let openKeys = state.collectionOpenState.get(key);
|
||||||
|
|
||||||
|
if (!openKeys) {
|
||||||
|
openKeys = new Set();
|
||||||
|
if (items.length > 0 && items.length <= 8) {
|
||||||
|
openKeys.add(collectionItemKey(items[0], 0));
|
||||||
|
}
|
||||||
|
state.collectionOpenState.set(key, openKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
return openKeys;
|
||||||
|
}
|
||||||
|
|
||||||
function fieldInputValue(input) {
|
function fieldInputValue(input) {
|
||||||
if (input.type === "checkbox") return input.checked;
|
if (input.type === "checkbox") return input.checked;
|
||||||
|
|
||||||
|
|
@ -1618,6 +1711,91 @@ function renderNumberField({ block, editableField, grid }) {
|
||||||
grid.append(label);
|
grid.append(label);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function siblingFieldPath(path, siblingKey) {
|
||||||
|
const keys = path.split(".");
|
||||||
|
keys[keys.length - 1] = siblingKey;
|
||||||
|
return keys.join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLinkModeField({ block, field, grid }) {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
const labelRow = document.createElement("div");
|
||||||
|
const labelText = document.createElement("span");
|
||||||
|
const kind = document.createElement("span");
|
||||||
|
const hiddenMode = document.createElement("input");
|
||||||
|
const rows = document.createElement("div");
|
||||||
|
const path = document.createElement("span");
|
||||||
|
const hrefPath = siblingFieldPath(field.path, field.hrefPath || "href");
|
||||||
|
const targetPath = siblingFieldPath(field.path, field.targetPath || "target");
|
||||||
|
const currentMode = getByPath(block, field.path) === "href" ? "href" : "target";
|
||||||
|
|
||||||
|
function createRow({ mode, title, inputPath, placeholder }) {
|
||||||
|
const row = document.createElement("label");
|
||||||
|
const check = document.createElement("input");
|
||||||
|
const caption = document.createElement("span");
|
||||||
|
const input = document.createElement("input");
|
||||||
|
|
||||||
|
row.className = "link-mode-row";
|
||||||
|
check.type = "checkbox";
|
||||||
|
check.checked = currentMode === mode;
|
||||||
|
check.setAttribute("aria-label", `${title}: использовать`);
|
||||||
|
caption.textContent = title;
|
||||||
|
input.dataset.path = inputPath;
|
||||||
|
input.type = "text";
|
||||||
|
input.autocomplete = "off";
|
||||||
|
input.placeholder = placeholder;
|
||||||
|
input.value = getByPath(block, inputPath) ?? "";
|
||||||
|
|
||||||
|
check.addEventListener("change", () => {
|
||||||
|
if (!check.checked) {
|
||||||
|
check.checked = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = activeBlock();
|
||||||
|
if (!active) return;
|
||||||
|
hiddenMode.value = mode;
|
||||||
|
setByPath(active, field.path, mode);
|
||||||
|
rows.querySelectorAll('input[type="checkbox"]').forEach((candidate) => {
|
||||||
|
candidate.checked = candidate === check;
|
||||||
|
});
|
||||||
|
el.json.value = JSON.stringify(active, null, 2);
|
||||||
|
markDirty(mode === "href" ? "Активен внешний адрес." : "Активен Target.");
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
const active = activeBlock();
|
||||||
|
if (!active) return;
|
||||||
|
syncFieldValue(active, inputPath, input);
|
||||||
|
});
|
||||||
|
|
||||||
|
row.append(check, caption, input);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.className = "field-control wide link-mode-field";
|
||||||
|
labelRow.className = "field-label-row";
|
||||||
|
labelText.className = "field-label-text";
|
||||||
|
labelText.textContent = field.label || "Источник ссылки";
|
||||||
|
kind.className = "field-kind";
|
||||||
|
kind.textContent = "link";
|
||||||
|
labelRow.append(labelText, kind);
|
||||||
|
|
||||||
|
hiddenMode.type = "hidden";
|
||||||
|
hiddenMode.dataset.path = field.path;
|
||||||
|
hiddenMode.value = currentMode;
|
||||||
|
rows.className = "link-mode-grid";
|
||||||
|
rows.append(
|
||||||
|
createRow({ mode: "href", title: "Адрес", inputPath: hrefPath, placeholder: "https://..." }),
|
||||||
|
createRow({ mode: "target", title: "Target", inputPath: targetPath, placeholder: "index.html#section" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
path.className = "field-path";
|
||||||
|
path.textContent = `${field.path} · ${hrefPath} · ${targetPath}`;
|
||||||
|
container.append(labelRow, hiddenMode, rows, path);
|
||||||
|
grid.append(container);
|
||||||
|
}
|
||||||
|
|
||||||
function renderHeaderBooleanToggle({ block, editableField, head }) {
|
function renderHeaderBooleanToggle({ block, editableField, head }) {
|
||||||
const label = document.createElement("label");
|
const label = document.createElement("label");
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
|
|
@ -1640,6 +1818,11 @@ function renderHeaderBooleanToggle({ block, editableField, head }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCollectionScalarField({ block, field, grid }) {
|
function renderCollectionScalarField({ block, field, grid }) {
|
||||||
|
if (field.kind === "link-mode") {
|
||||||
|
renderLinkModeField({ block, field, grid });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (field.kind === "boolean") {
|
if (field.kind === "boolean") {
|
||||||
renderBooleanField({ block, editableField: field, grid });
|
renderBooleanField({ block, editableField: field, grid });
|
||||||
return;
|
return;
|
||||||
|
|
@ -1752,6 +1935,7 @@ function startCollectionPointerDrag(event, card, items, index, rerender) {
|
||||||
|
|
||||||
function renderCollectionField({ block, editableField, grid }) {
|
function renderCollectionField({ block, editableField, grid }) {
|
||||||
const items = collectionItems(block, editableField);
|
const items = collectionItems(block, editableField);
|
||||||
|
const openKeys = collectionOpenKeys(block, editableField, items);
|
||||||
const container = document.createElement("section");
|
const container = document.createElement("section");
|
||||||
const head = document.createElement("div");
|
const head = document.createElement("div");
|
||||||
const title = document.createElement("div");
|
const title = document.createElement("div");
|
||||||
|
|
@ -1774,7 +1958,9 @@ function renderCollectionField({ block, editableField, grid }) {
|
||||||
addButton.setAttribute("aria-label", addButton.title);
|
addButton.setAttribute("aria-label", addButton.title);
|
||||||
addButton.textContent = "+";
|
addButton.textContent = "+";
|
||||||
addButton.addEventListener("click", () => {
|
addButton.addEventListener("click", () => {
|
||||||
items.push(prepareCollectionItemForInsert(items, collectionItemTemplate(editableField)));
|
const nextItem = prepareCollectionItemForInsert(items, collectionItemTemplate(editableField));
|
||||||
|
items.push(nextItem);
|
||||||
|
openKeys.add(collectionItemKey(nextItem, items.length - 1));
|
||||||
rerender(`Добавлен элемент в «${editableField.label || editableField.path}».`);
|
rerender(`Добавлен элемент в «${editableField.label || editableField.path}».`);
|
||||||
});
|
});
|
||||||
head.append(title, addButton);
|
head.append(title, addButton);
|
||||||
|
|
@ -1782,6 +1968,7 @@ function renderCollectionField({ block, editableField, grid }) {
|
||||||
list.className = "collection-list";
|
list.className = "collection-list";
|
||||||
|
|
||||||
items.forEach((item, index) => {
|
items.forEach((item, index) => {
|
||||||
|
const itemKey = collectionItemKey(item, index);
|
||||||
const details = document.createElement("details");
|
const details = document.createElement("details");
|
||||||
const summary = document.createElement("summary");
|
const summary = document.createElement("summary");
|
||||||
const summaryTitle = document.createElement("span");
|
const summaryTitle = document.createElement("span");
|
||||||
|
|
@ -1797,7 +1984,11 @@ function renderCollectionField({ block, editableField, grid }) {
|
||||||
|
|
||||||
details.className = "collection-item-card";
|
details.className = "collection-item-card";
|
||||||
details.classList.toggle("is-hidden", item?.enabled === false);
|
details.classList.toggle("is-hidden", item?.enabled === false);
|
||||||
if (index === 0 && items.length <= 8) details.open = true;
|
details.open = openKeys.has(itemKey);
|
||||||
|
details.addEventListener("toggle", () => {
|
||||||
|
if (details.open) openKeys.add(itemKey);
|
||||||
|
else openKeys.delete(itemKey);
|
||||||
|
});
|
||||||
summary.className = "collection-summary";
|
summary.className = "collection-summary";
|
||||||
summaryTitle.className = "collection-summary-title";
|
summaryTitle.className = "collection-summary-title";
|
||||||
summaryTitle.textContent = collectionItemTitle(item, index);
|
summaryTitle.textContent = collectionItemTitle(item, index);
|
||||||
|
|
@ -1851,12 +2042,15 @@ function renderCollectionField({ block, editableField, grid }) {
|
||||||
rerender("Порядок элементов коллекции изменён локально.");
|
rerender("Порядок элементов коллекции изменён локально.");
|
||||||
}],
|
}],
|
||||||
["⧉", "Дублировать", () => {
|
["⧉", "Дублировать", () => {
|
||||||
items.splice(index + 1, 0, prepareCollectionItemForInsert(items, item));
|
const nextItem = prepareCollectionItemForInsert(items, item);
|
||||||
|
items.splice(index + 1, 0, nextItem);
|
||||||
|
if (details.open) openKeys.add(collectionItemKey(nextItem, index + 1));
|
||||||
rerender("Элемент коллекции продублирован локально.");
|
rerender("Элемент коллекции продублирован локально.");
|
||||||
}],
|
}],
|
||||||
["⌫", "Удалить", async () => {
|
["⌫", "Удалить", async () => {
|
||||||
const confirmed = await requestDeleteConfirmation();
|
const confirmed = await requestDeleteConfirmation();
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
openKeys.delete(itemKey);
|
||||||
items.splice(index, 1);
|
items.splice(index, 1);
|
||||||
rerender("Элемент коллекции удалён локально.");
|
rerender("Элемент коллекции удалён локально.");
|
||||||
}],
|
}],
|
||||||
|
|
@ -1947,6 +2141,11 @@ function renderContentFields(block) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (editableField.kind === "link-mode") {
|
||||||
|
renderLinkModeField({ block, field: editableField, grid });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (editableField.kind === "boolean") {
|
if (editableField.kind === "boolean") {
|
||||||
renderBooleanField({ block, editableField, grid });
|
renderBooleanField({ block, editableField, grid });
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -970,6 +970,7 @@
|
||||||
"renderAs": "collection",
|
"renderAs": "collection",
|
||||||
"group": "FAQ",
|
"group": "FAQ",
|
||||||
"itemTemplate": {
|
"itemTemplate": {
|
||||||
|
"enabled": true,
|
||||||
"question": "Новый вопрос",
|
"question": "Новый вопрос",
|
||||||
"answerHtml": "<p>Новый ответ.</p>"
|
"answerHtml": "<p>Новый ответ.</p>"
|
||||||
},
|
},
|
||||||
|
|
@ -2420,23 +2421,19 @@
|
||||||
"renderAs": "text"
|
"renderAs": "text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "href",
|
"path": "linkMode",
|
||||||
"label": "Адрес",
|
"label": "Источник ссылки",
|
||||||
"kind": "url",
|
"kind": "link-mode",
|
||||||
"renderAs": "attr"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "target",
|
|
||||||
"label": "Target",
|
|
||||||
"kind": "text",
|
|
||||||
"renderAs": "attr"
|
"renderAs": "attr"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"itemTemplate": {
|
"itemTemplate": {
|
||||||
|
"enabled": true,
|
||||||
"label": "Новая ссылка",
|
"label": "Новая ссылка",
|
||||||
"href": "index.html#",
|
"href": "",
|
||||||
"target": "",
|
"target": "index.html#",
|
||||||
"enabled": true
|
"linkMode": "target",
|
||||||
|
"linkTarget": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -2460,23 +2457,19 @@
|
||||||
"renderAs": "text"
|
"renderAs": "text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "href",
|
"path": "linkMode",
|
||||||
"label": "Адрес",
|
"label": "Источник ссылки",
|
||||||
"kind": "url",
|
"kind": "link-mode",
|
||||||
"renderAs": "attr"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "target",
|
|
||||||
"label": "Target",
|
|
||||||
"kind": "text",
|
|
||||||
"renderAs": "attr"
|
"renderAs": "attr"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"itemTemplate": {
|
"itemTemplate": {
|
||||||
|
"enabled": true,
|
||||||
"label": "Новая ссылка",
|
"label": "Новая ссылка",
|
||||||
"href": "https://",
|
"href": "https://",
|
||||||
"target": "_blank",
|
"target": "",
|
||||||
"enabled": true
|
"linkMode": "href",
|
||||||
|
"linkTarget": "_blank"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -15,45 +15,67 @@
|
||||||
"links": [
|
"links": [
|
||||||
{
|
{
|
||||||
"label": "О продукте",
|
"label": "О продукте",
|
||||||
"href": "index.html#features",
|
"href": "",
|
||||||
"target": ""
|
"target": "index.html#features",
|
||||||
|
"enabled": true,
|
||||||
|
"linkMode": "target",
|
||||||
|
"linkTarget": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Запрос инвайта",
|
"label": "Запрос инвайта",
|
||||||
"href": "index.html#hero",
|
"href": "",
|
||||||
"target": ""
|
"target": "index.html#hero",
|
||||||
|
"enabled": true,
|
||||||
|
"linkMode": "target",
|
||||||
|
"linkTarget": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Документация",
|
"label": "Возможности",
|
||||||
"href": "https://docs.ssscript.app/",
|
"href": "https://nodedc.dctouch.ru/",
|
||||||
"target": "_blank"
|
"target": "",
|
||||||
|
"enabled": true,
|
||||||
|
"linkMode": "href",
|
||||||
|
"linkTarget": "_blank"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Тарифы",
|
"label": "Тарифы",
|
||||||
"href": "index.html#pricing-wrap",
|
"href": "",
|
||||||
"target": ""
|
"target": "index.html#pricing-wrap",
|
||||||
|
"enabled": false,
|
||||||
|
"linkMode": "target",
|
||||||
|
"linkTarget": ""
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"logoSrc": "./assets/uploads/images/static-elements/dclogo_darck-mquq1l13-transparent.png"
|
"logoSrc": "./assets/uploads/images/static-elements/dclogo_darck-mquq1l13-transparent.png"
|
||||||
},
|
},
|
||||||
"notifications": [
|
"notifications": [
|
||||||
{
|
{
|
||||||
"eyebrowHtml": "<span class=\"darker-70\">ДАЛЕЕ</span> — ИЮЛЬ 2026",
|
"eyebrowHtml": "<span class=\"darker-70\">РАНЕЕ</span> — МАЙ 2026",
|
||||||
"title": "Открытая бета",
|
"title": "Engineering Core выделен в отдельный модуль",
|
||||||
"body": "Открываем доступ всем. Будем собирать и отлаживать вместе.",
|
"body": "Единый контур инженерных моделей, версий, согласований и рабочих материалов.",
|
||||||
"eyebrowClass": "red-txt gray"
|
"eyebrowClass": "red-txt gray",
|
||||||
|
"enabled": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"eyebrowHtml": "<span class=\"darker-70\">ДАЛЕЕ</span> —<span class=\"darker-70\"> </span>ИЮЛЬ 2026",
|
"eyebrowHtml": "<span class=\"darker-70\">РАНЕЕ</span> —<span class=\"darker-70\"> </span>ИЮНЬ 2026",
|
||||||
"title": "Альфа для Founder-доступа",
|
"title": "Knowledge Graph добавлен в состав экосистемы",
|
||||||
"body": "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа.",
|
"body": "Связи между задачами, агентами, кодовой базой, документами и инженерными сущностями собраны в общем графе.",
|
||||||
"eyebrowClass": "red-txt gray"
|
"eyebrowClass": "red-txt gray",
|
||||||
|
"enabled": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"eyebrowHtml": "СЕЙЧАС",
|
"eyebrowHtml": "СЕЙЧАС",
|
||||||
"title": "Инвайты в закрытую бету уже отправлены.",
|
"title": "Открыт alpha-доступ к Procurement Intelligence",
|
||||||
"body": "Сейчас тестируем базовую функциональность и ИИ-интеграцию. ",
|
"body": "Агентный контур для закупок, сопоставления условий и контроля поставок.",
|
||||||
"eyebrowClass": "red-txt"
|
"eyebrowClass": "red-txt",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"eyebrowHtml": "<span class=\"darker-70\">ДАЛЕЕ</span> —<span class=\"darker-70\"> </span>СЕНТЯБРЬ 2026",
|
||||||
|
"eyebrowClass": "red-txt",
|
||||||
|
"title": "Field Ops включён в план релиза",
|
||||||
|
"body": "Мобильный операционный модуль для выездных команд, инспекций, фотофиксации и полевых отчётов.",
|
||||||
|
"enabled": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"dock": {
|
"dock": {
|
||||||
|
|
@ -124,9 +146,12 @@
|
||||||
"renderAs": "collection",
|
"renderAs": "collection",
|
||||||
"group": "Шапка: ссылки",
|
"group": "Шапка: ссылки",
|
||||||
"itemTemplate": {
|
"itemTemplate": {
|
||||||
|
"enabled": true,
|
||||||
"label": "Новая ссылка",
|
"label": "Новая ссылка",
|
||||||
"href": "index.html#",
|
"href": "",
|
||||||
"target": ""
|
"target": "index.html#",
|
||||||
|
"linkMode": "target",
|
||||||
|
"linkTarget": ""
|
||||||
},
|
},
|
||||||
"itemFields": [
|
"itemFields": [
|
||||||
{
|
{
|
||||||
|
|
@ -137,16 +162,9 @@
|
||||||
"group": "Шапка: ссылки"
|
"group": "Шапка: ссылки"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "href",
|
"path": "linkMode",
|
||||||
"label": "Адрес",
|
"label": "Источник ссылки",
|
||||||
"kind": "url",
|
"kind": "link-mode",
|
||||||
"renderAs": "attr",
|
|
||||||
"group": "Шапка: ссылки"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "target",
|
|
||||||
"label": "Target",
|
|
||||||
"kind": "text",
|
|
||||||
"renderAs": "attr",
|
"renderAs": "attr",
|
||||||
"group": "Шапка: ссылки"
|
"group": "Шапка: ссылки"
|
||||||
}
|
}
|
||||||
|
|
@ -159,6 +177,7 @@
|
||||||
"renderAs": "collection",
|
"renderAs": "collection",
|
||||||
"group": "Уведомления",
|
"group": "Уведомления",
|
||||||
"itemTemplate": {
|
"itemTemplate": {
|
||||||
|
"enabled": true,
|
||||||
"eyebrowHtml": "СЕЙЧАС",
|
"eyebrowHtml": "СЕЙЧАС",
|
||||||
"eyebrowClass": "red-txt gray",
|
"eyebrowClass": "red-txt gray",
|
||||||
"title": "Новое уведомление",
|
"title": "Новое уведомление",
|
||||||
|
|
@ -1761,65 +1780,79 @@
|
||||||
"main": "и ответы"
|
"main": "и ответы"
|
||||||
},
|
},
|
||||||
"introHtml": "<span class=\"darker-70\">Подробный обзор</span> возможностей, логики работы и внутреннего устройства.",
|
"introHtml": "<span class=\"darker-70\">Подробный обзор</span> возможностей, логики работы и внутреннего устройства.",
|
||||||
"docsHref": "https://docs.ssscript.app/",
|
"docsHref": "https://nodedc.dctouch.ru/",
|
||||||
"docsLabel": "Полная документация",
|
"docsLabel": "Возможности ",
|
||||||
"windowTitle": "FAQ",
|
"windowTitle": "FAQ",
|
||||||
"faq": [
|
"faq": [
|
||||||
{
|
{
|
||||||
"question": "Что входит в NODE.DC?",
|
"question": "Что входит в NODE.DC?",
|
||||||
"answerHtml": "<p>NODE.DC включает Hub для входа и управления доступами, Engine для разработки workflow и прикладной логики, Ops для операционной работы, сквозного ассистента и подключаемые модули: 1C Assistant, Tender, Digital Twin, Model Viewer / Engineering Core и отраслевые приложения.</p>"
|
"answerHtml": "<p>NODE.DC включает Hub для входа и управления доступами, Engine для разработки workflow и прикладной логики, Ops для операционной работы, сквозного ассистента и подключаемые модули: 1C Assistant, Tender, Digital Twin, Model Viewer / Engineering Core и отраслевые приложения.</p>",
|
||||||
|
"enabled": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"question": "Зачем нужен Hub?",
|
"question": "Зачем нужен Hub?",
|
||||||
"answerHtml": "<p>Hub выполняет роль лаунчера и административной точки входа. В нем отображается каталог доступных приложений, рабочие пространства, роли, группы пользователей и права доступа к модулям платформы.</p>"
|
"answerHtml": "<p>Hub выполняет роль лаунчера и административной точки входа. В нем отображается каталог доступных приложений, рабочие пространства, роли, группы пользователей и права доступа к модулям платформы.</p>",
|
||||||
|
"enabled": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"question": "Как связаны Hub, Engine и Ops?",
|
"question": "Как связаны Hub, Engine и Ops?",
|
||||||
"answerHtml": "<p>Hub отвечает за вход, роли и доступ к приложениям. Engine используется для сборки workflow, прикладной логики, интеграций и интерфейсов. Ops принимает задачи, отчеты, статусы, результаты работы модулей и действия агентов в операционный контур.</p>"
|
"answerHtml": "<p>Hub отвечает за вход, роли и доступ к приложениям. Engine используется для сборки workflow, прикладной логики, интеграций и интерфейсов. Ops принимает задачи, отчеты, статусы, результаты работы модулей и действия агентов в операционный контур.</p>",
|
||||||
|
"enabled": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"question": "Что создается в Engine?",
|
"question": "Что создается в Engine?",
|
||||||
"answerHtml": "<p>В Engine создаются workflow, интеграционные сценарии, обработчики данных, прикладная логика, интерфейсные связки и сервисные контуры. Сборка может выполняться вручную в нодовом редакторе или через ассистента по текстовому заданию.</p>"
|
"answerHtml": "<p>В Engine создаются workflow, интеграционные сценарии, обработчики данных, прикладная логика, интерфейсные связки и сервисные контуры. Сборка может выполняться вручную в нодовом редакторе или через ассистента по текстовому заданию.</p>",
|
||||||
|
"enabled": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"question": "Что фиксируется в Ops?",
|
"question": "Что фиксируется в Ops?",
|
||||||
"answerHtml": "<p>В Ops фиксируются задачи, статусы, карточки, отчеты, согласования, рабочие материалы, результаты агентов и данные из подключенных модулей. Слой предназначен для контроля процессов и операционной работы.</p>"
|
"answerHtml": "<p>В Ops фиксируются задачи, статусы, карточки, отчеты, согласования, рабочие материалы, результаты агентов и данные из подключенных модулей. Слой предназначен для контроля процессов и операционной работы.</p>",
|
||||||
|
"enabled": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"question": "Как работает сквозной ассистент?",
|
"question": "Как работает сквозной ассистент?",
|
||||||
"answerHtml": "<p>Ассистент работает как единый интерфейс к приложениям и данным платформы. В рамках прав доступа ему доступны задачи, процессы, кодовая база, проектные материалы, отчеты, инженерные модели и результаты работы агентов. Ассистент может отвечать на вопросы, декомпозировать задачи, готовить материалы и инициировать действия в связанных слоях.</p>"
|
"answerHtml": "<p>Ассистент работает как единый интерфейс к приложениям и данным платформы. В рамках прав доступа ему доступны задачи, процессы, кодовая база, проектные материалы, отчеты, инженерные модели и результаты работы агентов. Ассистент может отвечать на вопросы, декомпозировать задачи, готовить материалы и инициировать действия в связанных слоях.</p>",
|
||||||
|
"enabled": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"question": "Как работают прикладные модули?",
|
"question": "Как работают прикладные модули?",
|
||||||
"answerHtml": "<p>Прикладные модули подключаются к ролям, задачам, данным, рабочим пространствам и ассистенту. Модуль закрывает не отдельный экран, а рабочий контур: входные данные, обработку, действия, отчеты, согласования и передачу результата в Ops.</p>"
|
"answerHtml": "<p>Прикладные модули подключаются к ролям, задачам, данным, рабочим пространствам и ассистенту. Модуль закрывает не отдельный экран, а рабочий контур: входные данные, обработку, действия, отчеты, согласования и передачу результата в Ops.</p>",
|
||||||
|
"enabled": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"question": "Какие модули можно подключать?",
|
"question": "Какие модули можно подключать?",
|
||||||
"answerHtml": "<p>Базовый набор включает Hub, Engine и Ops. Дополнительно подключаются 1C Assistant, Tender, Digital Twin, Engineering Core, интеграционные сценарии, API-модули и специализированные приложения для подразделений.</p>"
|
"answerHtml": "<p>Базовый набор включает Hub, Engine и Ops. Дополнительно подключаются 1C Assistant, Tender, Digital Twin, Engineering Core, интеграционные сценарии, API-модули и специализированные приложения для подразделений.</p>",
|
||||||
|
"enabled": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"question": "Поддерживаются ли инженерные форматы?",
|
"question": "Поддерживаются ли инженерные форматы?",
|
||||||
"answerHtml": "<p>Для инженерных и производственных сценариев предусмотрена работа с цифровыми моделями и техническими материалами. Поддерживаются BIM, STEP, STL, 3DGS / Gaussian Splatting, LAS/LAZ-облака точек и другие форматы, связанные с задачами, версиями и проектным контекстом.</p>"
|
"answerHtml": "<p>Для инженерных и производственных сценариев предусмотрена работа с цифровыми моделями и техническими материалами. Поддерживаются BIM, STEP, STL, 3DGS / Gaussian Splatting, LAS/LAZ-облака точек и другие форматы, связанные с задачами, версиями и проектным контекстом.</p>",
|
||||||
|
"enabled": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"question": "Как настраиваются роли и права доступа?",
|
"question": "Как настраиваются роли и права доступа?",
|
||||||
"answerHtml": "<p>Права доступа задаются через Hub. Администратор может назначать роли, формировать группы, разделять контуры доступа по подразделениям и выдавать индивидуальные права на приложения, функции и рабочие области.</p>"
|
"answerHtml": "<p>Права доступа задаются через Hub. Администратор может назначать роли, формировать группы, разделять контуры доступа по подразделениям и выдавать индивидуальные права на приложения, функции и рабочие области.</p>",
|
||||||
|
"enabled": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"question": "Можно ли подключать корпоративные системы?",
|
"question": "Можно ли подключать корпоративные системы?",
|
||||||
"answerHtml": "<p>NODE.DC рассчитан на подключение внешних сервисов, API, внутренних приложений, 1C, баз данных, файловых хранилищ и корпоративных систем. Интеграционные сценарии собираются в Engine и передают результаты в Ops, прикладные модули или пользовательские интерфейсы.</p>"
|
"answerHtml": "<p>NODE.DC рассчитан на подключение внешних сервисов, API, внутренних приложений, 1C, баз данных, файловых хранилищ и корпоративных систем. Интеграционные сценарии собираются в Engine и передают результаты в Ops, прикладные модули или пользовательские интерфейсы.</p>",
|
||||||
|
"enabled": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"question": "Где размещается NODE.DC?",
|
"question": "Где размещается NODE.DC?",
|
||||||
"answerHtml": "<p>Доступны облачные, выделенные, гибридные и enterprise-варианты размещения. Итоговая конфигурация зависит от требований к безопасности, составу модулей, интеграциям и политике хранения данных.</p>"
|
"answerHtml": "<p>Доступны облачные, выделенные, гибридные и enterprise-варианты размещения. Итоговая конфигурация зависит от требований к безопасности, составу модулей, интеграциям и политике хранения данных.</p>",
|
||||||
|
"enabled": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"question": "Как запускается пилотный контур?",
|
"question": "Как запускается пилотный контур?",
|
||||||
"answerHtml": "<p>Пилотный контур формируется по составу модулей, ролей, интеграций и рабочих сценариев. Обычно подключается Hub, настраиваются базовые роли, выбирается один или несколько процессов, после чего собираются workflow, рабочие представления и отчеты.</p>"
|
"answerHtml": "<p>Пилотный контур формируется по составу модулей, ролей, интеграций и рабочих сценариев. Обычно подключается Hub, настраиваются базовые роли, выбирается один или несколько процессов, после чего собираются workflow, рабочие представления и отчеты.</p>",
|
||||||
|
"enabled": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"question": "Чем NODE.DC отличается от набора отдельных приложений?",
|
"question": "Чем NODE.DC отличается от набора отдельных приложений?",
|
||||||
"answerHtml": "<p>NODE.DC связывает приложения в единую платформенную архитектуру: общий доступ, роли, сквозной ассистент, связанные задачи, данные, workflow и операционный слой. Это снижает разрыв между разработкой, управлением и исполнением процессов.</p>"
|
"answerHtml": "<p>NODE.DC связывает приложения в единую платформенную архитектуру: общий доступ, роли, сквозной ассистент, связанные задачи, данные, workflow и операционный слой. Это снижает разрыв между разработкой, управлением и исполнением процессов.</p>",
|
||||||
|
"enabled": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"sender": {
|
"sender": {
|
||||||
|
|
@ -1827,7 +1860,7 @@
|
||||||
"avatarAlt": "Аватар NODE.DC",
|
"avatarAlt": "Аватар NODE.DC",
|
||||||
"name": "NODE.DC",
|
"name": "NODE.DC",
|
||||||
"replyLabel": "Ответить",
|
"replyLabel": "Ответить",
|
||||||
"sentLabel": "Отправлено с iPhone"
|
"sentLabel": "Отправлено с NODE.DC MANUAL CORE"
|
||||||
},
|
},
|
||||||
"scroll": {
|
"scroll": {
|
||||||
"speed": 0.7
|
"speed": 0.7
|
||||||
|
|
@ -1936,6 +1969,7 @@
|
||||||
"renderAs": "collection",
|
"renderAs": "collection",
|
||||||
"group": "FAQ",
|
"group": "FAQ",
|
||||||
"itemTemplate": {
|
"itemTemplate": {
|
||||||
|
"enabled": true,
|
||||||
"question": "Новый вопрос",
|
"question": "Новый вопрос",
|
||||||
"answerHtml": "<p>Новый ответ.</p>"
|
"answerHtml": "<p>Новый ответ.</p>"
|
||||||
},
|
},
|
||||||
|
|
@ -2594,7 +2628,7 @@
|
||||||
"template": "screenshot-cards.html",
|
"template": "screenshot-cards.html",
|
||||||
"adminLabel": "Карточки скриншотов",
|
"adminLabel": "Карточки скриншотов",
|
||||||
"anchor": null,
|
"anchor": null,
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
"content": {
|
"content": {
|
||||||
"galleryItems": [
|
"galleryItems": [
|
||||||
{
|
{
|
||||||
|
|
@ -3155,7 +3189,7 @@
|
||||||
"template": "demo-video-folder.html",
|
"template": "demo-video-folder.html",
|
||||||
"adminLabel": "Иконка демо-видео в доке",
|
"adminLabel": "Иконка демо-видео в доке",
|
||||||
"anchor": null,
|
"anchor": null,
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
"content": {
|
"content": {
|
||||||
"target": "demo-video",
|
"target": "demo-video",
|
||||||
"to": "features",
|
"to": "features",
|
||||||
|
|
@ -3215,38 +3249,48 @@
|
||||||
"appIconAlt": "Иконка NODE.DC",
|
"appIconAlt": "Иконка NODE.DC",
|
||||||
"wordmarkSrc": "./assets/webflow/images/69ad96a28b06b7552a339137_logtype.svg?v=node-dc",
|
"wordmarkSrc": "./assets/webflow/images/69ad96a28b06b7552a339137_logtype.svg?v=node-dc",
|
||||||
"wordmarkAlt": "Логотип NODE.DC",
|
"wordmarkAlt": "Логотип NODE.DC",
|
||||||
"copyrightHtml": "© 2026<a href=\"https://federic.ooo/\" target=\"_blank\"></a> — <a href=\"https://federic.ooo/\" target=\"_blank\">NODE.DC</a>",
|
"copyrightHtml": "© 2026<a href=\"https://www.dctouch.ru/\" target=\"_blank\"></a> — <a href=\"https://www.dctouch.ru//\" target=\"_blank\">NODE.DC</a>",
|
||||||
"navTitle": "Навигация",
|
"navTitle": "Навигация",
|
||||||
"navLinks": [
|
"navLinks": [
|
||||||
{
|
{
|
||||||
"label": "Документация",
|
"label": "Документация",
|
||||||
"href": "https://docs.ssscript.app/",
|
"href": "https://docs.ssscript.app/",
|
||||||
"target": "_blank",
|
"target": "",
|
||||||
"enabled": true
|
"enabled": false,
|
||||||
|
"linkMode": "href",
|
||||||
|
"linkTarget": "_blank"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Лист ожидания",
|
"label": "Лист ожидания",
|
||||||
"href": "index.html#waitlist",
|
"href": "",
|
||||||
"target": "",
|
"target": "index.html#waitlist",
|
||||||
"enabled": true
|
"enabled": false,
|
||||||
|
"linkMode": "target",
|
||||||
|
"linkTarget": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Тарифы",
|
"label": "Тарифы",
|
||||||
"href": "index.html#",
|
"href": "",
|
||||||
"target": "",
|
"target": "index.html#",
|
||||||
"enabled": true
|
"enabled": false,
|
||||||
|
"linkMode": "target",
|
||||||
|
"linkTarget": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Возможности",
|
"label": "Возможности",
|
||||||
"href": "index.html#features",
|
"href": "https://nodedc.dctouch.ru/",
|
||||||
"target": "",
|
"target": "index.html#features",
|
||||||
"enabled": true
|
"enabled": true,
|
||||||
|
"linkMode": "href",
|
||||||
|
"linkTarget": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Демо",
|
"label": "Демо",
|
||||||
"href": "index.html#features",
|
"href": "",
|
||||||
"target": "",
|
"target": "index.html#features",
|
||||||
"enabled": true
|
"enabled": true,
|
||||||
|
"linkMode": "target",
|
||||||
|
"linkTarget": ""
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"contactTitle": "Контакты",
|
"contactTitle": "Контакты",
|
||||||
|
|
@ -3254,30 +3298,38 @@
|
||||||
{
|
{
|
||||||
"label": "DCTOUCH",
|
"label": "DCTOUCH",
|
||||||
"href": "https://www.dctouch.ru/",
|
"href": "https://www.dctouch.ru/",
|
||||||
"target": "_blank",
|
"target": "",
|
||||||
"enabled": true
|
"enabled": true,
|
||||||
|
"linkMode": "href",
|
||||||
|
"linkTarget": "_blank"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Telegram",
|
"label": "Telegram",
|
||||||
"href": "https://t.me/dcconstructions",
|
"href": "https://t.me/dcconstructions",
|
||||||
"target": "_blank",
|
"target": "",
|
||||||
"enabled": true
|
"enabled": true,
|
||||||
|
"linkMode": "href",
|
||||||
|
"linkTarget": "_blank"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "GitHub",
|
"label": "GitHub",
|
||||||
"href": "https://www.federic.ooo/s/github",
|
"href": "https://www.federic.ooo/s/github",
|
||||||
"target": "_blank",
|
"target": "",
|
||||||
"enabled": false
|
"enabled": false,
|
||||||
|
"linkMode": "href",
|
||||||
|
"linkTarget": "_blank"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Портфолио",
|
"label": "Портфолио",
|
||||||
"href": "https://federic.ooo/",
|
"href": "https://federic.ooo/",
|
||||||
"target": "_blank",
|
"target": "",
|
||||||
"enabled": false
|
"enabled": false,
|
||||||
|
"linkMode": "href",
|
||||||
|
"linkTarget": "_blank"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"policyHtml": "<a href=\"https://docs.google.com/document/d/1vME2w8ZB88HoH-JyvkkiL7exrcMfWhkERfFRlnIeZFA/edit?usp=sharing\" target=\"_blank\">Политика конфиденциальности</a> & <a href=\"https://docs.google.com/document/d/1vME2w8ZB88HoH-JyvkkiL7exrcMfWhkERfFRlnIeZFA/edit?usp=sharing\" target=\"_blank\">Условия использования</a>",
|
"policyHtml": "<a href=\"https://docs.google.com/document/d/1vME2w8ZB88HoH-JyvkkiL7exrcMfWhkERfFRlnIeZFA/edit?usp=sharing\" target=\"_blank\">Политика конфиденциальности</a> & <a href=\"https://docs.google.com/document/d/1vME2w8ZB88HoH-JyvkkiL7exrcMfWhkERfFRlnIeZFA/edit?usp=sharing\" target=\"_blank\">Условия использования</a>",
|
||||||
"policyEnabled": true
|
"policyEnabled": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"editableFields": [
|
"editableFields": [
|
||||||
|
|
@ -3337,23 +3389,19 @@
|
||||||
"renderAs": "text"
|
"renderAs": "text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "href",
|
"path": "linkMode",
|
||||||
"label": "Адрес",
|
"label": "Источник ссылки",
|
||||||
"kind": "url",
|
"kind": "link-mode",
|
||||||
"renderAs": "attr"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "target",
|
|
||||||
"label": "Target",
|
|
||||||
"kind": "text",
|
|
||||||
"renderAs": "attr"
|
"renderAs": "attr"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"itemTemplate": {
|
"itemTemplate": {
|
||||||
|
"enabled": true,
|
||||||
"label": "Новая ссылка",
|
"label": "Новая ссылка",
|
||||||
"href": "index.html#",
|
"href": "",
|
||||||
"target": "",
|
"target": "index.html#",
|
||||||
"enabled": true
|
"linkMode": "target",
|
||||||
|
"linkTarget": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -3377,23 +3425,19 @@
|
||||||
"renderAs": "text"
|
"renderAs": "text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "href",
|
"path": "linkMode",
|
||||||
"label": "Адрес",
|
"label": "Источник ссылки",
|
||||||
"kind": "url",
|
"kind": "link-mode",
|
||||||
"renderAs": "attr"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "target",
|
|
||||||
"label": "Target",
|
|
||||||
"kind": "text",
|
|
||||||
"renderAs": "attr"
|
"renderAs": "attr"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"itemTemplate": {
|
"itemTemplate": {
|
||||||
|
"enabled": true,
|
||||||
"label": "Новая ссылка",
|
"label": "Новая ссылка",
|
||||||
"href": "https://",
|
"href": "https://",
|
||||||
"target": "_blank",
|
"target": "",
|
||||||
"enabled": true
|
"linkMode": "href",
|
||||||
|
"linkTarget": "_blank"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
<section class="s h-screen py mobile-space"><div class="c ve"><hgroup data-module="hg"><h2 class="main-h h1"><span class="darker-40">{{text:content.heading.muted}}</span><span class="darker-70">{{text:content.heading.middle}}</span><span>{{text:content.heading.main}}</span></h2><div class="h-txt">{{html:content.introHtml}}</div></hgroup><a href="{{attr:content.docsHref}}" target="_blank" class="ft-link w-inline-block"><button data-module="btn" class="form-btn static"><div data-module="alpha" class="btn-bg gray"><div class="clip-12"><div>{{text:content.docsLabel}}</div></div></div></button></a></div><div class="app-w mail"><div id="faq-item" data-module="app" class="app-item mail"><div data-draggable-area="" class="app-head preview"><div class="app-head-trigs"><button data-close="" aria-label="закрыть" class="circle opaque"><div class="x-icon"><div class="line full x-icon rot"></div><div class="line full x-icon"></div></div></button><div class="circle opaque"></div></div><div class="app-name-w"><div class="app-name-txt preview">{{text:content.windowTitle}}</div></div></div><div class="mail-app"><div><div class="mail-wrapper w-dyn-list"><div data-css="fade-tb" data-module="mail-list" data-scroll-speed="{{attr:content.scroll.speed}}" role="list" class="mail-list w-dyn-items">{{#each content.faq}}<div role="listitem" class="mail-slide w-dyn-item"><div class="mail-item"><div data-unread="" class="unread-email"><div class="dot relative email"></div></div><div class="mail-head"><div class="mail-p normal darker">{{text:content.sender.name}}</div><div class="mail-p">{{text:question}}</div></div></div></div>{{/each}}</div></div></div><div class="mail-rel-wrap"><div class="collection-list-wrapper w-dyn-list"><div data-module="mail-view" role="list" class="collection-list w-dyn-items">{{#each content.faq}}<div role="listitem" class="mail-view w-dyn-item"><div class="mail-size"><div class="mail-head-w"><div class="mail-img-w" role="img" aria-label="{{attr:content.sender.avatarAlt}}" style="background-image:url('{{attr:content.sender.avatarSrc}}')"></div><div class="mail-head"><div class="mail-p normal">{{text:content.sender.name}}</div><div class="div-block-7"><div class="mail-p smaller"><span class="darker">{{text:content.sender.replyLabel}}</span></div><div class="mail-p">{{text:question}}</div></div></div></div><div class="line ho darker"></div><div class="mail-body"><div class="mail-rt w-richtext">{{html:answerHtml}}</div></div><div class="darker sm">{{text:content.sender.sentLabel}}</div></div></div>{{/each}}</div></div></div></div></div></div></section>
|
<section class="s h-screen py mobile-space"><div class="c ve"><hgroup data-module="hg"><h2 class="main-h h1"><span class="darker-40">{{text:content.heading.muted}}</span><span class="darker-70">{{text:content.heading.middle}}</span><span>{{text:content.heading.main}}</span></h2><div class="h-txt">{{html:content.introHtml}}</div></hgroup><a href="{{attr:content.docsHref}}" target="_blank" class="ft-link w-inline-block"><button data-module="btn" class="form-btn static"><div data-module="alpha" class="btn-bg gray"><div class="clip-12"><div>{{text:content.docsLabel}}</div></div></div></button></a></div><div class="app-w mail"><div id="faq-item" data-module="app" class="app-item mail"><div data-draggable-area="" class="app-head preview"><div class="app-head-trigs"><button data-close="" aria-label="закрыть" class="circle opaque"><div class="x-icon"><div class="line full x-icon rot"></div><div class="line full x-icon"></div></div></button><div class="circle opaque"></div></div><div class="app-name-w"><div class="app-name-txt preview">{{text:content.windowTitle}}</div></div></div><div class="mail-app"><div><div class="mail-wrapper w-dyn-list"><div data-css="fade-tb" data-module="mail-list" data-scroll-speed="{{attr:content.scroll.speed}}" role="list" class="mail-list w-dyn-items">{{#each content.faq}}{{#if enabled}}<div role="listitem" class="mail-slide w-dyn-item"><div class="mail-item"><div data-unread="" class="unread-email"><div class="dot relative email"></div></div><div class="mail-head"><div class="mail-p normal darker">{{text:content.sender.name}}</div><div class="mail-p">{{text:question}}</div></div></div></div>{{/if}}{{/each}}</div></div></div><div class="mail-rel-wrap"><div class="collection-list-wrapper w-dyn-list"><div data-module="mail-view" role="list" class="collection-list w-dyn-items">{{#each content.faq}}{{#if enabled}}<div role="listitem" class="mail-view w-dyn-item"><div class="mail-size"><div class="mail-head-w"><div class="mail-img-w" role="img" aria-label="{{attr:content.sender.avatarAlt}}" style="background-image:url('{{attr:content.sender.avatarSrc}}')"></div><div class="mail-head"><div class="mail-p normal">{{text:content.sender.name}}</div><div class="div-block-7"><div class="mail-p smaller"><span class="darker">{{text:content.sender.replyLabel}}</span></div><div class="mail-p">{{text:question}}</div></div></div></div><div class="line ho darker"></div><div class="mail-body"><div class="mail-rt w-richtext">{{html:answerHtml}}</div></div><div class="darker sm">{{text:content.sender.sentLabel}}</div></div></div>{{/if}}{{/each}}</div></div></div></div></div></div></section>
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
<footer class="s footer"><div class="c footer-col"><div class="div-block-9"><div class="div-block-2"><img data-module="mouse-follow" loading="lazy" alt="{{attr:content.footer.appIconAlt}}" src="{{attr:content.footer.appIconSrc}}" class="logo app footer"/><img loading="lazy" src="{{attr:content.footer.wordmarkSrc}}" alt="{{attr:content.footer.wordmarkAlt}}" class="logo-size"/></div><div class="footer-notice mt">{{html:content.footer.copyrightHtml}}</div></div><div class="footer-cols"><div class="div-block-3"><h2 class="footer-list-he">{{text:content.footer.navTitle}}</h2><ul data-group="hero" data-module="list" role="list" class="w-list-unstyled">{{#each content.footer.navLinks}}{{#if enabled}}<li><a data-module="link" href="{{attr:href}}" target="{{attr:target}}" class="ft-link w-inline-block"><div class="clip-12"><div>{{text:label}}</div></div></a></li>{{/if}}{{/each}}</ul></div><div class="div-block-3"><h2 data-module="text-wave" class="footer-list-he">{{text:content.footer.contactTitle}}</h2><ul data-group="hero" data-module="list" role="list" class="w-list-unstyled">{{#each content.footer.contactLinks}}{{#if enabled}}<li><a data-module="link" href="{{attr:href}}" target="{{attr:target}}" class="ft-link w-inline-block"><div class="clip-12"><div>{{text:label}}</div></div></a></li>{{/if}}{{/each}}</ul></div></div></div>{{#if content.footer.policyEnabled}}<div class="c footer-col"><div class="footer-notice mt">{{html:content.footer.policyHtml}}</div></div>{{/if}}</footer>
|
<footer class="s footer"><div class="c footer-col"><div class="div-block-9"><div class="div-block-2"><img data-module="mouse-follow" loading="lazy" alt="{{attr:content.footer.appIconAlt}}" src="{{attr:content.footer.appIconSrc}}" class="logo app footer"/><img loading="lazy" src="{{attr:content.footer.wordmarkSrc}}" alt="{{attr:content.footer.wordmarkAlt}}" class="logo-size"/></div><div class="footer-notice mt">{{html:content.footer.copyrightHtml}}</div></div><div class="footer-cols"><div class="div-block-3"><h2 class="footer-list-he">{{text:content.footer.navTitle}}</h2><ul data-group="hero" data-module="list" role="list" class="w-list-unstyled">{{#each content.footer.navLinks}}{{#if enabled}}<li><a data-module="link" href="{{attr:resolvedHref}}" target="{{attr:resolvedTarget}}" class="ft-link w-inline-block"><div class="clip-12"><div>{{text:label}}</div></div></a></li>{{/if}}{{/each}}</ul></div><div class="div-block-3"><h2 data-module="text-wave" class="footer-list-he">{{text:content.footer.contactTitle}}</h2><ul data-group="hero" data-module="list" role="list" class="w-list-unstyled">{{#each content.footer.contactLinks}}{{#if enabled}}<li><a data-module="link" href="{{attr:resolvedHref}}" target="{{attr:resolvedTarget}}" class="ft-link w-inline-block"><div class="clip-12"><div>{{text:label}}</div></div></a></li>{{/if}}{{/each}}</ul></div></div></div>{{#if content.footer.policyEnabled}}<div class="c footer-col"><div class="footer-notice mt">{{html:content.footer.policyHtml}}</div></div>{{/if}}</footer>
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -68,6 +68,55 @@ function applySharedDemoRequest(model) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LINK_WINDOW_TARGETS = new Set(["_blank", "_self", "_parent", "_top"]);
|
||||||
|
|
||||||
|
function isWindowTarget(value) {
|
||||||
|
return LINK_WINDOW_TARGETS.has(String(value || "").trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExternalHref(value) {
|
||||||
|
return /^(https?:)?\/\//i.test(String(value || "")) || /^(mailto:|tel:)/i.test(String(value || ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLinkForRender(item) {
|
||||||
|
if (!item || typeof item !== "object") return;
|
||||||
|
|
||||||
|
let href = item.href ?? "";
|
||||||
|
let target = item.target ?? "";
|
||||||
|
let linkTarget = item.linkTarget ?? "";
|
||||||
|
|
||||||
|
if (isWindowTarget(target)) {
|
||||||
|
linkTarget ||= target;
|
||||||
|
target = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = item.linkMode === "href" || item.linkMode === "target" ? item.linkMode : isExternalHref(href) ? "href" : "target";
|
||||||
|
|
||||||
|
if (mode === "target" && !target && href && !isExternalHref(href)) {
|
||||||
|
target = href;
|
||||||
|
href = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryHref = mode === "href" ? href : target;
|
||||||
|
const fallbackHref = mode === "href" ? target : href;
|
||||||
|
item.resolvedHref = primaryHref || fallbackHref || "#";
|
||||||
|
item.resolvedTarget = mode === "href" ? linkTarget : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRenderableLinks(model) {
|
||||||
|
const staticLinks = model.staticElements?.content?.navigation?.links;
|
||||||
|
if (Array.isArray(staticLinks)) {
|
||||||
|
staticLinks.forEach(normalizeLinkForRender);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const block of allBlocks(model)) {
|
||||||
|
const footer = block.content?.footer;
|
||||||
|
if (!footer) continue;
|
||||||
|
if (Array.isArray(footer.navLinks)) footer.navLinks.forEach(normalizeLinkForRender);
|
||||||
|
if (Array.isArray(footer.contactLinks)) footer.contactLinks.forEach(normalizeLinkForRender);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
return String(value)
|
return String(value)
|
||||||
.replaceAll("&", "&")
|
.replaceAll("&", "&")
|
||||||
|
|
@ -232,6 +281,7 @@ async function renderSections() {
|
||||||
}
|
}
|
||||||
|
|
||||||
applySharedDemoRequest(page);
|
applySharedDemoRequest(page);
|
||||||
|
applyRenderableLinks(page);
|
||||||
|
|
||||||
let output = renderTemplate(shell, { id: page.slug, ...page });
|
let output = renderTemplate(shell, { id: page.slug, ...page });
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue