feat(admin): add visibility controls and link modes
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user