feat(admin): add visibility controls and link modes

This commit is contained in:
dcconstructions
2026-06-27 13:22:16 +03:00
parent 26f9e5daeb
commit 12493711d1
8 changed files with 474 additions and 134 deletions
+54
View File
@@ -1017,6 +1017,60 @@ body[data-file-picker-active="true"] .editor {
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,
.group-head-toggle input,
.collection-visibility-toggle input {
+209 -10
View File
@@ -10,6 +10,7 @@ const state = {
mediaUploadActive: false,
filePickerResetTimer: null,
deleteConfirmResolve: null,
collectionOpenState: new Map(),
};
const SECTIONS_REGION_ID = "sections";
@@ -62,26 +63,36 @@ const STATIC_FALLBACK = {
logoAlt: "главная",
brandLabel: "NODE.DC",
links: [
{ label: "О продукте", href: "index.html#features", target: "" },
{ label: "Лист ожидания", href: "index.html#hero", target: "" },
{ label: "Документация", href: "https://docs.ssscript.app/", target: "_blank" },
{ label: "Тарифы", href: "index.html#pricing-wrap", target: "" },
{ enabled: true, label: "О продукте", href: "", target: "index.html#features", linkMode: "target", linkTarget: "" },
{ enabled: true, label: "Лист ожидания", href: "", target: "index.html#hero", linkMode: "target", linkTarget: "" },
{
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: [
{
enabled: true,
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> — ИЮЛЬ 2026',
eyebrowClass: "red-txt gray",
title: "Открытая бета",
body: "Открываем доступ всем. Будем собирать и отлаживать вместе.",
},
{
enabled: true,
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> —<span class="darker-70"> </span>ИЮЛЬ 2026',
eyebrowClass: "red-txt gray",
title: "Альфа для Founder-доступа",
body: "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа.",
},
{
enabled: true,
eyebrowHtml: "СЕЙЧАС",
eyebrowClass: "red-txt",
title: "Инвайты в закрытую бету уже отправлены.",
@@ -135,11 +146,10 @@ const STATIC_FALLBACK = {
"content.navigation.links",
"Кнопки шапки",
"Шапка: ссылки",
{ label: "Новая ссылка", href: "index.html#", target: "" },
{ enabled: true, label: "Новая ссылка", href: "", target: "index.html#", linkMode: "target", linkTarget: "" },
[
field("label", "Текст кнопки", "text", "text", "Шапка: ссылки"),
field("href", "Адрес", "url", "attr", "Шапка: ссылки"),
field("target", "Target", "text", "attr", "Шапка: ссылки"),
field("linkMode", "Источник ссылки", "link-mode", "attr", "Шапка: ссылки"),
],
),
collection(
@@ -147,6 +157,7 @@ const STATIC_FALLBACK = {
"Уведомления",
"Уведомления",
{
enabled: true,
eyebrowHtml: "СЕЙЧАС",
eyebrowClass: "red-txt gray",
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 mediaFilePicker = createMediaFilePicker();
const collectionItemRuntimeKeys = new WeakMap();
let collectionItemRuntimeKeyCounter = 0;
function field(path, label, kind = "text", renderAs = kind === "html" ? "html" : "text", group = null, options = {}) {
return { path, label, kind, renderAs, group: group || inferFieldGroup(path), ...options };
@@ -332,6 +345,48 @@ function setByPath(source, path, 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) {
if (path.startsWith("content.navigation.")) 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.logoSrc ||= STATIC_FALLBACK.content.navigation.logoSrc;
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.forEach((notification, index) => {
notification.enabled = notification.enabled !== false;
notification.eyebrowClass ||= STATIC_FALLBACK.content.notifications[index]?.eyebrowClass || "red-txt gray";
});
state.page.staticElements.content.assets ||= clone(STATIC_FALLBACK.content.assets);
@@ -1467,6 +1524,42 @@ function prepareCollectionItemForInsert(items, item) {
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) {
if (input.type === "checkbox") return input.checked;
@@ -1618,6 +1711,91 @@ function renderNumberField({ block, editableField, grid }) {
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 }) {
const label = document.createElement("label");
const input = document.createElement("input");
@@ -1640,6 +1818,11 @@ function renderHeaderBooleanToggle({ block, editableField, head }) {
}
function renderCollectionScalarField({ block, field, grid }) {
if (field.kind === "link-mode") {
renderLinkModeField({ block, field, grid });
return;
}
if (field.kind === "boolean") {
renderBooleanField({ block, editableField: field, grid });
return;
@@ -1752,6 +1935,7 @@ function startCollectionPointerDrag(event, card, items, index, rerender) {
function renderCollectionField({ block, editableField, grid }) {
const items = collectionItems(block, editableField);
const openKeys = collectionOpenKeys(block, editableField, items);
const container = document.createElement("section");
const head = document.createElement("div");
const title = document.createElement("div");
@@ -1774,7 +1958,9 @@ function renderCollectionField({ block, editableField, grid }) {
addButton.setAttribute("aria-label", addButton.title);
addButton.textContent = "+";
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}».`);
});
head.append(title, addButton);
@@ -1782,6 +1968,7 @@ function renderCollectionField({ block, editableField, grid }) {
list.className = "collection-list";
items.forEach((item, index) => {
const itemKey = collectionItemKey(item, index);
const details = document.createElement("details");
const summary = document.createElement("summary");
const summaryTitle = document.createElement("span");
@@ -1797,7 +1984,11 @@ function renderCollectionField({ block, editableField, grid }) {
details.className = "collection-item-card";
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";
summaryTitle.className = "collection-summary-title";
summaryTitle.textContent = collectionItemTitle(item, index);
@@ -1851,12 +2042,15 @@ function renderCollectionField({ block, editableField, grid }) {
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("Элемент коллекции продублирован локально.");
}],
["⌫", "Удалить", async () => {
const confirmed = await requestDeleteConfirmation();
if (!confirmed) return;
openKeys.delete(itemKey);
items.splice(index, 1);
rerender("Элемент коллекции удалён локально.");
}],
@@ -1947,6 +2141,11 @@ function renderContentFields(block) {
continue;
}
if (editableField.kind === "link-mode") {
renderLinkModeField({ block, field: editableField, grid });
continue;
}
if (editableField.kind === "boolean") {
renderBooleanField({ block, editableField, grid });
continue;